Merge pull request #672 from Tria-plc/freight_feature/usermanagement

train
This commit is contained in:
marshal
2026-07-14 14:09:18 +03:00
committed by GitHub
64 changed files with 4896 additions and 404 deletions

View File

@@ -26,6 +26,14 @@ export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
export const WagonTransferRequest = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferRequest);
/** OCC fulfils a wagon-transfer request — picks the wagons and executes the move. */
export const WagonTransferFulfill = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

@@ -0,0 +1,105 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Train Builder: a `Train` becomes a first-class buildable consist — a coded
* train (e.g. 81001) assembled in one yard from 2+ locomotives and ordered
* wagons, then reused by scheduling ("schedule the train" instead of picking
* locomotives per departure).
*
* - `freight.train_locomotives` — link table train ⇄ locomotive with an order
* index (mirrors `train_set_locomotives`).
* - `trains.current_yard_id` — yard the train sits in; wagons/locomotives may
* only be attached from this yard.
* - `train_sets.train_id` — which built train an operational set was formed
* from, so schedules can surface the train code and the lifecycle can sync
* the train's status/yard on dispatch/arrival/cancel.
*
* 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 TrainBuilder2150000000000 implements MigrationInterface {
name = 'TrainBuilder2150000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_locomotives (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
train_id uuid NOT NULL,
locomotive_id uuid NOT NULL,
sequence_no int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "PK_train_locomotives" PRIMARY KEY (id),
CONSTRAINT "FK_train_locomotives_train" FOREIGN KEY (train_id)
REFERENCES freight.trains (id) ON DELETE CASCADE,
CONSTRAINT "FK_train_locomotives_locomotive" FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives (id)
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_locomotives_train_loco"
ON freight.train_locomotives (train_id, locomotive_id);
`);
await queryRunner.query(`
ALTER TABLE freight.trains
ADD COLUMN IF NOT EXISTS current_yard_id uuid;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_trains_current_yard'
) THEN
ALTER TABLE freight.trains
ADD CONSTRAINT "FK_trains_current_yard" FOREIGN KEY (current_yard_id)
REFERENCES freight.yards (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_trains_current_yard_id"
ON freight.trains (current_yard_id);
`);
await queryRunner.query(`
ALTER TABLE freight.train_sets
ADD COLUMN IF NOT EXISTS train_id uuid;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_train_sets_train'
) THEN
ALTER TABLE freight.train_sets
ADD CONSTRAINT "FK_train_sets_train" FOREIGN KEY (train_id)
REFERENCES freight.trains (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_train_sets_train_id"
ON freight.train_sets (train_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_train_sets_train_id";`);
await queryRunner.query(`
ALTER TABLE freight.train_sets
DROP CONSTRAINT IF EXISTS "FK_train_sets_train",
DROP COLUMN IF EXISTS train_id;
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_trains_current_yard_id";`);
await queryRunner.query(`
ALTER TABLE freight.trains
DROP CONSTRAINT IF EXISTS "FK_trains_current_yard",
DROP COLUMN IF EXISTS current_yard_id;
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_train_locomotives_train_loco";`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_locomotives;`);
}
}

View File

@@ -0,0 +1,119 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A container type / cargo type can now be carried by SEVERAL wagon types
* (e.g. a 20ft container rides NX70 or NW5). Replaces the single
* `wagon_type_id` FK on both tables with proper link tables; train scheduling
* resolves the wagon type from the list, picking whichever type the schedule's
* built train (or the yard) actually has.
*
* Backfills one link row from each existing `wagon_type_id`, then drops the
* old column — the single-FK field is removed from the API and UI entirely.
*
* 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 MultiWagonTypePerCargoAndContainer2160000000000 implements MigrationInterface {
name = 'MultiWagonTypePerCargoAndContainer2160000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.container_type_wagon_types (
container_type_id uuid NOT NULL,
wagon_type_id uuid NOT NULL,
CONSTRAINT "PK_container_type_wagon_types" PRIMARY KEY (container_type_id, wagon_type_id),
CONSTRAINT "FK_ctwt_container_type" FOREIGN KEY (container_type_id)
REFERENCES freight.container_types (id) ON DELETE CASCADE,
CONSTRAINT "FK_ctwt_wagon_type" FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.cargo_type_wagon_types (
cargo_type_id uuid NOT NULL,
wagon_type_id uuid NOT NULL,
CONSTRAINT "PK_cargo_type_wagon_types" PRIMARY KEY (cargo_type_id, wagon_type_id),
CONSTRAINT "FK_cgwt_cargo_type" FOREIGN KEY (cargo_type_id)
REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
CONSTRAINT "FK_cgwt_wagon_type" FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
);
`);
// Backfill from the old single FK (column may already be gone on re-run).
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'container_types'
AND column_name = 'wagon_type_id'
) THEN
INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
SELECT ct.id, ct.wagon_type_id
FROM freight.container_types ct
WHERE ct.wagon_type_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
END $$;
`);
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'freight' AND table_name = 'cargo_types'
AND column_name = 'wagon_type_id'
) THEN
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
SELECT cg.id, cg.wagon_type_id
FROM freight.cargo_types cg
WHERE cg.wagon_type_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
END $$;
`);
// Old single-FK column is fully retired (API + UI now use the lists).
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
`);
// Restore the first linked wagon type per row, then drop the link tables.
await queryRunner.query(`
UPDATE freight.container_types ct
SET wagon_type_id = link.wagon_type_id
FROM (
SELECT DISTINCT ON (container_type_id) container_type_id, wagon_type_id
FROM freight.container_type_wagon_types
ORDER BY container_type_id, wagon_type_id
) link
WHERE link.container_type_id = ct.id;
`);
await queryRunner.query(`
UPDATE freight.cargo_types cg
SET wagon_type_id = link.wagon_type_id
FROM (
SELECT DISTINCT ON (cargo_type_id) cargo_type_id, wagon_type_id
FROM freight.cargo_type_wagon_types
ORDER BY cargo_type_id, wagon_type_id
) link
WHERE link.cargo_type_id = cg.id;
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.container_type_wagon_types;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargo_type_wagon_types;`);
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Two-person wagon-transfer request queue. A requester records a count-only
* request (N wagons of a type, from yard → to yard); OCC staff later pick the
* physical wagons and execute the move. Replaces the single-step instant
* bulk-transfer as the customer-facing yard-to-yard relocation path.
*/
export class CreateWagonTransferRequests2170000000000
implements MigrationInterface
{
name = 'CreateWagonTransferRequests2170000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_transfer_requests (
id uuid NOT NULL DEFAULT gen_random_uuid(),
from_yard_id uuid NOT NULL,
to_yard_id uuid NOT NULL,
wagon_type_id uuid NOT NULL,
quantity integer NOT NULL,
status varchar(20) NOT NULL DEFAULT 'PENDING',
requested_by_user_id uuid NULL,
fulfilled_by_user_id uuid NULL,
fulfilled_at timestamptz NULL,
note text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_wagon_transfer_requests PRIMARY KEY (id),
CONSTRAINT fk_wtr_from_yard FOREIGN KEY (from_yard_id) REFERENCES freight.yards (id),
CONSTRAINT fk_wtr_to_yard FOREIGN KEY (to_yard_id) REFERENCES freight.yards (id),
CONSTRAINT fk_wtr_wagon_type FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types (id),
CONSTRAINT chk_wtr_quantity CHECK (quantity > 0)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wtr_status_from_yard
ON freight.wagon_transfer_requests (status, from_yard_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_wtr_status_from_yard`,
);
await queryRunner.query(
`DROP TABLE IF EXISTS freight.wagon_transfer_requests`,
);
}
}

View File

@@ -1255,10 +1255,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationYard: true,
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
// containerType.wagonType + cargoType.wagonType drive wagon-type
// resolution during scheduling (FK, not the old load-type string map).
bookingContainers: { containerType: { wagonType: true }, units: true },
cargoType: { wagonType: true },
// containerType.wagonTypes + cargoType.wagonTypes drive wagon-type
// resolution during scheduling (many-to-many lists — the plan mixes
// wagon types within one consist).
bookingContainers: { containerType: { wagonTypes: true }, units: true },
cargoType: { wagonTypes: true },
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@@ -22,12 +22,15 @@ export class CreateCargoTypeDto {
parentGroupId?: string;
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description:
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
'Wagon types that can carry this (bulk) cargo. Drives train scheduling wagon-type resolution; at least one is required for bulk commodities that are scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@IsArray()
@IsUUID('4', { each: true })
wagonTypeIds?: string[];
@ApiPropertyOptional({ default: false })
@IsOptional()

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class CreateContainerTypeDto {
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
@@ -31,12 +31,15 @@ export class CreateContainerTypeDto {
isOpenTop?: boolean;
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description:
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
'Wagon types that can carry this container. Drives train scheduling wagon-type resolution; at least one is required when this container type is scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@IsArray()
@IsUUID('4', { each: true })
wagonTypeIds?: string[];
@ApiPropertyOptional({ default: true })
@IsOptional()

View File

@@ -1,13 +1,21 @@
import { BaseEntity } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
} from 'typeorm';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'cargo_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['parentGroupId'])
@Index(['wagonTypeId'])
@Index(['code'])
export class CargoType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
@@ -28,17 +36,19 @@ export class CargoType extends BaseEntity {
unitOfMeasure?: CargoUnitOfMeasure | null;
/**
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
* through this FK. Nullable — grouping rows and container/legacy cargo never
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
* Wagon types that can carry this (bulk) cargo. Train scheduling resolves the
* bulk wagon type through this list, picking whichever type the schedule's
* train (or yard) actually has. Grouping rows and container/legacy cargo
* leave it empty; scheduling throws if a scheduled bulk cargo type has none.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@ManyToMany(() => WagonType)
@JoinTable({
name: 'cargo_type_wagon_types',
schema: 'freight',
joinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' },
})
wagonTypes?: WagonType[];
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -1,12 +1,11 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinTable, ManyToMany, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['code'])
@Index(['isActive'])
@Index(['wagonTypeId'])
export class ContainerType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@@ -27,17 +26,19 @@ export class ContainerType extends BaseEntity {
isOpenTop!: boolean;
/**
* Wagon type that carries this container. Replaces the former hardcoded
* container wagon-code default (NW5): train scheduling resolves the container
* wagon type through this FK. Nullable; scheduling throws if a scheduled
* container type leaves it unset.
* Wagon types that can carry this container (e.g. a 20ft rides NX70 or NW5).
* Train scheduling resolves the container wagon type through this list,
* picking whichever type the schedule's train (or yard) actually has.
* Scheduling throws if a scheduled container type has none configured.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@ManyToMany(() => WagonType)
@JoinTable({
name: 'container_type_wagon_types',
schema: 'freight',
joinColumn: { name: 'container_type_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' },
})
wagonTypes?: WagonType[];
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -15,7 +15,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
}
findById(id: string): Promise<CargoType | null> {
return this.repo.findOne({ where: { id }, relations: { parent: true } });
return this.repo.findOne({ where: { id }, relations: { parent: true, wagonTypes: true } });
}
findByCode(code: string): Promise<CargoType | null> {
@@ -35,6 +35,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
const qb = this.repo
.createQueryBuilder('cargoType')
.leftJoinAndSelect('cargoType.parent', 'parent')
.leftJoinAndSelect('cargoType.wagonTypes', 'wagonType')
.orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
@@ -63,7 +64,18 @@ export class CargoTypesRepository implements ICargoTypesRepository {
}
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
await this.repo.update(id, data as never);
// Relation lists can't ride a column UPDATE — sync them via entity save.
const { wagonTypes, ...columns } = data;
if (Object.keys(columns).length) {
await this.repo.update(id, columns as never);
}
if (wagonTypes) {
const entity = await this.repo.findOne({ where: { id } });
if (entity) {
entity.wagonTypes = wagonTypes;
await this.repo.save(entity);
}
}
return this.findById(id);
}

View File

@@ -15,7 +15,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
}
findById(id: string): Promise<ContainerType | null> {
return this.repo.findOne({ where: { id } });
return this.repo.findOne({ where: { id }, relations: { wagonTypes: true } });
}
findByCode(code: string): Promise<ContainerType | null> {
@@ -34,6 +34,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
const qb = this.repo
.createQueryBuilder('containerType')
.leftJoinAndSelect('containerType.wagonTypes', 'wagonType')
.orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
@@ -54,7 +55,18 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
}
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
await this.repo.update(id, data as never);
// Relation lists can't ride a column UPDATE — sync them via entity save.
const { wagonTypes, ...columns } = data;
if (Object.keys(columns).length) {
await this.repo.update(id, columns as never);
}
if (wagonTypes) {
const entity = await this.repo.findOne({ where: { id } });
if (entity) {
entity.wagonTypes = wagonTypes;
await this.repo.save(entity);
}
}
return this.findById(id);
}

View File

@@ -6,6 +6,7 @@ import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
@@ -59,7 +60,8 @@ export class CargoTypesService {
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeId: dto.wagonTypeId ?? null,
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
displayOrder,
});
}
@@ -72,7 +74,13 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
const updated = await this.repository.update(id, dto);
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
const updated = await this.repository.update(id, {
...columns,
...(wagonTypeIds
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
: {}),
});
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
return updated;
}

View File

@@ -6,6 +6,7 @@ import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
@@ -51,7 +52,8 @@ export class ContainerTypesService {
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
wagonTypeId: dto.wagonTypeId ?? null,
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
displayOrder,
});
}
@@ -59,7 +61,13 @@ export class ContainerTypesService {
/** Update an existing container type. */
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
const updated = await this.repository.update(id, {
...columns,
...(wagonTypeIds
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
: {}),
});
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
return updated;
}

View File

@@ -29,6 +29,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
trainSet: {
locomotive: true,
locomotives: { locomotive: true },
train: true,
wagons: {
wagonType: true,
physicalWagon: true,

View File

@@ -886,7 +886,7 @@ describe('BookingBatchService — wagonsFor', () => {
};
it('charges a bulk booking the tare of ITS wagon type, not the representative', () => {
const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } });
const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } });
const need = service.needFor(booking, dimsWithTypes);
expect(need.wagons).toBe(30);
expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation
@@ -905,7 +905,7 @@ describe('BookingBatchService — wagonsFor', () => {
{
quantity: 2,
wagonsRequired: 2,
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' },
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
},
],
};

View File

@@ -2913,21 +2913,23 @@ export class BookingBatchService implements OnModuleInit {
/**
* Dimensions of the wagon type THIS booking rides: bulk resolves through its
* cargo type's wagon_type_id, container through the first container line's
* type — the same FK resolution `resolveWagonType` applies when the paid
* booking is allocated. Board/fill math measured on a representative wagon
* while allocation validated the real one let a selected batch flunk the
* post-payment gross-weight check; sharing the resolution closes that gap.
* Falls back to the representative dims when the FK or relation is absent.
* cargo type's allowed wagon-type list, container through the first container
* line's — the same list resolution the scheduling planner applies when the
* paid booking is allocated. Board/fill math measured on a representative
* wagon while allocation validated the real one let a selected batch flunk
* the post-payment gross-weight check; sharing the resolution closes that
* gap. Uses the first configured type (the fill engine has no train context);
* falls back to the representative dims when the list or relation is absent.
*/
private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims {
const fallback =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const wagonTypeId =
booking.freightType === "BULK"
? booking.cargoType?.wagonTypeId
? booking.cargoType?.wagonTypes?.[0]?.id
: (booking.bookingContainers ?? [])
.map((line) => line.containerType?.wagonTypeId)
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wagonType) => wagonType.id)
.find((id): id is string => Boolean(id));
const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined;
if (!dims) return fallback;

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AvailableTrainsQueryDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
routeId!: string;
}

View File

@@ -20,15 +20,26 @@ export class CreateContainerTrainScheduleDto {
@IsDateString()
scheduleDate!: string;
@ApiProperty({
@ApiPropertyOptional({
format: 'uuid',
description:
'Built train (Train Builder) to run this departure — its locomotive set is used. Provide either trainId or locomotiveIds.',
})
@IsOptional()
@IsUUID()
trainId?: string;
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back)',
description:
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
})
@IsOptional()
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
locomotiveIds?: string[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()

View File

@@ -39,6 +39,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
@@ -153,6 +154,18 @@ export class TrainSchedulingController {
);
}
@Get("available-trains")
@TrainSchedulingView()
@ApiOperation({
summary:
"List built trains (Train Builder) schedulable on a route, annotated with yard position and future runs",
})
getAvailableTrains(@Query() query: AvailableTrainsQueryDto) {
return this.trainSchedulingService.getAvailableTrainsForRoute(
query.routeId,
);
}
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.

View File

@@ -72,7 +72,7 @@ const makeBooking = (
wagonsRequired,
vgmPerUnitTons: weight / quantity,
isOverweight: false,
containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id },
containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] },
},
],
...extra,
@@ -80,7 +80,7 @@ const makeBooking = (
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock };
@@ -91,7 +91,12 @@ describe('TrainSchedulingService', () => {
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
beforeEach(() => {
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
query: jest.fn().mockResolvedValue([]),
};
bookingsRepository = {
findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(),
@@ -259,8 +264,10 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
expect(result.summary.wagonsNeeded).toBe(45);
expect(result.wagonPlan).toHaveLength(45);
// TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) —
// the planner packs by container size, not the stored per-line fallback.
expect(result.summary.wagonsNeeded).toBe(40);
expect(result.wagonPlan).toHaveLength(40);
});
it('returns soft hold warnings without forceAssign', async () => {
@@ -293,7 +300,7 @@ describe('TrainSchedulingService', () => {
wagonsRequired: 80,
vgmPerUnitTons: 45,
isOverweight: true,
containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id },
containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] },
},
],
}),
@@ -655,10 +662,12 @@ describe('TrainSchedulingService', () => {
destinationStationId: 'yard-djibouti',
});
expect(result.valid).toBe(false);
expect(
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
).toBe(true);
// List-based planner: a booking with no plannable wagon at the yard is
// DEFERRED with the wagon-type reason (assign still hard-fails when no
// booking fits), instead of surfacing a phantom-slot violation.
expect(result.valid).toBe(true);
expect(result.wagonPlan).toHaveLength(0);
expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true);
});
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {

View File

@@ -0,0 +1,299 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
expandBookingContainerUnits,
roundTons,
tareTonsOf,
teuSlotsForSizeFt,
type SlotLoadType,
type WagonPlanSlot,
} from './wagon-plan.util';
/**
* Wagon types allowed to carry each container type / bulk cargo type — the
* many-to-many configuration lists, resolved once per validation run.
*/
export type AllowedWagonTypeMap = {
byContainerTypeId: Map<string, WagonType[]>;
byCargoTypeId: Map<string, WagonType[]>;
};
/**
* Plannable wagon inventory. TRAIN mode is the built train's own consist —
* a hard cap, the plan never reaches for loose yard wagons. YARD mode is the
* AVAILABLE pool at the boarding yards (legacy schedules).
*/
export type WagonStock = {
mode: 'TRAIN' | 'YARD';
/** Remaining plannable wagons per wagon type id. Missing type = 0. */
remainingByTypeId: Map<string, number>;
/** Wagon-type code per id, for human-readable shortfall messages. */
codesByTypeId: Map<string, string>;
};
export type FlexPlanResult = {
plan: WagonPlanSlot[];
fitting: Booking[];
deferred: DeferredBookingRow[];
/**
* Misconfiguration (a scheduled type with no wagon types configured) —
* a hard violation, unlike stock shortfalls which merely defer bookings.
*/
configIssues: string[];
};
type OpenSlot = {
slot: WagonPlanSlot;
teuUsed: number;
kind: SlotLoadType;
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
cargoTypeId: string | null;
freeCapacityTons: number;
};
type PlacementProblem = { kind: 'config' | 'stock'; message: string };
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
sequenceNo: 0, // stamped at the end
wagonTypeId: wagonType.id,
wagonTypeCode: wagonType.code,
capacityTons: Number(wagonType.capacityTons),
lengthMeters: Number(wagonType.lengthMeters),
tareWeightTons: tareTonsOf(wagonType),
assignedWeightTons: 0,
allocations: [],
slotLoadType: kind,
});
const addAllocation = (
slot: WagonPlanSlot,
bookingId: string,
bookingReference: string,
weightTons: number,
loadType: AllocationLoadType,
) => {
let allocation = slot.allocations.find((a) => a.bookingId === bookingId);
if (!allocation) {
allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType };
slot.allocations.push(allocation);
}
allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons);
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons);
};
/**
* Build the wagon plan against a wagon-type inventory, mixing wagon types
* within one consist. Each booking is atomic: it either fits entirely (its
* containers/tonnage placed on wagons whose type is allowed for its container
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
* two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon
* with a different cargo type.
*/
export function planWagonsWithStock(params: {
bookings: Booking[];
allowed: AllowedWagonTypeMap;
stock: WagonStock;
}): FlexPlanResult {
const { bookings, allowed, stock } = params;
const remaining = new Map(stock.remainingByTypeId);
const openSlots: OpenSlot[] = [];
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
const configIssues = new Set<string>();
const noStockMessage = (candidates: WagonType[]): string => {
const codes = candidates.map((wt) => wt.code).join('/');
return stock.mode === 'TRAIN'
? `Train has no free ${codes} wagon left`
: `No available ${codes} wagon at the yard`;
};
/** Open a new wagon of one of the candidate types, consuming stock. */
const openSlot = (
candidates: WagonType[],
kind: SlotLoadType,
cargoTypeId: string | null,
): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order.
const chosen = [...inStock].sort((a, b) =>
kind === 'BULK'
? Number(b.capacityTons) - Number(a.capacityTons) ||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
)[0];
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
teuUsed: 0,
kind,
cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons),
};
openSlots.push(open);
return open;
};
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
if (booking.freightType === 'CONTAINER') {
const units = expandBookingContainerUnits([booking]);
if (!units.length) {
// Degenerate container booking with no lines still reserves one wagon
// (legacy behavior) — but there is no container type to resolve against.
return {
kind: 'config',
message: `Booking ${booking.reference} has no container lines to plan`,
};
}
for (const unit of units) {
const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? [];
if (!candidates.length) {
return {
kind: 'config',
message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`,
};
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
let target = openSlots.find(
(open) =>
open.kind === 'CONTAINER' &&
allowedIds.has(open.slot.wagonTypeId) &&
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
);
if (!target) {
const openedSlot = openSlot(candidates, 'CONTAINER', null);
if ('message' in openedSlot) return openedSlot;
target = openedSlot;
}
addAllocation(
target.slot,
unit.bookingId,
unit.bookingReference,
unit.grossWeightTons,
AllocationLoadType.Container,
);
target.teuUsed += teu;
}
return null;
}
// BULK — weight-based, one cargo type per wagon.
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null;
const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
if (!candidates.length) {
return {
kind: 'config',
message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`,
};
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
let placedAnywhere = false;
// Top off wagons already carrying THIS cargo type before opening new ones.
for (const open of openSlots) {
if (remainingWeight <= 0) break;
if (open.kind !== 'BULK') continue;
if (open.cargoTypeId !== cargoTypeId) continue;
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
if (open.freeCapacityTons <= 0) continue;
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
addAllocation(
open.slot,
booking.id,
booking.reference,
take,
AllocationLoadType.Bulk,
);
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true;
}
while (remainingWeight > 0 || !placedAnywhere) {
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
if ('message' in openedSlot) return openedSlot;
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
addAllocation(
openedSlot.slot,
booking.id,
booking.reference,
take,
AllocationLoadType.Bulk,
);
openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take);
remainingWeight = roundTons(remainingWeight - take);
placedAnywhere = true;
}
return null;
};
for (const booking of sortBookingsForScheduling(bookings)) {
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
const remainingSnapshot = new Map(remaining);
const slotCountSnapshot = openSlots.length;
const slotStateSnapshot = openSlots.map((open) => ({
teuUsed: open.teuUsed,
freeCapacityTons: open.freeCapacityTons,
assignedWeightTons: open.slot.assignedWeightTons,
allocationCount: open.slot.allocations.length,
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
}));
const problem = tryPlaceBooking(booking);
if (!problem) {
fitting.push(booking);
continue;
}
// Roll back this booking's partial placements.
remaining.clear();
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
openSlots.length = slotCountSnapshot;
openSlots.forEach((open, index) => {
const snap = slotStateSnapshot[index];
if (!snap) return;
open.teuUsed = snap.teuUsed;
open.freeCapacityTons = snap.freeCapacityTons;
open.slot.assignedWeightTons = snap.assignedWeightTons;
open.slot.allocations.length = snap.allocationCount;
snap.allocationWeights.forEach((weight, allocationIndex) => {
open.slot.allocations[allocationIndex].allocatedWeightTons = weight;
});
});
if (problem.kind === 'config') configIssues.add(problem.message);
deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
}
return {
plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })),
fitting,
deferred,
configIssues: [...configIssues],
};
}
/** Unbounded stock — used to compute pure demand for availability reporting. */
export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock {
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const list of [
...allowed.byContainerTypeId.values(),
...allowed.byCargoTypeId.values(),
]) {
for (const wagonType of list) {
remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER);
codesByTypeId.set(wagonType.id, wagonType.code);
}
}
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}

View File

@@ -514,7 +514,7 @@ export function validateTrainLimits(
*/
export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonTypes: WagonType[],
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits?: TrainLimitConfig,
): string[] {
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;

View File

@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { Train } from '../../trains/entities/train.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetLocomotive } from './train-set-locomotive.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
@@ -32,6 +33,14 @@ export class TrainSet extends BaseEntity {
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];
/** Built fleet train this set was formed from (Train Builder), when scheduled by train. */
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId!: string | null;
@ManyToOne(() => Train, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'train_id' })
train?: Train | null;
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class AssignTrainWagonsDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Wagons to append to the consist, in order. Each must be AVAILABLE in the train\'s yard.',
})
@IsArray()
@ArrayMinSize(1)
@IsUUID('all', { each: true })
wagonIds!: string[];
}

View File

@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
export class BuildTrainDto {
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
@IsString()
@MaxLength(32)
code!: string;
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
@IsUUID()
currentYardId!: string;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Wagons to attach at build time, in consist order (must sit in the same yard)',
})
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
wagonIds?: string[];
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
trainName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,22 @@
import { Freight } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class ListBuiltTrainsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: Freight.TrainStatus })
@IsOptional()
@IsEnum(Freight.TrainStatus)
status?: Freight.TrainStatus;
@ApiPropertyOptional({ format: 'uuid', description: 'Only trains sitting in this yard' })
@IsOptional()
@IsUUID()
currentYardId?: string;
@ApiPropertyOptional({ enum: ['code', 'trainName', 'status', 'createdAt'] })
@IsOptional()
@IsIn(['code', 'trainName', 'status', 'createdAt'])
sortBy?: 'code' | 'trainName' | 'status' | 'createdAt';
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class ReorderTrainWagonsDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Every wagon of the train, in the new consist order',
})
@IsArray()
@ArrayMinSize(1)
@IsUUID('all', { each: true })
wagonIds!: string[];
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class UpdateTrainLocomotivesDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Full replacement locomotive set (minimum 2), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { Train } from './train.entity';
/**
* Link row joining a built train to one of its locomotives. A train must be
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
* in the consist — 0 is the lead locomotive.
*
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built
* in the Train Builder rather than the per-departure operational train set.
*/
@Entity({ schema: 'freight', name: 'train_locomotives' })
@Index(['trainId', 'locomotiveId'], { unique: true })
export class TrainLocomotive extends BaseEntity {
@Column({ name: 'train_id', type: 'uuid' })
trainId!: string;
@ManyToOne(() => Train, (train) => train.locomotives, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_id' })
train?: Train;
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'sequence_no', type: 'int', default: 0 })
sequenceNo!: number;
}

View File

@@ -1,12 +1,16 @@
// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, OneToMany } from 'typeorm';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { TrainLocomotive } from './train-locomotive.entity';
/**
* Fleet master data — named wagon consist in inventory (POST /trains).
* Operational departures use train_schedules + locomotives; scheduling never creates trains rows.
* Fleet master data — a train built in the Train Builder: a coded consist
* (e.g. 81001) of 2+ locomotives and ordered wagons, assembled in one yard.
* Operational departures reference it through `train_sets.train_id`; the
* schedule's own composition still lives on the train set.
*/
@Entity({ schema: 'freight', name: 'trains' })
export class Train extends BaseEntity {
@@ -56,7 +60,19 @@ export class Train extends BaseEntity {
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string;
/** Yard where the train currently sits (set at build, moved on schedule arrival). */
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
currentYardId!: string | null;
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'current_yard_id' })
currentYard?: Yard | null;
// --- relationships ---
@OneToMany(() => Wagon, (wagon) => wagon.train)
wagons!: Wagon[]; // fixed typo: was 'wagens'
@OneToMany(() => Wagon, (wagon) => wagon.train)
wagons!: Wagon[];
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
@OneToMany(() => TrainLocomotive, (link) => link.train)
locomotives?: TrainLocomotive[];
}

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@ApiBearerAuth()
@Controller('train-builder')
@FleetView()
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
build(@Body() dto: BuildTrainDto) {
return this.trainBuilderService.buildTrain(dto);
}
@Get()
@ApiOperation({ summary: 'Paginated built trains with composition summary' })
list(@Query() query: ListBuiltTrainsQueryDto) {
return this.trainBuilderService.listBuilt(query);
}
@Get(':id')
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
composition(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.getComposition(id);
}
@Put(':id/locomotives')
@FleetManage()
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainLocomotivesDto,
) {
return this.trainBuilderService.setLocomotives(id, dto);
}
@Post(':id/wagons')
@FleetManage()
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
return this.trainBuilderService.assignWagons(id, dto);
}
@Delete(':id/wagons/:wagonId')
@FleetManage()
@ApiOperation({ summary: 'Detach one wagon from the consist' })
removeWagon(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
) {
return this.trainBuilderService.removeWagon(id, wagonId);
}
@Post(':id/reorder-wagons')
@FleetManage()
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
return this.trainBuilderService.reorderWagons(id, dto);
}
@Delete(':id')
@FleetManage()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
disband(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.disband(id);
}
}

View File

@@ -0,0 +1,507 @@
import { Freight, WagonStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
/**
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
* ordered wagons, all in one yard) that scheduling can later reference as a
* unit instead of hand-picking locomotives per departure.
*
* Resource rules:
* - Locomotive double-use is prevented through the `train_locomotives` link
* table (a locomotive rides at most one built train); its `status` column
* keeps its operational meaning (ASSIGNED = out on a dispatched train).
* - Wagons attached to a train are flipped to ASSIGNED (same semantic the
* legacy assign-train flow uses), so no other train or schedule grabs them.
*/
@Injectable()
export class TrainBuilderService {
constructor(private readonly dataSource: DataSource) {}
async buildTrain(dto: BuildTrainDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
const trainId = await this.dataSource.transaction(async (manager) => {
const code = dto.code.trim();
const existing = await manager.getRepository(Train).findOne({ where: { code } });
if (existing) {
throw new ConflictException(`Train code ${code} is already in use`);
}
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
const locomotives = await this.validateAndLockLocomotives(
manager,
locomotiveIds,
yard,
null,
);
// Effective haul capacity is capped by the weakest locomotive in the set.
const limits = minLocomotiveLimits(locomotives);
const train = await manager.getRepository(Train).save(
manager.getRepository(Train).create({
code,
currentYardId: yard.id,
capacityTons: round(limits?.maxPullWeightTons ?? 0),
status: Freight.TrainStatus.Available,
trainName: dto.trainName?.trim() || undefined,
notes: dto.notes?.trim() || undefined,
}),
);
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
if (dto.wagonIds?.length) {
await this.attachWagons(manager, train, dto.wagonIds, 0);
}
return train.id;
});
return this.getComposition(trainId);
}
/** Paginated builder list with a composition summary per train. */
async listBuilt(query: ListBuiltTrainsQueryDto) {
const { page, pageSize, skip, take } = normalizePagination(query);
const search = query.search?.trim();
const filters = {
...(query.status ? { status: query.status } : {}),
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
};
const where = search
? [
{ ...filters, code: ILike(`%${search}%`) },
{ ...filters, trainName: ILike(`%${search}%`) },
]
: filters;
const [trains, total] = await this.dataSource.getRepository(Train).findAndCount({
where,
relations: {
currentYard: true,
locomotives: { locomotive: true },
wagons: { wagonType: true },
},
order: { [query.sortBy ?? 'createdAt']: query.sortOrder ?? 'DESC' },
skip,
take,
});
return {
items: trains.map((train) => this.mapSummary(train)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({
where: { id },
relations: {
currentYard: true,
locomotives: { locomotive: { currentYard: true } },
wagons: { wagonType: true, currentYard: true },
},
order: {
locomotives: { sequenceNo: 'ASC' },
wagons: { sequenceNumber: 'ASC' },
},
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
const schedules: { id: string; status: string; reference: string | null }[] =
await this.dataSource.query(
`SELECT ts.id, ts.status, ts.reference
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`,
[id],
);
const locomotives = (train.locomotives ?? [])
.filter((link) => link.locomotive)
.map((link, index) => ({
id: link.locomotive!.id,
code: link.locomotive!.code,
name: link.locomotive!.name ?? null,
locomotiveType: link.locomotive!.locomotiveType,
status: link.locomotive!.status,
sequenceNo: link.sequenceNo,
role: index === 0 ? 'LEAD' : 'ASSIST',
currentYardId: link.locomotive!.currentYardId ?? null,
currentYard: link.locomotive!.currentYard
? {
id: link.locomotive!.currentYard.id,
code: link.locomotive!.currentYard.code,
label: link.locomotive!.currentYard.label,
}
: null,
maxPullWeightTons: round(link.locomotive!.maxPullWeightTons),
maxTrainLengthMeters: round(link.locomotive!.maxTrainLengthMeters),
}));
const wagons = (train.wagons ?? []).map((wagon) => ({
id: wagon.id,
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
status: wagon.status,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
capacityTons: round(wagon.wagonType.capacityTons),
tareWeightTons: round(wagon.wagonType.tareWeightTons),
lengthMeters: round(wagon.wagonType.lengthMeters),
}
: null,
}));
const limits = minLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
);
const totalTareTons = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ?? 0), 0),
);
const totalCapacityTons = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.capacityTons ?? 0), 0),
);
const totalLengthMeters = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
);
const maxGrossTons = round(totalTareTons + totalCapacityTons);
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
return {
id: train.id,
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
notes: train.notes ?? null,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
: null,
locomotives,
wagons,
totals: {
wagonCount: wagons.length,
totalTareTons,
totalCapacityTons,
maxGrossTons,
totalLengthMeters,
maxPullWeightTons,
maxTrainLengthMeters,
// Fully loaded gross vs. what the weakest locomotive can haul.
weightUtilizationPct: maxPullWeightTons
? round((maxGrossTons / maxPullWeightTons) * 100)
: null,
lengthUtilizationPct: maxTrainLengthMeters
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
: null,
},
activeSchedules: schedules,
// Composition is frozen while the train is out on a dispatched run.
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
};
}
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const yard = await manager
.getRepository(Yard)
.findOne({ where: { id: train.currentYardId ?? '' } });
if (!yard) {
throw new BadRequestException('Train has no yard; set the yard before changing locomotives');
}
const locomotives = await this.validateAndLockLocomotives(
manager,
locomotiveIds,
yard,
train.id,
);
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
const limits = minLocomotiveLimits(locomotives);
await manager
.getRepository(Train)
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
});
return this.getComposition(id);
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const currentCount = await manager
.getRepository(Wagon)
.count({ where: { trainId: train.id } });
await this.attachWagons(manager, train, dto.wagonIds, currentCount);
});
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
if (wagon.currentTrainScheduleId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
});
await this.resequenceWagons(manager, train.id);
});
return this.getComposition(id);
}
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagons = await manager
.getRepository(Wagon)
.find({ where: { trainId: train.id } });
const current = new Set(wagons.map((w) => w.id));
const incoming = new Set(dto.wagonIds);
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
}
for (let i = 0; i < dto.wagonIds.length; i++) {
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
}
});
return this.getComposition(id);
}
/** Disband the train: release wagons and locomotives, then delete it. */
async disband(id: string): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
const active: { count: string }[] = await manager.query(
`SELECT COUNT(*)::text AS count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
[id],
);
if (Number(active[0]?.count ?? 0) > 0) {
throw new ConflictException(
'Train has active schedules; cancel them before disbanding the train',
);
}
await manager
.getRepository(Wagon)
.update(
{ trainId: train.id },
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
);
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
await manager.getRepository(Train).remove(train);
});
}
// ---------------------------------------------------------------- internals
private mapSummary(train: Train) {
const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
const wagons = train.wagons ?? [];
const maxGrossTons = round(
wagons.reduce(
(sum, w) =>
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
0,
),
);
return {
id: train.id,
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
: null,
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
wagonCount: wagons.length,
maxGrossTons,
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: round(train.capacityTons),
};
}
/** Load + freeze the train row for edit; block edits while it is out on a run. */
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
const train = await manager.getRepository(Train).findOne({
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.InService) {
throw new ConflictException(
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
);
}
return train;
}
/**
* Lock and validate the locomotives for a build/replace: each must exist, be
* serviceable, sit in the train's yard, and not ride another built train.
*/
private async validateAndLockLocomotives(
manager: EntityManager,
locomotiveIds: string[],
yard: Yard,
ownTrainId: string | null,
): Promise<Locomotive[]> {
const locomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
where: { id: locomotiveId },
lock: { mode: 'pessimistic_write' },
});
if (!locked) throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
if (locked.status === 'OUT_OF_SERVICE' || locked.status === 'MAINTENANCE') {
throw new ConflictException(`Locomotive ${locked.code} is ${locked.status.toLowerCase().replace('_', ' ')}`);
}
if (locked.currentYardId !== yard.id) {
throw new BadRequestException(
`Locomotive ${locked.code} is not in yard ${yard.label ?? yard.code}; a train can only be built from locomotives in its own yard`,
);
}
locomotives.push(locked);
}
const taken = await manager.getRepository(TrainLocomotive).find({
where: { locomotiveId: In(locomotiveIds) },
relations: { train: true },
});
const conflict = taken.find((link) => link.trainId !== ownTrainId);
if (conflict) {
const loco = locomotives.find((l) => l.id === conflict.locomotiveId);
throw new ConflictException(
`Locomotive ${loco?.code ?? conflict.locomotiveId} is already coupled to train ${conflict.train?.code ?? conflict.trainId}`,
);
}
return locomotives;
}
private async replaceLocomotiveLinks(
manager: EntityManager,
trainId: string,
locomotiveIds: string[],
): Promise<void> {
await manager.getRepository(TrainLocomotive).delete({ trainId });
await manager.getRepository(TrainLocomotive).save(
locomotiveIds.map((locomotiveId, index) =>
manager.getRepository(TrainLocomotive).create({ trainId, locomotiveId, sequenceNo: index }),
),
);
}
private async attachWagons(
manager: EntityManager,
train: Train,
wagonIds: string[],
startCount: number,
): Promise<void> {
const uniqueIds = [...new Set(wagonIds)];
let sequence = startCount;
for (const wagonId of uniqueIds) {
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
if (wagon.trainId === train.id) continue;
if (wagon.trainId) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another 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 attached`,
);
}
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
});
}
}
/** Compact wagon sequence numbers back to 1..n after a removal. */
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
const wagons = await manager.getRepository(Wagon).find({
where: { trainId },
order: { sequenceNumber: 'ASC' },
});
for (let i = 0; i < wagons.length; i++) {
if (wagons[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(wagons[i].id, { sequenceNumber: i + 1 });
}
}
}
}

View File

@@ -1,14 +1,17 @@
// apps/edr-freight-api/src/modules/trains/trains.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import { TrainBuilderController } from './train-builder.controller';
import { TrainBuilderService } from './train-builder.service';
import { TrainsController } from './trains.controller';
import { TrainsService } from './trains.service';
@Module({
imports: [TypeOrmModule.forFeature([Train])],
controllers: [TrainsController],
providers: [TrainsService],
exports: [TrainsService], // if other modules need it
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])],
controllers: [TrainsController, TrainBuilderController],
providers: [TrainsService, TrainBuilderService],
exports: [TrainsService, TrainBuilderService],
})
export class TrainsModule {}
export class TrainsModule {}

View File

@@ -0,0 +1,28 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
/**
* A count-only wagon-transfer request. The requester picks source yard, wagon
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
* those at fulfilment.
*/
export class CreateTransferRequestDto {
@IsUUID()
fromYardId!: string;
@IsUUID()
toYardId!: string;
@IsUUID()
wagonTypeId!: string;
@IsInt()
@Min(1)
@Max(1000)
quantity!: number;
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,13 @@
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
/**
* OCC fulfilment: the specific wagons hand-picked to satisfy a transfer request.
* The service validates they all sit in the request's source yard, match its
* wagon type, and number exactly the requested quantity.
*/
export class FulfillTransferRequestDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonIds!: string[];
}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from '@edr/api-common';
import { WagonTransferRequestStatus } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
/**
* A two-person wagon relocation request. A requester asks for `quantity` wagons
* of `wagonTypeId` to move from `fromYardId` to `toYardId` — specifying a count
* only, never the physical wagons. OCC staff later open the PENDING request,
* hand-pick the actual wagons in the source yard, and execute the transfer
* (which writes the `wagon_movements` ledger and marks this FULFILLED).
*/
@Entity({ schema: 'freight', name: 'wagon_transfer_requests' })
@Index(['status', 'fromYardId'])
export class WagonTransferRequest extends BaseEntity {
@Column({ name: 'from_yard_id', type: 'uuid' })
fromYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'from_yard_id' })
fromYard?: Yard | null;
@Column({ name: 'to_yard_id', type: 'uuid' })
toYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'to_yard_id' })
toYard?: Yard | null;
@Column({ name: 'wagon_type_id', type: 'uuid' })
wagonTypeId!: string;
@ManyToOne(() => WagonType)
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
/** How many wagons of `wagonTypeId` to move out of `fromYardId`. */
@Column({ name: 'quantity', type: 'int' })
quantity!: number;
@Column({
name: 'status',
type: 'varchar',
length: 20,
default: WagonTransferRequestStatus.Pending,
})
status!: WagonTransferRequestStatus;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
fulfilledByUserId?: string | null;
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
fulfilledAt?: Date | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
}

View File

@@ -0,0 +1,76 @@
import { WagonTransferRequestStatus } from '@edr/types';
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import {
FleetManage,
FleetView,
WagonTransferFulfill,
WagonTransferRequest,
} from '../../common/booking-guards';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
/**
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
* the move. Separate top-level path so it never collides with `wagons/:id`.
*/
@ApiTags('wagon-transfer-requests')
@Controller('wagon-transfer-requests')
@FleetView()
export class WagonTransferRequestsController {
constructor(private readonly service: WagonTransferRequestsService) {}
@Post()
@WagonTransferRequest()
@ApiOperation({ summary: 'File a count-only wagon-transfer request' })
create(
@Body() dto: CreateTransferRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.createRequest(dto, user?.id);
}
@Get()
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
list(@Query('status') status?: WagonTransferRequestStatus) {
return this.service.listRequests(status);
}
@Get(':id')
@ApiOperation({ summary: 'Get one transfer request' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post(':id/fulfill')
@WagonTransferFulfill()
@ApiOperation({ summary: 'OCC: pick wagons and execute the transfer' })
fulfill(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: FulfillTransferRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.fulfillRequest(id, dto, user?.id);
}
@Post(':id/cancel')
@FleetManage()
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancelRequest(id);
}
}

View File

@@ -0,0 +1,153 @@
import { WagonTransferRequestStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { WagonsService } from './wagons.service';
const REQUEST_RELATIONS = {
fromYard: true,
toYard: true,
wagonType: true,
} as const;
/**
* Two-person wagon-transfer workflow. A requester records a count-only request
* (see `createRequest`); OCC staff later open the PENDING queue, hand-pick the
* physical wagons, and `fulfillRequest` validates + executes the move. Replaces
* the single-step instant bulk transfer.
*/
@Injectable()
export class WagonTransferRequestsService {
constructor(
@InjectRepository(WagonTransferRequest)
private readonly requestRepo: Repository<WagonTransferRequest>,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository<Wagon>,
private readonly wagonsService: WagonsService,
) {}
/** Record a PENDING request. Count-only — no wagons are picked here. */
async createRequest(
dto: CreateTransferRequestDto,
userId?: string | null,
): Promise<WagonTransferRequest> {
if (dto.fromYardId === dto.toYardId) {
throw new BadRequestException(
'Source and destination yard must be different',
);
}
const request = this.requestRepo.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
wagonTypeId: dto.wagonTypeId,
quantity: dto.quantity,
status: WagonTransferRequestStatus.Pending,
requestedByUserId: userId ?? null,
note: dto.note ?? null,
});
const saved = await this.requestRepo.save(request);
return this.findById(saved.id);
}
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
async listRequests(
status?: WagonTransferRequestStatus,
): Promise<WagonTransferRequest[]> {
return this.requestRepo.find({
where: status ? { status } : {},
relations: REQUEST_RELATIONS,
order: { createdAt: 'DESC' },
});
}
async findById(id: string): Promise<WagonTransferRequest> {
const request = await this.requestRepo.findOne({
where: { id },
relations: REQUEST_RELATIONS,
});
if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
return request;
}
/**
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
* in the request's source yard, match its wagon type, and the count must equal
* the requested quantity — then the transfer runs and the request is marked
* FULFILLED.
*/
async fulfillRequest(
id: string,
dto: FulfillTransferRequestDto,
userId?: string | null,
): Promise<WagonTransferRequest> {
const request = await this.findById(id);
if (request.status !== WagonTransferRequestStatus.Pending) {
throw new ConflictException(
`Request is already ${request.status.toLowerCase()}`,
);
}
const wagonIds = [...new Set(dto.wagonIds)];
if (wagonIds.length !== request.quantity) {
throw new BadRequestException(
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
);
}
const wagons = await this.wagonRepo.find({ where: { id: In(wagonIds) } });
if (wagons.length !== wagonIds.length) {
throw new NotFoundException('One or more selected wagons not found');
}
const offSource = wagons.filter((w) => w.currentYardId !== request.fromYardId);
if (offSource.length) {
throw new BadRequestException(
`These wagons are not in the source yard: ${offSource
.map((w) => w.wagonNumber)
.join(', ')}`,
);
}
const wrongType = wagons.filter((w) => w.wagonTypeId !== request.wagonTypeId);
if (wrongType.length) {
throw new BadRequestException(
`These wagons are the wrong type: ${wrongType
.map((w) => w.wagonNumber)
.join(', ')}`,
);
}
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
await this.wagonsService.bulkTransfer(
{ wagonIds, toYardId: request.toYardId },
userId,
);
request.status = WagonTransferRequestStatus.Fulfilled;
request.fulfilledByUserId = userId ?? null;
request.fulfilledAt = new Date();
await this.requestRepo.save(request);
return this.findById(id);
}
/** Withdraw a still-PENDING request. */
async cancelRequest(id: string): Promise<WagonTransferRequest> {
const request = await this.findById(id);
if (request.status !== WagonTransferRequestStatus.Pending) {
throw new ConflictException(
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
);
}
request.status = WagonTransferRequestStatus.Cancelled;
await this.requestRepo.save(request);
return this.findById(id);
}
}

View File

@@ -1,15 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wagon } from './entities/wagon.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
import { WagonsService } from './wagons.service';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@Module({
imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])],
controllers: [WagonsController, TrainWagonsReorderController],
providers: [WagonsService],
exports: [WagonsService],
imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
controllers: [
WagonsController,
TrainWagonsReorderController,
WagonTransferRequestsController,
],
providers: [WagonsService, WagonTransferRequestsService],
exports: [WagonsService, WagonTransferRequestsService],
})
export class WagonsModule {}

View File

@@ -175,6 +175,8 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
@@ -440,6 +442,10 @@ export const FREIGHT_PERMS = {
create: 'edr_freight_app:wagons:create',
update: 'edr_freight_app:wagons:update',
delete: 'edr_freight_app:wagons:delete',
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
// executes the move). Distinct keys so OCC can hold fulfil without request.
transferRequest: 'edr_freight_app:wagons:transfer_request',
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
},
trains: {
view: 'edr_freight_app:trains:view',

View File

@@ -4,6 +4,7 @@ import {
Container,
FileSignature,
FileText,
Hammer,
LayoutDashboard,
LayoutGrid,
MapPin,
@@ -109,6 +110,8 @@ import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityP
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
@@ -261,6 +264,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Wagon types",
@@ -1032,6 +1041,22 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="train-builder"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainBuilderListPage />
</RequirePermission>
}
/>
<Route
path="train-builder/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainBuilderDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
@@ -1246,6 +1271,22 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="train-builder"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainBuilderListPage />
</RequirePermission>
}
/>
<Route
path="train-builder/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainBuilderDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={

View File

@@ -5,6 +5,7 @@ import {
Button,
TextInput,
Textarea,
MultiSelect,
Select,
Switch,
Stack,
@@ -79,8 +80,12 @@ const buildInitialValues = (
): Record<string, unknown> => {
const values: Record<string, unknown> = {};
for (const field of fields) {
const raw = record?.[field.name];
if (raw !== undefined && raw !== null) {
const raw = field.getInitialValue && record
? field.getInitialValue(record)
: record?.[field.name];
if (field.type === "multiselect") {
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
} else if (raw !== undefined && raw !== null) {
if (field.type === "date" && typeof raw === "string") {
values[field.name] = raw.slice(0, 10);
} else if (Array.isArray(raw)) {
@@ -197,7 +202,10 @@ const RuleEngineFormDialog = ({
for (const field of visibleFields) {
const raw = values[field.name];
if (field.type === "number") {
if (field.type === "multiselect") {
// Always the full replacement list — the API syncs the relation to it.
payload[field.name] = Array.isArray(raw) ? raw : [];
} else if (field.type === "number") {
if (raw === "" || raw === undefined) continue;
payload[field.name] = Number(raw);
} else if (field.type === "boolean") {
@@ -258,6 +266,38 @@ const RuleEngineFormDialog = ({
const label = <FieldLabel label={field.label} required={field.required} />;
if (field.type === "multiselect") {
const options = field.optionsFromValues
? field.optionsFromValues(values)
: (field.options ?? []);
const selected = Array.isArray(values[field.name])
? (values[field.name] as string[])
: [];
return (
<MultiSelect
key={field.name}
label={label}
description={field.description}
placeholder={
selectOptionsLoading
? "Loading options..."
: (field.placeholder ?? "Select one or more")
}
value={selected}
onChange={(v) => setField(field.name, v)}
disabled={selectOptionsLoading}
data={options
.filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE)
.map((opt) => ({ label: opt.label, value: opt.value }))}
searchable
clearable
size="md"
radius="md"
styles={inputStyles}
/>
);
}
if (field.type === "select") {
// Dynamic options (e.g. rate unit) resolve from the live form values so
// the choices track the other fields the admin has picked.

View File

@@ -0,0 +1,152 @@
import { Freight } from "@edr/types";
import {
Button,
Checkbox,
Group,
ScrollArea,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
/**
* AVAILABLE wagons standing in the train's own yard — the only ones that can
* be coupled. Pick any number and append them to the consist.
*/
export default function AvailableWagonsPanel({
yardId,
yardLabel,
onAssign,
assigning,
}: AvailableWagonsPanelProps) {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("ALL");
const [selected, setSelected] = useState<string[]>([]);
const wagonsQuery = useQuery(
api.wagons.list.queryOptions({
input: {
filters: { status: Freight.WagonStatus.Available, currentYardId: yardId },
},
enabled: Boolean(yardId),
}),
);
const wagons = useMemo(() => {
const q = search.trim().toLowerCase();
return (wagonsQuery.data ?? []).filter((wagon) => {
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
return true;
});
}, [wagonsQuery.data, search, typeFilter]);
const typeOptions = useMemo(() => {
const byId = new Map<string, string>();
for (const wagon of wagonsQuery.data ?? []) {
if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
}
return [
{ value: "ALL", label: "All types" },
...[...byId.entries()].map(([value, label]) => ({ value, label })),
];
}, [wagonsQuery.data]);
const toggle = (wagonId: string, checked: boolean) => {
setSelected((prev) =>
checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId),
);
};
const handleAssign = () => {
if (!selected.length) return;
onAssign(selected);
setSelected([]);
};
return (
<Stack gap="sm">
<Group gap="xs" grow>
<TextInput
size="sm"
placeholder="Search wagon number…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Select
size="sm"
data={typeOptions}
value={typeFilter}
onChange={(v) => setTypeFilter(v ?? "ALL")}
/>
</Group>
<ScrollArea.Autosize mah={380} type="auto">
<Stack gap={6}>
{wagonsQuery.isLoading ? (
<Text py="md" ta="center" c="dimmed" size="sm">
Loading wagons
</Text>
) : !wagons.length ? (
<Text py="md" ta="center" c="dimmed" size="sm">
No available wagons in {yardLabel ?? "this yard"}
</Text>
) : (
wagons.map((wagon) => (
<Group
key={wagon.id}
gap="sm"
wrap="nowrap"
p="xs"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
}}
>
<Checkbox
size="sm"
checked={selected.includes(wagon.id)}
onChange={(e) => toggle(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.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
: "Unknown type"}
</Text>
</Stack>
</Group>
))
)}
</Stack>
</ScrollArea.Autosize>
<Button
leftSection={<Plus size={16} />}
disabled={!selected.length}
loading={assigning}
onClick={handleAssign}
>
Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
</Button>
</Stack>
);
}
export interface AvailableWagonsPanelProps {
yardId: string;
yardLabel?: string | null;
onAssign: (wagonIds: string[]) => void;
assigning: boolean;
}

View File

@@ -0,0 +1,182 @@
import {
Button,
Group,
Modal,
MultiSelect,
Select,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } 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;
};
/**
* Step one of the Train Builder: give the train its operator code, pick the
* yard it is being assembled in, and couple at least two locomotives from that
* yard. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
const [trainName, setTrainName] = useState("");
const [yardId, setYardId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
// Only serviceable locomotives standing in the selected yard can be coupled.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
enabled: Boolean(yardId),
}),
);
const build = useMutation(api.trainBuilder.build.mutationOptions());
// A locomotive belongs to one yard — switching yards invalidates the pick.
useEffect(() => {
setLocomotiveIds([]);
}, [yardId]);
useEffect(() => {
if (!opened) {
setCode("");
setTrainName("");
setYardId("");
setLocomotiveIds([]);
setNotes("");
}
}, [opened]);
const handleBuild = async () => {
if (!code.trim() || !yardId || locomotiveIds.length < 2) {
toast({
title: "Enter a train code, pick a yard, and couple at least two locomotives",
variant: "destructive",
});
return;
}
try {
const composition = await build.mutateAsync({
code: code.trim(),
currentYardId: yardId,
locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
});
toast({ title: `Train ${composition.code} built` });
onClose();
onBuilt(composition);
} catch (err) {
toast({
title: "Build failed",
description: parseError(err, "Could not build the train"),
variant: "destructive",
});
}
};
const locomotiveOptions = (locomotivesQuery.data ?? []).map((loco) => ({
value: loco.id,
label: `${loco.code}${loco.name ? `${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
}));
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Build a train</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
A train is assembled in one yard: two or more locomotives plus wagons
standing in that same yard. Wagons are attached on the next screen.
</Text>
<Group grow>
<TextInput
label="Train code"
placeholder="e.g. 81001"
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
maxLength={32}
/>
<TextInput
label="Name (optional)"
placeholder="e.g. Fertilizer block"
value={trainName}
onChange={(e) => setTrainName(e.currentTarget.value)}
maxLength={100}
/>
</Group>
<Select
label="Build yard"
placeholder="Select the yard the train is assembled in"
data={(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
}))}
value={yardId || null}
onChange={(v) => setYardId(v ?? "")}
searchable
/>
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
data={locomotiveOptions}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!yardId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
yardId ? "No available locomotives in this yard" : "Select a yard first"
}
/>
<Textarea
label="Notes (optional)"
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={build.isPending} onClick={handleBuild}>
Build train
</Button>
</Group>
</Stack>
</Modal>
);
}
export interface BuildTrainModalProps {
opened: boolean;
onClose: () => void;
onBuilt: (composition: TrainComposition) => void;
}

View File

@@ -0,0 +1,128 @@
import { Button, Group, Modal, MultiSelect, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } 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;
};
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
export default function ChangeLocomotivesModal({
composition,
opened,
onClose,
}: ChangeLocomotivesModalProps) {
const { toast } = useToast();
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const yardId = composition?.currentYard?.id ?? "";
const availableQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
enabled: opened && Boolean(yardId),
}),
);
const setLocomotives = useMutation(api.trainBuilder.setLocomotives.mutationOptions());
useEffect(() => {
if (opened && composition) {
setLocomotiveIds(composition.locomotives.map((l) => l.id));
}
}, [opened, composition]);
// Pickable = available locomotives in the yard + the ones already coupled
// to this train (valid to keep even though they are not "loose" anymore).
const options = useMemo(() => {
const seen = new Set<string>();
const rows: Array<{ value: string; label: string }> = [];
for (const loco of composition?.locomotives ?? []) {
seen.add(loco.id);
rows.push({
value: loco.id,
label: `${loco.code}${loco.name ? `${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T (coupled)`,
});
}
for (const loco of availableQuery.data ?? []) {
if (seen.has(loco.id)) continue;
rows.push({
value: loco.id,
label: `${loco.code}${loco.name ? `${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
});
}
return rows;
}, [composition, availableQuery.data]);
const handleSave = async () => {
if (!composition) return;
if (locomotiveIds.length < 2) {
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
return;
}
try {
await setLocomotives.mutateAsync({ id: composition.id, locomotiveIds });
toast({ title: "Locomotives updated" });
onClose();
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update locomotives"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>Change locomotives</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Only available locomotives standing in{" "}
{composition?.currentYard?.label ?? "the train's yard"} can be coupled.
The first pick is the lead locomotive.
</Text>
<MultiSelect
label="Locomotives"
data={options}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage="No available locomotives in this yard"
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={setLocomotives.isPending} onClick={handleSave}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
}
export interface ChangeLocomotivesModalProps {
composition: TrainComposition | null;
opened: boolean;
onClose: () => void;
}

View File

@@ -0,0 +1,171 @@
import {
DragDropContext,
Draggable,
Droppable,
type DraggableProvided,
type DraggableStateSnapshot,
type DropResult,
} from "@hello-pangea/dnd";
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { GripVertical, Trash2 } from "lucide-react";
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
const PortalAwareRow = ({
snapshot,
children,
}: {
snapshot: DraggableStateSnapshot;
children: ReactNode;
}) => {
if (snapshot.isDragging) {
return createPortal(children, document.body);
}
return <>{children}</>;
};
/**
* The train's ordered wagon consist. Drag to reorder (persisted on drop),
* trash to detach a wagon back to the yard.
*/
export default function ConsistWagonList({
wagons,
editable,
onReorder,
onRemove,
busy = false,
}: ConsistWagonListProps) {
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const from = result.source.index;
const to = result.destination.index;
if (from === to) return;
const next = [...wagons];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onReorder(next.map((w) => w.id));
};
if (!wagons.length) {
return (
<Text py="lg" ta="center" c="dimmed" size="sm">
No wagons in the consist yet.
</Text>
);
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
{(dropProvided) => (
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
draggableId={wagon.id}
index={index}
isDragDisabled={!editable || busy}
>
{(dragProvided, snapshot) => (
<WagonRow
wagon={wagon}
index={index}
dragProvided={dragProvided}
snapshot={snapshot}
editable={editable}
busy={busy}
onRemove={onRemove}
/>
)}
</Draggable>
))}
{dropProvided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
);
}
export interface ConsistWagonListProps {
wagons: TrainCompositionWagon[];
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
busy?: boolean;
}
function WagonRow({
wagon,
index,
dragProvided,
snapshot,
editable,
busy,
onRemove,
}: {
wagon: TrainCompositionWagon;
index: number;
dragProvided: DraggableProvided;
snapshot: DraggableStateSnapshot;
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
}) {
return (
<PortalAwareRow snapshot={snapshot}>
<Group
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
gap="sm"
wrap="nowrap"
p="sm"
style={{
...dragProvided.draggableProps.style,
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
userSelect: "none",
}}
>
{editable ? (
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
<GripVertical size={18} />
</Box>
) : null}
<Badge variant="light" color="gray" size="sm">
{index + 1}
</Badge>
<Stack gap={2} 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.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
: "Unknown type"}
</Text>
</Stack>
{editable ? (
<Tooltip label="Detach wagon" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={() => onRemove(wagon.id)}
aria-label={`Detach wagon ${wagon.wagonNumber}`}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
</PortalAwareRow>
);
}

View File

@@ -0,0 +1,159 @@
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Train as TrainIcon } from "lucide-react";
import type {
TrainCompositionLocomotive,
TrainCompositionWagon,
} from "@/services/trainBuilder.service";
/**
* Visual consist: locomotives + wagons drawn in order on a rail, the way the
* train would leave the yard. Scrolls horizontally for long consists.
*/
export default function TrainConsistStrip({
locomotives,
wagons,
emptyHint = "No wagons attached yet — add wagons from the yard below.",
}: TrainConsistStripProps) {
return (
<Box
px="md"
py="lg"
style={{
overflowX: "auto",
borderRadius: 12,
background:
"linear-gradient(180deg, var(--mantine-color-gray-0) 0%, var(--mantine-color-gray-1) 100%)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Box style={{ display: "inline-block", minWidth: "100%" }}>
<Group gap={0} wrap="nowrap" align="flex-end">
{locomotives.map((loco, index) => (
<Group key={loco.id} gap={0} wrap="nowrap" align="flex-end">
{index > 0 ? <Coupler /> : null}
<LocomotiveCar locomotive={loco} />
</Group>
))}
{wagons.map((wagon) => (
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-end">
<Coupler />
<WagonCar wagon={wagon} />
</Group>
))}
</Group>
{/* The rail */}
<Box
mt={6}
style={{
height: 0,
borderTop: "3px solid var(--mantine-color-gray-4)",
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
/>
{!wagons.length ? (
<Text size="xs" c="dimmed" mt="xs">
{emptyHint}
</Text>
) : null}
</Box>
</Box>
);
}
export interface TrainConsistStripProps {
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
emptyHint?: string;
}
function Coupler() {
return (
<Box
style={{
width: 12,
height: 4,
marginBottom: 18,
background: "var(--mantine-color-gray-5)",
flexShrink: 0,
}}
/>
);
}
function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
return (
<Tooltip
label={`${locomotive.code}${locomotive.name ? `${locomotive.name}` : ""} · ${
locomotive.role === "LEAD" ? "Lead" : "Assist"
} · pulls ${locomotive.maxPullWeightTons}T`}
withArrow
>
<Stack
gap={2}
align="center"
px="sm"
py={6}
style={{
minWidth: 96,
borderRadius: "10px 14px 4px 4px",
background:
"linear-gradient(180deg, var(--mantine-color-edr-green-6) 0%, var(--mantine-color-edr-green-8) 100%)",
color: "white",
border: "1px solid var(--mantine-color-edr-green-9)",
flexShrink: 0,
cursor: "default",
}}
>
<Group gap={4} wrap="nowrap">
<TrainIcon size={13} />
<Text size="xs" fw={700} ff="monospace" lh={1.2}>
{locomotive.code}
</Text>
</Group>
<Text size="10px" fw={600} tt="uppercase" style={{ opacity: 0.85 }} lh={1}>
{locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
</Text>
</Stack>
</Tooltip>
);
}
function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
return (
<Tooltip
label={`${wagon.wagonNumber}${
wagon.wagonType
? ` · ${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
: ""
}`}
withArrow
>
<Stack
gap={2}
align="center"
px="xs"
py={6}
style={{
minWidth: 76,
borderRadius: 6,
background: "white",
border: "1px solid var(--mantine-color-gray-3)",
borderBottom: "3px solid var(--mantine-color-edr-green-3)",
flexShrink: 0,
cursor: "default",
}}
>
<Text size="10px" c="dimmed" lh={1}>
#{wagon.sequenceNumber ?? "—"}
</Text>
<Text size="xs" fw={600} ff="monospace" lh={1.2}>
{wagon.wagonNumber}
</Text>
<Text size="10px" c="dimmed" lh={1}>
{wagon.wagonType?.code ?? "—"}
</Text>
</Stack>
</Tooltip>
);
}

View File

@@ -0,0 +1,25 @@
import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
/** Badge color per built-train lifecycle status (Mantine palette keys). */
export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
switch (status) {
case "AVAILABLE":
return "edr-green";
case "SCHEDULED":
return "blue";
case "IN_SERVICE":
return "teal";
case "UNDER_MAINTENANCE":
return "yellow";
case "OUT_OF_SERVICE":
return "red";
default:
return "gray";
}
};
export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
String(status)
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());

View File

@@ -0,0 +1,344 @@
import { Freight } from "@edr/types";
import {
Badge,
Button,
Card,
Checkbox,
Divider,
Group,
Loader,
Modal,
ScrollArea,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
Inbox,
PackageCheck,
Warehouse,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { WagonTransferRequest } from "@/services/wagon.service";
export interface WagonTransferRequestsModalProps {
opened: boolean;
onClose: () => void;
}
const PENDING = Freight.WagonTransferRequestStatus.Pending;
const AVAILABLE = Freight.WagonStatus.Available;
const yardLabel = (y?: { label?: string; code?: string } | null) =>
y?.label || y?.code || "—";
const typeLabel = (t?: { code?: string; name?: string } | null) =>
t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
/** Requester → destination + type + count summary line, reused in list and picker. */
const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
<Group gap={8} wrap="nowrap">
<Text fw={600} size="sm" truncate>
{yardLabel(r.fromYard)}
</Text>
<ArrowRight size={14} style={{ flexShrink: 0 }} />
<Text fw={600} size="sm" truncate>
{yardLabel(r.toYard)}
</Text>
<Badge variant="light" color="grape" radius="sm">
{r.quantity}× {typeLabel(r.wagonType)}
</Badge>
</Group>
);
/**
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
* one to hand-pick exactly the requested number of wagons from the source yard
* (of the requested type) and execute the move, or cancel the request.
*/
const WagonTransferRequestsModal = ({
opened,
onClose,
}: WagonTransferRequestsModalProps) => {
const { toast } = useToast();
const [active, setActive] = useState<WagonTransferRequest | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const { data: requests = [], isLoading } = useQuery({
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
enabled: opened,
});
// Available wagons of the requested type sitting in the request's source yard.
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
...api.wagons.list.queryOptions({
input: {
filters: active
? {
currentYardId: active.fromYardId,
wagonTypeId: active.wagonTypeId,
status: AVAILABLE,
}
: {},
},
}),
enabled: opened && Boolean(active),
});
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
const showError = (err: unknown, fallback: string) => {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? fallback;
toast({ title: fallback, description: String(message), variant: "destructive" });
};
const openPicker = (r: WagonTransferRequest) => {
setActive(r);
setPicked(new Set());
};
const closePicker = () => {
setActive(null);
setPicked(new Set());
};
const toggle = (id: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (active && next.size >= active.quantity) return prev; // cap at quantity
else next.add(id);
return next;
});
const need = active?.quantity ?? 0;
const shortfall = active ? Math.max(0, need - wagons.length) : 0;
const handleFulfill = async () => {
if (!active || picked.size !== need) return;
try {
await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
toast({
title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)}${yardLabel(
active.toYard,
)}`,
});
closePicker();
} catch (err) {
showError(err, "Transfer failed");
}
};
const handleCancel = async (r: WagonTransferRequest) => {
try {
await cancel.mutateAsync({ id: r.id });
toast({ title: "Request cancelled" });
} catch (err) {
showError(err, "Cancel failed");
}
};
const sortedWagons = useMemo(
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
[wagons],
);
return (
<Modal
opened={opened}
onClose={onClose}
size="min(760px, 96vw)"
radius="lg"
centered
overlayProps={{ blur: 2 }}
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Inbox size={18} />
</ThemeIcon>
<div>
<Text fw={700}>Wagon Transfer Requests</Text>
<Text size="xs" c="dimmed">
{active
? "Pick the wagons to move, then transfer"
: "OCC queue — pick wagons and complete each move"}
</Text>
</div>
</Group>
}
>
{!active ? (
// ---- Pending queue ----
isLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : requests.length === 0 ? (
<Card withBorder radius="md" padding="xl">
<Stack align="center" gap={6}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text fw={600}>No pending transfer requests</Text>
<Text size="sm" c="dimmed" ta="center" maw={420}>
When staff request a yard-to-yard wagon move, it appears here for
you to fulfil.
</Text>
</Stack>
</Card>
) : (
<Stack gap="sm">
{requests.map((r) => (
<Card key={r.id} withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={6} style={{ minWidth: 0 }}>
<RequestSummary r={r} />
{r.note ? (
<Text size="xs" c="dimmed">
{r.note}
</Text>
) : null}
</Stack>
<Group gap={8} wrap="nowrap">
<Button
size="compact-sm"
variant="subtle"
color="gray"
leftSection={<X size={14} />}
loading={cancel.isPending}
onClick={() => handleCancel(r)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
leftSection={<PackageCheck size={14} />}
onClick={() => openPicker(r)}
>
Fulfil
</Button>
</Group>
</Group>
</Card>
))}
</Stack>
)
) : (
// ---- Wagon picker for the active request ----
<Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
<RequestSummary r={active} />
</Card>
<Group justify="space-between">
<Text size="sm" fw={600}>
Select wagons in {yardLabel(active.fromYard)}
</Text>
<Badge
color={picked.size === need ? "teal" : "gray"}
variant={picked.size === need ? "filled" : "light"}
>
{picked.size} / {need} selected
</Badge>
</Group>
{wagonsLoading ? (
<Group justify="center" p="lg">
<Loader size="sm" />
</Group>
) : sortedWagons.length === 0 ? (
<Card withBorder radius="md" padding="lg">
<Group gap={8} justify="center">
<Warehouse size={16} />
<Text size="sm" c="dimmed">
No available wagons of this type in {yardLabel(active.fromYard)}.
</Text>
</Group>
</Card>
) : (
<>
{shortfall > 0 ? (
<Text size="xs" c="orange.7">
Only {sortedWagons.length} available {shortfall} short of the{" "}
{need} requested.
</Text>
) : null}
<ScrollArea.Autosize mah={320}>
<Stack gap={6}>
{sortedWagons.map((w) => {
const checked = picked.has(w.id);
const atCap = !checked && picked.size >= need;
return (
<Card
key={w.id}
withBorder
radius="md"
padding="xs"
onClick={() => !atCap && toggle(w.id)}
style={{
cursor: atCap ? "not-allowed" : "pointer",
borderColor: checked
? "var(--mantine-color-edr-green-4)"
: undefined,
opacity: atCap ? 0.55 : 1,
}}
>
<Group gap="sm" wrap="nowrap">
{/* Visual only — the Card's onClick owns the toggle so a
click on the box doesn't fire both and cancel out. */}
<Checkbox
checked={checked}
readOnly
disabled={atCap}
color="edr-green"
tabIndex={-1}
aria-hidden
/>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Group>
</Card>
);
})}
</Stack>
</ScrollArea.Autosize>
</>
)}
<Divider />
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
leftSection={<ChevronLeft size={16} />}
onClick={closePicker}
>
Back to queue
</Button>
<Button
color="edr-green"
leftSection={<PackageCheck size={16} />}
loading={fulfill.isPending}
disabled={picked.size !== need}
onClick={handleFulfill}
>
Transfer {need} wagon{need === 1 ? "" : "s"}
</Button>
</Group>
</Stack>
)}
</Modal>
);
};
export default WagonTransferRequestsModal;

View File

@@ -14,7 +14,6 @@ import {
Select,
Slider,
Stack,
Switch,
Text,
ThemeIcon,
} from "@mantine/core";
@@ -126,11 +125,12 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState(0);
const [freeAfterMove, setFreeAfterMove] = useState(false);
const [toAssignedQty, setToAssignedQty] = useState(0);
const [toAvailableQty, setToAvailableQty] = useState(0);
const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions());
const createRequest = useMutation(
api.wagonTransferRequests.create.mutationOptions(),
);
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
const yardName = useMemo(() => {
@@ -187,13 +187,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
() => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED),
[matching],
);
// Available first, then assigned, then the rest — a partial move relocates
// idle wagons before touching assigned ones.
const transferPool = useMemo(
() => [...availableWagons, ...assignedWagons, ...otherWagons],
[availableWagons, assignedWagons, otherWagons],
);
const total = matching.length;
const availableCount = availableWagons.length;
const assignedCount = assignedWagons.length;
@@ -214,7 +207,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
useEffect(() => {
setTransferYardId(null);
setTransferQty(0);
setFreeAfterMove(false);
setToAssignedQty(0);
setToAvailableQty(0);
}, [yardId, typeId]);
@@ -238,25 +230,27 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
toast({ title: fallback, description: String(message), variant: "destructive" });
};
const handleTransfer = async () => {
if (!transferYardId || transferQty < 1) return;
const ids = transferPool.slice(0, transferQty).map((w) => w.id);
if (!ids.length) return;
// Request-only: the requester specifies count + destination; OCC later picks
// the physical wagons and executes the move. No wagons are moved here.
const handleRequest = async () => {
if (!yardId || !typeId || !transferYardId || transferQty < 1) return;
try {
const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId });
if (freeAfterMove) {
await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE });
}
await createRequest.mutateAsync({
fromYardId: yardId,
toYardId: transferYardId,
wagonTypeId: typeId,
quantity: transferQty,
});
toast({
title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${
freeAfterMove ? " · set Available" : ""
}`,
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
yardId,
)}${yardName(transferYardId)}`,
description: "OCC will pick the wagons and complete the move.",
});
setTransferQty(0);
setTransferYardId(null);
setFreeAfterMove(false);
} catch (err) {
showError(err, "Transfer failed");
showError(err, "Request failed");
}
};
@@ -279,7 +273,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
}
};
const busy = transfer.isPending || setStatus.isPending;
const busy = createRequest.isPending || setStatus.isPending;
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
return (
@@ -402,12 +396,15 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
{/* Transfer */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%" padding="lg">
<Group gap="xs" mb="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="grape" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Text fw={700}>Move to another yard</Text>
<Text fw={700}>Request transfer to another yard</Text>
</Group>
<Text size="xs" c="dimmed" mb="md">
Sends a request to OCC they pick the wagons and complete the move.
</Text>
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={4}>
@@ -424,12 +421,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
searchable
radius="md"
/>
<Switch
checked={freeAfterMove}
onChange={(e) => setFreeAfterMove(e.currentTarget.checked)}
label="Set moved wagons to Available"
color="teal"
/>
{transferYardId && transferQty > 0 ? (
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
<Group gap={8} wrap="nowrap">
@@ -453,12 +444,13 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
) : null}
<Button
leftSection={<ArrowRightLeft size={16} />}
onClick={handleTransfer}
loading={transfer.isPending}
onClick={handleRequest}
loading={createRequest.isPending}
disabled={busy || !transferYardId || transferQty < 1}
color="edr-green"
>
Move {transferQty > 0 ? `${transferQty} ` : ""}wagon{transferQty === 1 ? "" : "s"}
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
{transferQty === 1 ? "" : "s"}
</Button>
</Stack>
</Card>

View File

@@ -99,6 +99,8 @@ export const QUERY_KEYS = {
] as const,
locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const,
availableTrains: (routeId?: string) =>
["train-scheduling", "available-trains", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: (filters?: unknown) =>
["train-scheduling", "schedules", filters ?? {}] as const,
@@ -122,6 +124,12 @@ export const QUERY_KEYS = {
["fleet", "list", resource] as const,
},
TRAIN_BUILDER: {
ROOT: ["train-builder"] as const,
list: (filters?: unknown) => ["train-builder", "list", filters ?? {}] as const,
composition: (id: string) => ["train-builder", "composition", id] as const,
},
VEHICLES: {
ROOT: ["vehicles"] as const,
list: (filter?: Record<string, unknown>) =>

View File

@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Plus, Warehouse } from "lucide-react";
import { Inbox, Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
@@ -15,6 +15,7 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -48,6 +49,7 @@ const FleetResourcePage = () => {
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
@@ -373,15 +375,26 @@ const FleetResourcePage = () => {
</div>
<Group gap="sm">
{slug === "wagons" ? (
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
<>
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
<Button
variant="light"
color="grape"
leftSection={<Inbox size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setTransferRequestsOpen(true)}
>
Transfer Requests
</Button>
</>
) : null}
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
@@ -605,6 +618,13 @@ const FleetResourcePage = () => {
/>
) : null}
{slug === "wagons" ? (
<WagonTransferRequestsModal
opened={transferRequestsOpen}
onClose={() => setTransferRequestsOpen(false)}
/>
) : null}
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}

View File

@@ -54,8 +54,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
wagonTypeId?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
wagonTypes?: { id: string; code?: string; name?: string }[];
isActive?: boolean;
displayOrder?: number;
}
@@ -82,16 +82,19 @@ const FORM_FIELDS: FormFieldDef[] = [
],
},
{
// Wagon type that carries this (bulk) commodity — drives train-scheduling
// wagon resolution. Optional: leave "None" for grouping categories and
// container/legacy cargo; set it on scheduled bulk commodities.
// Wagon types that can carry this (bulk) commodity — drive train-scheduling
// wagon resolution (the plan uses whichever type the train/yard has).
// Optional: leave empty for grouping categories and container/legacy cargo;
// set them on scheduled bulk commodities.
// Options injected at render from useWagonTypeOptions.
name: "wagonTypeId",
label: "Wagon type",
type: "select",
name: "wagonTypeIds",
label: "Wagon types",
type: "multiselect",
optional: true,
placeholder: "Select wagon type (bulk cargo)",
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
placeholder: "Select wagon types (bulk cargo)",
options: [],
getInitialValue: (record) =>
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
@@ -119,19 +122,13 @@ const CargoTypesPage = () => {
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
const formFields = useMemo<FormFieldDef[]>(
() =>
FORM_FIELDS.map((field) =>
field.name === "wagonTypeId"
? {
...field,
options: [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
...(wagonTypeOptions ?? []),
],
}
field.name === "wagonTypeIds"
? { ...field, options: wagonTypeOptions ?? [] }
: field,
),
[wagonTypeOptions],

View File

@@ -153,7 +153,9 @@ const RuleEngineResourcePage = () => {
config?.formFields.some((f) => f.name === "rateId"),
);
const usesWagonTypeField = Boolean(
config?.formFields.some((f) => f.name === "wagonTypeId"),
config?.formFields.some(
(f) => f.name === "wagonTypeId" || f.name === "wagonTypeIds",
),
);
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
@@ -206,6 +208,13 @@ const RuleEngineResourcePage = () => {
options: wagonTypeOptions ?? [],
};
}
if (field.name === "wagonTypeIds") {
return {
...field,
type: "multiselect" as const,
options: wagonTypeOptions ?? [],
};
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);

View File

@@ -55,6 +55,12 @@ export interface FormFieldDef {
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
*/
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
/**
* Derive the field's initial form value from the record being edited when it
* doesn't live under `record[name]` — e.g. a multiselect of ids backed by a
* relation list (`wagonTypeIds` read from `record.wagonTypes`).
*/
getInitialValue?: (record: Record<string, unknown>) => unknown;
}
export interface RuleEngineOrderConfig {
@@ -258,11 +264,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
{
name: "wagonTypeId",
label: "Wagon type",
type: "select",
name: "wagonTypeIds",
label: "Wagon types",
type: "multiselect",
required: true,
description: "Wagon type used to carry this container during train scheduling.",
description:
"Wagon types that can carry this container during train scheduling (one container size per wagon at a time).",
getInitialValue: (record) =>
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
},
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },

View File

@@ -0,0 +1,355 @@
import {
Alert,
Badge,
Button,
Card,
Grid,
Group,
Menu,
Modal,
Progress,
Stack,
Text,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
AlertTriangle,
CalendarClock,
MoreHorizontal,
Replace,
Ruler,
Trash2,
Train as TrainIcon,
TrainFront,
Weight,
} from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import TrainConsistStrip from "@/components/trainBuilder/TrainConsistStrip";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
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;
};
/** Utilization bar color: green while safe, amber when close, red when over. */
const utilizationColor = (pct: number | null) => {
if (pct == null) return "gray";
if (pct > 100) return "red";
if (pct > 85) return "yellow";
return "edr-green";
};
/**
* Train Builder workspace for one train: the visual consist, the wagon yard
* panel, and the locomotive set — everything needed to (re)compose the train.
*/
export default function TrainBuilderDetailPage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [locoModalOpen, setLocoModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
);
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const composition = compositionQuery.data;
const busy =
assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending;
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
try {
await action();
} catch (err) {
toast({
title: failTitle,
description: parseError(err, "Something went wrong"),
variant: "destructive",
});
}
};
if (compositionQuery.isLoading) {
return (
<PageContainer>
<Text py="xl" ta="center" c="dimmed">
Loading train
</Text>
</PageContainer>
);
}
if (compositionQuery.isError || !composition) {
return (
<PageContainer>
<Alert color="red" icon={<AlertTriangle size={16} />}>
Failed to load this train.{" "}
<Button variant="subtle" size="compact-sm" onClick={() => compositionQuery.refetch()}>
Retry
</Button>
</Alert>
</PageContainer>
);
}
const { totals } = composition;
const yard = composition.currentYard;
return (
<PageContainer>
<PageHeader
title={`Train ${composition.code}`}
subtitle={
composition.trainName
? `${composition.trainName} · built in ${yard?.label ?? "unknown yard"}`
: `Built in ${yard?.label ?? "unknown yard"}`
}
backTo="/dashboard/train-builder"
meta={
<Badge color={trainStatusColor(composition.status)} variant="light">
{trainStatusLabel(composition.status)}
</Badge>
}
action={
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
<Menu.Target>
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
Actions
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Replace size={15} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Change locomotives
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDisbandOpen(true)}
>
Disband train
</Menu.Item>
</Menu.Dropdown>
</Menu>
}
/>
<KpiStrip
items={[
{ label: "Locomotives", value: composition.locomotives.length, icon: TrainFront },
{ label: "Wagons", value: totals.wagonCount, icon: TrainIcon },
{
label: "Max gross / haul limit",
value: `${totals.maxGrossTons}T / ${totals.maxPullWeightTons}T`,
icon: Weight,
},
{
label: "Length / limit",
value: `${totals.totalLengthMeters}m / ${totals.maxTrainLengthMeters}m`,
icon: Ruler,
},
]}
/>
{!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival.
</Alert>
) : null}
<Card>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Consist</Text>
<Text size="xs" c="dimmed">
{composition.locomotives.length} locomotive
{composition.locomotives.length === 1 ? "" : "s"} · {totals.wagonCount} wagon
{totals.wagonCount === 1 ? "" : "s"}
</Text>
</Group>
<TrainConsistStrip
locomotives={composition.locomotives}
wagons={composition.wagons}
/>
<Grid gap="lg">
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar
label="Weight utilization (fully loaded)"
pct={totals.weightUtilizationPct}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<UtilizationBar label="Length utilization" pct={totals.lengthUtilizationPct} />
</Grid.Col>
</Grid>
</Stack>
</Card>
<Grid gap="lg" align="stretch">
{composition.editable ? (
<Grid.Col span={{ base: 12, md: 5 }}>
<Card h="100%">
<Stack gap="sm">
<Text fw={600}>Available wagons {yard?.label ?? "yard"}</Text>
<Text size="xs" c="dimmed">
Only AVAILABLE wagons standing in the train's own yard can be coupled.
</Text>
<AvailableWagonsPanel
yardId={yard?.id ?? ""}
yardLabel={yard?.label}
assigning={assignWagons.isPending}
onAssign={(wagonIds) =>
void withToast(
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not add wagons",
)
}
/>
</Stack>
</Card>
</Grid.Col>
) : null}
<Grid.Col span={{ base: 12, md: composition.editable ? 7 : 12 }}>
<Card h="100%">
<Stack gap="sm">
<Text fw={600}>Wagon order</Text>
<Text size="xs" c="dimmed">
Drag to reorder position 1 couples right behind the locomotives.
</Text>
<ConsistWagonList
wagons={composition.wagons}
editable={composition.editable}
busy={busy}
onReorder={(wagonIds) =>
void withToast(
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
"Could not reorder wagons",
)
}
onRemove={(wagonId) =>
void withToast(
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
"Could not detach wagon",
)
}
/>
</Stack>
</Card>
</Grid.Col>
</Grid>
{composition.activeSchedules.length ? (
<Card>
<Stack gap="sm">
<Text fw={600}>Upcoming runs</Text>
{composition.activeSchedules.map((schedule) => (
<Group key={schedule.id} justify="space-between">
<Group gap="sm">
<CalendarClock size={15} color="var(--mantine-color-gray-6)" />
<Text size="sm" ff="monospace" fw={600}>
{schedule.reference ?? schedule.id.slice(0, 8)}
</Text>
<Badge size="sm" variant="light">
{schedule.status}
</Badge>
</Group>
<Button
variant="subtle"
size="compact-sm"
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
>
Open schedule
</Button>
</Group>
))}
</Stack>
</Card>
) : null}
<ChangeLocomotivesModal
composition={composition}
opened={locoModalOpen}
onClose={() => setLocoModalOpen(false)}
/>
<Modal
opened={disbandOpen}
onClose={() => setDisbandOpen(false)}
title={<Text fw={600}>Disband train {composition.code}?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
All wagons and locomotives are released back to{" "}
{yard?.label ?? "their yard"} and the train is deleted. This cannot be undone.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDisbandOpen(false)}>
Keep train
</Button>
<Button
color="red"
loading={disband.isPending}
onClick={() =>
void withToast(async () => {
await disband.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} disbanded` });
navigate("/dashboard/train-builder");
}, "Could not disband train")
}
>
Disband
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
function UtilizationBar({ label, pct }: { label: string; pct: number | null }) {
return (
<Stack gap={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={600} c={pct != null && pct > 100 ? "red" : undefined}>
{pct != null ? `${pct}%` : "—"}
</Text>
</Group>
<Progress
value={Math.min(pct ?? 0, 100)}
color={utilizationColor(pct)}
size="sm"
radius="xl"
/>
</Stack>
);
}

View File

@@ -0,0 +1,336 @@
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import {
Badge,
Box,
Button,
Card,
Group,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
Hammer,
Ruler,
Search,
Train as TrainIcon,
TrainFront,
Weight,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
import { api } from "@/services/api";
import type {
BuiltTrainListFilters,
BuiltTrainStatus,
BuiltTrainSummary,
} from "@/services/trainBuilder.service";
/**
* Train Builder board: every built train (code, yard, locomotive set, consist
* totals, lifecycle status) plus the entry point for building a new one.
*/
export default function TrainBuilderListPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState<"ALL" | BuiltTrainStatus>("ALL");
const [yardFilter, setYardFilter] = useState("ALL");
const [buildOpen, setBuildOpen] = useState(false);
const resetPage = useCallback(() => {
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
);
}, [setPagination]);
useEffect(() => {
resetPage();
}, [debouncedSearch, resetPage]);
const filters = useMemo<BuiltTrainListFilters>(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(yardFilter !== "ALL" ? { currentYardId: yardFilter } : {}),
sortBy: "createdAt",
sortOrder: "DESC",
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedSearch,
statusFilter,
yardFilter,
],
);
const trainsQuery = useQuery(
api.trainBuilder.list.queryOptions({
input: { filters },
placeholderData: keepPreviousData,
staleTime: 30_000,
}),
);
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const trains = trainsQuery.data?.items ?? [];
const totalTrains = trainsQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, trainsQuery.data?.meta.totalPages ?? 1);
const stats = useMemo(() => {
const base = { available: 0, scheduled: 0, inService: 0, wagons: 0 };
for (const train of trains) {
if (train.status === "AVAILABLE") base.available += 1;
if (train.status === "SCHEDULED") base.scheduled += 1;
if (train.status === "IN_SERVICE") base.inService += 1;
base.wagons += train.wagonCount;
}
return base;
}, [trains]);
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const columns = useMemo((): ColumnDef<BuiltTrainSummary>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "code",
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
flexShrink: 0,
}}
>
<TrainIcon size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.code}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{row.original.trainName ?? "—"}
</Text>
</Stack>
</Group>
),
},
{
id: "yard",
header: "Yard",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm">{row.original.currentYard?.label ?? "—"}</Text>
),
},
{
id: "locomotives",
header: "Locomotives",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const locos = row.original.locomotives;
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{locos.map((l) => l.code).join(" + ")}
</Text>
</Group>
);
},
},
{
id: "consist",
header: "Consist",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm">
{row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
{row.original.totalLengthMeters}m
</Text>
),
},
{
id: "capacity",
header: "Haul limit",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={500}>
{row.original.maxPullWeightTons}T
</Text>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={trainStatusColor(row.original.status)} variant="light">
{trainStatusLabel(row.original.status)}
</Badge>
),
},
];
}, []);
const tableStatus = trainsQuery.isLoading
? "loading"
: trainsQuery.isError
? "error"
: "success";
return (
<PageContainer>
<PageHeader
title="Train Builder"
subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit."
action={
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
Build train
</Button>
}
/>
<KpiStrip
items={[
{ label: "Trains", value: totalTrains, icon: TrainIcon },
{ label: "Available", value: stats.available, icon: Hammer },
{ label: "Scheduled / in service", value: stats.scheduled + stats.inService, icon: Weight },
{ label: "Wagons coupled", value: stats.wagons, icon: Ruler },
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group gap="sm" wrap="wrap">
<TextInput
size="sm"
radius="lg"
placeholder="Search by code or name…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => {
if (!v) return;
setStatusFilter(v as "ALL" | BuiltTrainStatus);
resetPage();
}}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "AVAILABLE", label: "Available" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "IN_SERVICE", label: "In service" },
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
{ value: "OUT_OF_SERVICE", label: "Out of service" },
]}
w={180}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Yard"
searchable
value={yardFilter}
onChange={(v) => {
setYardFilter(v ?? "ALL");
resetPage();
}}
data={[{ value: "ALL", label: "All yards" }, ...yardOptions]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</Group>
</Box>
<DataTable
columns={columns}
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
error={
trainsQuery.isError
? {
message: "Failed to load trains.",
onRetry: () => void trainsQuery.refetch(),
}
: undefined
}
emptyMessage="No trains built yet — build the first one"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: totalTrains,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "trains" } }}
/>
)}
/>
</Stack>
</Card>
<BuildTrainModal
opened={buildOpen}
onClose={() => setBuildOpen(false)}
onBuilt={(composition) => navigate(`/dashboard/train-builder/${composition.id}`)}
/>
</PageContainer>
);
}

View File

@@ -7,7 +7,6 @@ import {
Group,
Menu,
Modal,
MultiSelect,
Select,
SimpleGrid,
Stack,
@@ -41,10 +40,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import {
locomotiveOption,
showScheduleWarnings,
} from "@/components/trainScheduling/locomotiveOptions";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import {
RouteCorridor,
StatusPill,
@@ -119,7 +115,7 @@ export default function TrainScheduleV2ListPage() {
useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const [trainId, setTrainId] = useState("");
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
@@ -186,9 +182,10 @@ export default function TrainScheduleV2ListPage() {
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
);
const locomotivesQuery = useQuery(
api.trainScheduling.availableLocomotives.queryOptions({
input: { routeId: routeId || undefined },
const trainsQuery = useQuery(
api.trainScheduling.availableTrains.queryOptions({
input: { routeId },
enabled: Boolean(routeId),
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
@@ -203,17 +200,17 @@ export default function TrainScheduleV2ListPage() {
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
const locomotiveYardHint = useMemo(() => {
const trainYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
return `All in-service locomotives are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
}, [selectedRoute]);
useEffect(() => {
setLocomotiveIds([]);
setTrainId("");
}, [routeId]);
// Filtering, sorting, and paging all happen server-side — `schedules` IS the
@@ -326,10 +323,29 @@ export default function TrainScheduleV2ListPage() {
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "loco",
header: "Locomotives",
id: "train",
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Schedules created from the Train Builder carry the train code;
// legacy rows fall back to their locomotive set.
if (row.original.train) {
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.train.code}
</Text>
{row.original.train.trainName ? (
<Text size="xs" c="dimmed" lh={1.2}>
{row.original.train.trainName}
</Text>
) : null}
</Stack>
</Group>
);
}
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
@@ -456,9 +472,9 @@ export default function TrainScheduleV2ListPage() {
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
if (!routeId || !scheduleDate || !trainId) {
toast({
title: "Select route, date, and at least two locomotives",
title: "Select route, date, and the train to run",
variant: "destructive",
});
return;
@@ -475,7 +491,7 @@ export default function TrainScheduleV2ListPage() {
payload: {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
locomotiveIds,
trainId,
},
});
toast({ title: "Train schedule created" });
@@ -728,7 +744,7 @@ export default function TrainScheduleV2ListPage() {
/>
{routeId ? (
<Text size="xs" c="dimmed">
{locomotiveYardHint}
{trainYardHint}
</Text>
) : null}
<TextInput
@@ -738,24 +754,26 @@ export default function TrainScheduleV2ListPage() {
value={scheduleDate}
onChange={(e) => setScheduleDate(e.currentTarget.value)}
/>
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back)"
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => locomotiveOption(l))}
value={locomotiveIds}
onChange={setLocomotiveIds}
<Select
label="Train"
description="A built train (Train Builder) runs this departure with its locomotives and wagons"
placeholder={routeId ? "Select a train" : "Select a route first"}
data={(trainsQuery.data ?? []).map((train) => ({
value: train.id,
label: `${train.code}${train.trainName ? `${train.trainName}` : ""} · ${
train.locomotives.length
} locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${
train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""
}`,
}))}
value={trainId || null}
onChange={(v) => setTrainId(v ?? "")}
searchable
disabled={!routeId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
}
nothingFoundMessage={
routeId ? "No available locomotives for this corridor" : "Select a route first"
routeId
? "No built trains yet — assemble one in the Train Builder first"
: "Select a route first"
}
/>
<Group justify="flex-end">

View File

@@ -156,6 +156,7 @@ import {
import {
locomotivesService,
type Locomotive,
type LocomotiveListFilters,
type SaveLocomotivePayload,
} from "./locomotives.service";
import { overviewService } from "./overview.service";
@@ -181,13 +182,24 @@ import {
type SaveSignaturePayload,
} from "./signatures.service";
import { trainService, type Train } from "./trains.service";
import {
trainBuilderService,
type AvailableTrain,
type BuildTrainPayload,
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type TrainComposition,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import {
wagonService,
wagonTransferRequestService,
type Wagon,
type WagonListFilters,
type WagonMovementRecord,
type WagonTransferRequest,
type CreateTransferRequestPayload,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -213,6 +225,19 @@ const TRAIN_SCHEDULING_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.BOOKINGS.ROOT,
];
/**
* Train Builder mutations change wagon/locomotive availability and the
* schedule-creation train picker alongside the builder's own lists.
*/
const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
QUERY_KEYS.TRAIN_BUILDER.ROOT,
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
["wagons"],
["locomotives"],
["trains"],
QUERY_KEYS.FLEET.ROOT,
];
export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
@@ -286,6 +311,14 @@ export const api = {
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId),
),
availableTrains: endpoint<{ routeId: string }, AvailableTrain[]>(
"train-scheduling",
"available-trains",
({ routeId }) =>
trainBuilderService.availableTrains(routeId).then((r) => r.data),
({ routeId }) => QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
),
bookableSchedules: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
BookableSchedule[]
@@ -1621,6 +1654,55 @@ export const api = {
),
},
wagonTransferRequests: {
list: endpoint<
{ status?: WagonTransferRequest["status"] },
WagonTransferRequest[]
>(
"wagonTransferRequests",
"list",
({ status }) =>
wagonTransferRequestService.list(status).then((r) => r.data),
({ status }) => ["wagonTransferRequests", "list", status ?? "ALL"],
),
getById: endpoint<{ id: string }, WagonTransferRequest>(
"wagonTransferRequests",
"getById",
({ id }) => wagonTransferRequestService.getById(id).then((r) => r.data),
({ id }) => ["wagonTransferRequests", "detail", id],
),
create: endpoint<CreateTransferRequestPayload, WagonTransferRequest>(
"wagonTransferRequests",
"create",
(payload) =>
wagonTransferRequestService.create(payload).then((r) => r.data),
undefined,
() => [["wagonTransferRequests"]],
),
fulfill: endpoint<
{ id: string; wagonIds: string[] },
WagonTransferRequest
>(
"wagonTransferRequests",
"fulfill",
({ id, wagonIds }) =>
wagonTransferRequestService.fulfill(id, wagonIds).then((r) => r.data),
undefined,
() => [["wagonTransferRequests"], ["wagons"]],
),
cancel: endpoint<{ id: string }, WagonTransferRequest>(
"wagonTransferRequests",
"cancel",
({ id }) => wagonTransferRequestService.cancel(id).then((r) => r.data),
undefined,
() => [["wagonTransferRequests"]],
),
},
trains: {
list: endpoint<void, Train[]>(
"trains",
@@ -1661,6 +1743,80 @@ export const api = {
),
},
// Train Builder — persistent coded consists (2+ locomotives + ordered wagons)
// that train scheduling can reference as a unit. Every mutation also touches
// wagon/locomotive availability, so those roots are invalidated together.
trainBuilder: {
list: endpoint<{ filters?: BuiltTrainListFilters }, BuiltTrainListResponse>(
"train-builder",
"list",
({ filters }) => trainBuilderService.list(filters).then((r) => r.data),
({ filters }) => QUERY_KEYS.TRAIN_BUILDER.list(filters),
),
composition: endpoint<{ id: string }, TrainComposition>(
"train-builder",
"composition",
({ id }) => trainBuilderService.getComposition(id).then((r) => r.data),
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
),
build: endpoint<BuildTrainPayload, TrainComposition>(
"train-builder",
"build",
(payload) => trainBuilderService.build(payload).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
setLocomotives: endpoint<
{ id: string; locomotiveIds: string[] },
TrainComposition
>(
"train-builder",
"setLocomotives",
({ id, locomotiveIds }) =>
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"assignWagons",
({ id, wagonIds }) =>
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
"train-builder",
"removeWagon",
({ id, wagonId }) =>
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",
({ id, wagonIds }) =>
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
disband: endpoint<string, void>(
"train-builder",
"disband",
(id) => trainBuilderService.disband(id).then(() => undefined),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
},
locomotives: {
list: endpoint<void, Locomotive[]>(
"locomotives",
@@ -1669,6 +1825,13 @@ export const api = {
() => ["locomotives"],
),
listFiltered: endpoint<{ filters?: LocomotiveListFilters }, Locomotive[]>(
"locomotives",
"listFiltered",
({ filters }) => locomotivesService.getAll(filters ?? {}).then((r) => r.data),
({ filters }) => ["locomotives", "list", filters ?? {}],
),
create: endpoint<Partial<SaveLocomotivePayload>, Locomotive>(
"locomotives",
"create",

View File

@@ -0,0 +1,172 @@
import { api as apiClient } from "../auth/http";
// ---------------------------------------------------------------------------
// Types — mirror the freight API's train-builder responses
// ---------------------------------------------------------------------------
export type BuiltTrainStatus =
| "AVAILABLE"
| "SCHEDULED"
| "IN_SERVICE"
| "UNDER_MAINTENANCE"
| "OUT_OF_SERVICE";
export interface YardRefLite {
id: string;
code: string;
label: string;
}
export interface BuiltTrainSummary {
id: string;
code: string;
trainName: string | null;
status: BuiltTrainStatus;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
maxGrossTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
}
export interface TrainCompositionLocomotive {
id: string;
code: string;
name: string | null;
locomotiveType: "DIESEL" | "ELECTRIC";
status: string;
sequenceNo: number;
role: "LEAD" | "ASSIST";
currentYardId: string | null;
currentYard: YardRefLite | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
}
export interface TrainCompositionWagon {
id: string;
wagonNumber: string;
sequenceNumber: number | null;
status: string;
wagonType: {
id: string;
code: string;
name: string;
capacityTons: number;
tareWeightTons: number;
lengthMeters: number;
} | null;
}
export interface TrainCompositionTotals {
wagonCount: number;
totalTareTons: number;
totalCapacityTons: number;
maxGrossTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
weightUtilizationPct: number | null;
lengthUtilizationPct: number | null;
}
export interface TrainComposition {
id: string;
code: string;
trainName: string | null;
status: BuiltTrainStatus;
notes: string | null;
createdAt: string;
currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[];
totals: TrainCompositionTotals;
activeSchedules: Array<{ id: string; status: string; reference: string | null }>;
editable: boolean;
}
export interface BuiltTrainListFilters {
page?: number;
pageSize?: number;
search?: string;
status?: BuiltTrainStatus;
currentYardId?: string;
sortBy?: "code" | "trainName" | "status" | "createdAt";
sortOrder?: "ASC" | "DESC";
}
export interface BuiltTrainListResponse {
items: BuiltTrainSummary[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}
export interface BuildTrainPayload {
code: string;
currentYardId: string;
locomotiveIds: string[];
wagonIds?: string[];
trainName?: string;
notes?: string;
}
/** Built train annotated for the schedule-creation picker. */
export interface AvailableTrain {
id: string;
code: string;
trainName: string | null;
status: BuiltTrainStatus;
currentYardId: string | null;
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
maxGrossTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
atOriginYard: boolean;
futureScheduleCount: number;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
const BASE = "/train-builder";
const toQuery = (filters: BuiltTrainListFilters = {}) => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
params.set(key, String(value));
}
});
const qs = params.toString();
return qs ? `?${qs}` : "";
};
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
/** Built trains schedulable on a route (train-scheduling picker). */
availableTrains: (routeId: string) =>
apiClient.get<AvailableTrain[]>(`/train-scheduling/available-trains`, {
params: { routeId },
}),
};

View File

@@ -90,3 +90,53 @@ export const wagonService = {
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) =>
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }),
};
/**
* A two-person wagon-transfer request: a requester asks for N wagons of a type
* to move between yards (count only); OCC hand-picks the wagons and fulfils it.
*/
export interface WagonTransferRequest {
id: string;
fromYardId: string;
toYardId: string;
wagonTypeId: string;
quantity: number;
status: Freight.WagonTransferRequestStatus;
requestedByUserId: string | null;
fulfilledByUserId: string | null;
fulfilledAt: string | null;
note: string | null;
fromYard?: { id: string; label?: string; code?: string } | null;
toYard?: { id: string; label?: string; code?: string } | null;
wagonType?: { id: string; code?: string; name?: string } | null;
createdAt: string;
}
export interface CreateTransferRequestPayload {
fromYardId: string;
toYardId: string;
wagonTypeId: string;
quantity: number;
note?: string;
}
export const wagonTransferRequestService = {
list: (status?: Freight.WagonTransferRequestStatus) =>
apiClient.get<WagonTransferRequest[]>(
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
),
getById: (id: string) =>
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>
apiClient.post<WagonTransferRequest>('/wagon-transfer-requests', data),
/** OCC: execute the transfer with the hand-picked wagons. */
fulfill: (id: string, wagonIds: string[]) =>
apiClient.post<WagonTransferRequest>(
`/wagon-transfer-requests/${id}/fulfill`,
{ wagonIds },
),
cancel: (id: string) =>
apiClient.post<WagonTransferRequest>(
`/wagon-transfer-requests/${id}/cancel`,
),
};

View File

@@ -162,6 +162,12 @@ export interface TrainScheduleListItem {
origin: string | null;
destination: string | null;
freightType?: FreightType | null;
/** Built train (Train Builder) behind this departure, when scheduled by train. */
train?: {
id: string;
code: string;
trainName?: string | null;
} | null;
locomotive:
| {
id: string;
@@ -771,8 +777,10 @@ export interface ReschedulePlan {
export interface CreateTrainSchedulePayload {
routeId: string;
scheduleDate: string;
/** Locomotives pulling the train (minimum 2 — front and back). */
locomotiveIds: string[];
/** Built train (Train Builder) to run this departure — its locomotives are used. */
trainId?: string;
/** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */
locomotiveIds?: string[];
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;

View File

@@ -331,6 +331,33 @@ export interface IWagonMovement extends BaseEntity {
note?: string | null;
}
/**
* Lifecycle of a two-person wagon-transfer request. A requester asks for N
* wagons of a type to move from one yard to another (count only, no specific
* wagons); OCC staff later pick the physical wagons and execute the move.
*/
export enum WagonTransferRequestStatus {
/** Awaiting OCC fulfilment. */
Pending = "PENDING",
/** OCC picked the wagons and executed the transfer. */
Fulfilled = "FULFILLED",
/** Requester or OCC withdrew it before fulfilment. */
Cancelled = "CANCELLED",
}
export interface IWagonTransferRequest extends BaseEntity {
fromYardId: string;
toYardId: string;
wagonTypeId: string;
/** How many wagons of `wagonTypeId` to move out of `fromYardId`. */
quantity: number;
status: WagonTransferRequestStatus;
requestedByUserId?: string | null;
fulfilledByUserId?: string | null;
fulfilledAt?: string | null;
note?: string | null;
}
export enum BulkPricingUnit {
PerWagon = "PER_WAGON",
PerTon = "PER_TON",