diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index afb214137..6c31c4f08 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -53,22 +53,23 @@ import { } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; -import { DemoUsersSeeder } from "./seed/demo-users.seeder"; -import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; +// Disabled seeds — imports commented out with their provider/injection/run below. +// import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; -import { PricingDataSeeder } from "./seed/pricing-data.seeder"; +// import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; -import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; -import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; -import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; -import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; -import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; -import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; -import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; -import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; +// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; +// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; +// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; +// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; +// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; +// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; +// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; +// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; -import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; -import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; +// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -192,21 +193,22 @@ import { LoggerMiddleware } from "./logger.middleware"; providers: [ EdrOrgSeeder, FreightPositionsSeeder, - DemoUsersSeeder, - FreightStaffUsersSeeder, - PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, - DemoFreightDataSeeder, - GovCompaniesSeeder, - IndodeFacilitySeeder, - Batch14TestDataSeeder, - Batch5TestDataSeeder, - Batch7TestDataSeeder, - Batch8TestDataSeeder, - WarehouseDemoSeeder, - ExportDjiboutiInterchangeDemoSeeder, - MarshallingDemoTrainsSeeder, + // Disabled seeds — providers commented out (imports/injection/run too): + // DemoUsersSeeder, + // FreightStaffUsersSeeder, + // PricingDataSeeder, + // DemoFreightDataSeeder, + // GovCompaniesSeeder, + // IndodeFacilitySeeder, + // Batch14TestDataSeeder, + // Batch5TestDataSeeder, + // Batch7TestDataSeeder, + // Batch8TestDataSeeder, + // WarehouseDemoSeeder, + // ExportDjiboutiInterchangeDemoSeeder, + // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, ], @@ -216,51 +218,66 @@ export class AppModule implements OnApplicationBootstrap { private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, - private readonly demoUsersSeeder: DemoUsersSeeder, - private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, - private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, - private readonly indodeFacilitySeeder: IndodeFacilitySeeder, - private readonly batch14TestDataSeeder: Batch14TestDataSeeder, - private readonly batch5TestDataSeeder: Batch5TestDataSeeder, - private readonly batch7TestDataSeeder: Batch7TestDataSeeder, - private readonly batch8TestDataSeeder: Batch8TestDataSeeder, - private readonly warehouseDemoSeeder: WarehouseDemoSeeder, - private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, - private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, - private readonly demoFreightDataSeeder: DemoFreightDataSeeder, - private readonly govCompaniesSeeder: GovCompaniesSeeder, + // Disabled seeds — injections commented out (imports/provider/run too): + // private readonly demoUsersSeeder: DemoUsersSeeder, + // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + // private readonly pricingDataSeeder: PricingDataSeeder, + // private readonly indodeFacilitySeeder: IndodeFacilitySeeder, + // private readonly batch14TestDataSeeder: Batch14TestDataSeeder, + // private readonly batch5TestDataSeeder: Batch5TestDataSeeder, + // private readonly batch7TestDataSeeder: Batch7TestDataSeeder, + // private readonly batch8TestDataSeeder: Batch8TestDataSeeder, + // private readonly warehouseDemoSeeder: WarehouseDemoSeeder, + // private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, + // private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, + // private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + // private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { + // ── Enabled: permissions + file-upload settings (+ dropdown settings) only ── + // Everything else below is intentionally disabled. Seeders stay registered + // as providers and injected; only their .run() calls are commented out, so + // re-enabling any of them is a one-line uncomment. + + // Permissions foundation — keep enabled: + // freightPermissionKeyMigration → renames legacy permission keys + // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions + // edrOrgSeeder → seeds org/unit + the Permission catalog + // freightPositionsSeeder → seeds Position + PositionPermission rows + // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); await this.seeder.run(); await this.edrOrgSeeder.run(); await this.freightPositionsSeeder.run(); - await this.demoUsersSeeder.run(); - await this.freightStaffUsersSeeder.run(); - await this.pricingDataSeeder.run(); + + // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); - await this.indodeFacilitySeeder.run(); - await this.batch14TestDataSeeder.run(); - await this.batch5TestDataSeeder.run(); - await this.batch7TestDataSeeder.run(); - await this.batch8TestDataSeeder.run(); - await this.warehouseDemoSeeder.run(); - await this.exportDjiboutiInterchangeDemoSeeder.run(); - await this.marshallingDemoTrainsSeeder.run(); - // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. - // Each block self-guards on an empty-table check, so this is safe every boot. - // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, - // FileUploadSettingsSeeder) are intentionally disabled — they stay - // registered as providers but are not run. Re-inject + call .run() to enable. - // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval - // rules are disabled inside the seeder). Kept running for the staff users. - await this.demoFreightDataSeeder.run(); - // Government entities (with importer/exporter profiles) that government - // bookings bill to. Idempotent — keyed by fixed IDs. - await this.govCompaniesSeeder.run(); + + // Dropdown settings are not seeded on boot; run them with + // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). + + // ── Disabled: demo / test / reference data seeds ── + // Uncomment a line to re-enable that seed. + // await this.demoUsersSeeder.run(); + // await this.freightStaffUsersSeeder.run(); + // await this.pricingDataSeeder.run(); + // await this.indodeFacilitySeeder.run(); + // await this.batch14TestDataSeeder.run(); + // await this.batch5TestDataSeeder.run(); + // await this.batch7TestDataSeeder.run(); + // await this.batch8TestDataSeeder.run(); + // await this.warehouseDemoSeeder.run(); + // await this.exportDjiboutiInterchangeDemoSeeder.run(); + // await this.marshallingDemoTrainsSeeder.run(); + // demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval + // rules are already disabled inside the seeder). + // await this.demoFreightDataSeeder.run(); + // Government entities (importer/exporter profiles) that government bookings + // bill to. Idempotent — keyed by fixed IDs. + // await this.govCompaniesSeeder.run(); } configure(consumer: MiddlewareConsumer) { diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d3344aa5a..9769a7f18 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -34,6 +34,10 @@ export const WagonTransferRequest = () => export const WagonTransferFulfill = () => BookingStaff(FREIGHT_PERMS.wagons.transferFulfill); +/** Admin: read every staffer's wagon-transfer history (not just one's own). */ +export const WagonTransferHistoryAll = () => + BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll); + /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts new file mode 100644 index 000000000..3f10fa874 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Link each physical wagon move back to the transfer request that drove it, so + * the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103". + * Nullable — legacy moves and non-request manual corrections carry no request. + * Also indexes `moved_by_user_id` for the per-user history queries. + */ +export class LinkWagonMovementToTransferRequest2180000000000 + implements MigrationInterface +{ + name = 'LinkWagonMovementToTransferRequest2180000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request' + ) THEN + ALTER TABLE freight.wagon_movements + ADD CONSTRAINT fk_wm_transfer_request + FOREIGN KEY (transfer_request_id) + REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_transfer_request + ON freight.wagon_movements (transfer_request_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_moved_by + ON freight.wagon_movements (moved_by_user_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP CONSTRAINT IF EXISTS fk_wm_transfer_request + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP COLUMN IF EXISTS transfer_request_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index a9711c1e5..e51d4ad23 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail { docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; + /** Built train (Train Builder) behind this departure, when scheduled by train. */ + train: BatchBoardSchedule["train"]; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; @@ -235,6 +237,12 @@ export interface BatchBoardSchedule { docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; + /** Built train (Train Builder) behind this departure, when scheduled by train. */ + train: { + id: string; + code: string; + trainName: string | null; + } | null; locomotive: { code: string; name: string | null; @@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true }, + trainSet: { locomotive: true, train: true }, originStation: true, destinationStation: true, // Yards supply the route's display name for `routeName` below; @@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit { ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, + train: s.trainSet?.train + ? { + id: s.trainSet.train.id, + code: s.trainSet.train.code, + trainName: s.trainSet.train.trainName ?? null, + } + : null, locomotive: loco ? { code: loco.code, @@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit { ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, + train: s.trainSet?.train + ? { + id: s.trainSet.train.id, + code: s.trainSet.train.code, + trainName: s.trainSet.train.trainName ?? null, + } + : null, locomotive: loco ? { code: loco.code, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 874516548..7e0720238 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -282,6 +282,8 @@ interface BookingWindowRow { origin_code: string | null; destination_label: string | null; destination_code: string | null; + /** Full ordered corridor (origin → milestones → destination) from the schedule's route. */ + route_stations: string[] | null; } @Injectable() @@ -1329,9 +1331,15 @@ export class TrainSchedulingService { limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0); const lengthCapWithOverage = limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0); - if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) { + // The locomotives pull GROSS weight: the customers' cargo plus the empty + // weight of every planned wagon — cargo-only comparison understates the load. + const planTareTons = roundTons( + wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), + ); + const grossWeightTons = roundTons(totalWeightTons + planTareTons); + if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { throw new BadRequestException( - `Train set locomotives cannot pull ${totalWeightTons}T`, + `Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`, ); } if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { @@ -4605,14 +4613,8 @@ export class TrainSchedulingService { name: link.locomotive!.name ?? null, })), wagonCount: wagons.length, - maxGrossTons: roundTons( - wagons.reduce( - (sum, w) => - sum + - (Number(w.wagonType?.tareWeightTons) || 0) + - (Number(w.wagonType?.capacityTons) || 0), - 0, - ), + totalTareTons: roundTons( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0), ), totalLengthMeters: roundTons( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), @@ -4725,7 +4727,11 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts LEFT JOIN freight.contract_routes cr ON cr.deleted_at IS NULL @@ -4777,7 +4783,11 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts JOIN freight.contract_routes cr ON cr.contract_id = $1 @@ -4823,7 +4833,11 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -4845,6 +4859,17 @@ export class TrainSchedulingService { } private mapBookingWindowRow(r: BookingWindowRow) { + const origin = r.origin_label ?? r.origin_code ?? null; + const destination = r.destination_label ?? r.destination_code ?? null; + // Full corridor from the route's milestones (origin → stops → destination). + // Falls back to the schedule's origin/destination when no milestones exist. + const milestoneStops = (r.route_stations ?? []).filter( + (s): s is string => Boolean(s), + ); + const routeStations = + milestoneStops.length >= 2 + ? milestoneStops + : [origin, destination].filter((s): s is string => Boolean(s)); return { scheduleId: r.schedule_id, reference: r.reference ?? null, @@ -4860,8 +4885,9 @@ export class TrainSchedulingService { bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, - origin: r.origin_label ?? r.origin_code ?? null, - destination: r.destination_label ?? r.destination_code ?? null, + origin, + destination, + routeStations, }; } diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts new file mode 100644 index 000000000..428651288 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class UpdateTrainYardDto { + @ApiProperty({ + format: 'uuid', + description: + 'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.', + }) + @IsUUID() + currentYardId!: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 7281aca31..7ed3e4c42 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -7,6 +7,7 @@ import { HttpStatus, Param, ParseUUIDPipe, + Patch, Post, Put, Query, @@ -19,6 +20,7 @@ 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 { UpdateTrainYardDto } from './dto/update-train-yard.dto'; import { TrainBuilderService } from './train-builder.service'; @ApiTags('train-builder') @@ -57,6 +59,15 @@ export class TrainBuilderController { return this.trainBuilderService.setLocomotives(id, dto); } + @Patch(':id/yard') + @FleetManage() + @ApiOperation({ + summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', + }) + setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) { + return this.trainBuilderService.setYard(id, dto.currentYardId); + } + @Post(':id/wagons') @FleetManage() @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index c28873234..b92ab21d6 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -1,4 +1,4 @@ -import { Freight, WagonStatus } from '@edr/types'; +import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, ConflictException, @@ -10,6 +10,7 @@ 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 { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -202,7 +203,6 @@ export class TrainBuilderService { 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); @@ -221,14 +221,17 @@ export class TrainBuilderService { totals: { wagonCount: wagons.length, totalTareTons, + // Informational only — building never checks against full capacity; + // the real gross check (cargo + tare vs haul limit) runs at allocation. totalCapacityTons, - maxGrossTons, totalLengthMeters, maxPullWeightTons, maxTrainLengthMeters, - // Fully loaded gross vs. what the weakest locomotive can haul. - weightUtilizationPct: maxPullWeightTons - ? round((maxGrossTons / maxPullWeightTons) * 100) + // Cargo the locomotives can still haul once pulling the empty consist. + payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)), + // Share of the haul limit consumed by the empty wagons alone. + tareUtilizationPct: maxPullWeightTons + ? round((totalTareTons / maxPullWeightTons) * 100) : null, lengthUtilizationPct: maxTrainLengthMeters ? round((totalLengthMeters / maxTrainLengthMeters) * 100) @@ -269,6 +272,54 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Relocate the train to another yard. The consist moves as one unit: every + * coupled locomotive and wagon follows to the new yard (so their current + * yards always match the train's), and each wagon gets a movement-ledger row. + * Blocked while the train is out on a dispatched run. + */ + async setYard(id: string, currentYardId: string) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + if (train.currentYardId === currentYardId) return; + const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); + if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + + await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); + + const links = await manager + .getRepository(TrainLocomotive) + .find({ where: { trainId: train.id } }); + if (links.length) { + await manager + .getRepository(Locomotive) + .update( + { id: In(links.map((link) => link.locomotiveId)) }, + { currentYardId: yard.id }, + ); + } + + const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); + const now = new Date(); + for (const wagon of wagons) { + if (wagon.currentYardId === yard.id) continue; + await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); + // Ledger row keeps the wagon's yard history auditable (mirrors the + // manual-relocation path in the wagons service). + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + kind: WagonMovementKind.Manual, + occurredAt: now, + }), + ); + } + }); + 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) => { @@ -361,12 +412,8 @@ export class TrainBuilderService { .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, - ), + const totalTareTons = round( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0), ); return { id: train.id, @@ -379,7 +426,7 @@ export class TrainBuilderService { : null, locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })), wagonCount: wagons.length, - maxGrossTons, + totalTareTons, totalLengthMeters: round( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts index 7c5ed092c..a36e5deea 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Wagon } from './wagon.entity'; +import { WagonTransferRequest } from './wagon-transfer-request.entity'; /** * Ledger of every physical wagon relocation between yards — one row per move. @@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity { @Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true }) movedByUserId?: string | null; + /** The transfer request this move fulfilled, when it came from one. */ + @Column({ name: 'transfer_request_id', type: 'uuid', nullable: true }) + transferRequestId?: string | null; + + @ManyToOne(() => WagonTransferRequest, { nullable: true }) + @JoinColumn({ name: 'transfer_request_id' }) + transferRequest?: WagonTransferRequest | null; + @Column({ name: 'occurred_at', type: 'timestamptz' }) occurredAt!: Date; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index 07557f431..12fdaf27c 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -16,6 +16,7 @@ import { FleetManage, FleetView, WagonTransferFulfill, + WagonTransferHistoryAll, WagonTransferRequest, } from '../../common/booking-guards'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; @@ -50,6 +51,30 @@ export class WagonTransferRequestsController { return this.service.listRequests(status); } + // NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express + // matches in declaration order, so `/history` would otherwise be captured by + // the `:id` param route (and rejected by ParseUUIDPipe). + @Get('history') + @ApiOperation({ + summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)", + }) + myHistory(@CurrentUser() user: TCurrentUser) { + // Never fall through to the all-staff view: getHistory(undefined) means + // "everyone", so a missing caller id must return empty, not leak scope. + if (!user?.id) return { requests: [], movements: [] }; + return this.service.getHistory(user.id); + } + + @Get('history/all') + @WagonTransferHistoryAll() + @ApiQuery({ name: 'userId', required: false }) + @ApiOperation({ + summary: "Admin: any/all staff's transfer history (optional ?userId filter)", + }) + allHistory(@Query('userId') userId?: string) { + return this.service.getHistory(userId); + } + @Get(':id') @ApiOperation({ summary: 'Get one transfer request' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 64b408b6a..bf69d767d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -6,14 +6,24 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, IsNull, Not, 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 { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { WagonsService } from './wagons.service'; +/** Bundled per-user activity: requests they touched + wagons they moved. */ +export interface TransferHistory { + requests: WagonTransferRequest[]; + movements: WagonMovement[]; +} + +/** How many ledger rows the history returns at most (newest first). */ +const HISTORY_LIMIT = 500; + const REQUEST_RELATIONS = { fromYard: true, toYard: true, @@ -33,6 +43,8 @@ export class WagonTransferRequestsService { private readonly requestRepo: Repository, @InjectRepository(Wagon) private readonly wagonRepo: Repository, + @InjectRepository(WagonMovement) + private readonly movementRepo: Repository, private readonly wagonsService: WagonsService, ) {} @@ -125,10 +137,12 @@ export class WagonTransferRequestsService { ); } - // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows). + // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows, + // each stamped with this request's id so history can link them back). await this.wagonsService.bulkTransfer( { wagonIds, toYardId: request.toYardId }, userId, + { transferRequestId: request.id }, ); request.status = WagonTransferRequestStatus.Fulfilled; @@ -138,6 +152,38 @@ export class WagonTransferRequestsService { return this.findById(id); } + /** + * Per-user transfer history: the requests a user filed OR fulfilled, plus the + * individual wagons they physically moved (linked back to their request when + * one drove the move). Pass a `userId` to scope to one staffer; pass + * `undefined` for the admin all-staff view. Scope is decided by the CALLER + * (the controller passes the caller's id unless they hold the history-all + * permission) — this method trusts its argument. + */ + async getHistory(userId?: string | null): Promise { + const requests = await this.requestRepo.find({ + where: userId + ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }] + : {}, + relations: REQUEST_RELATIONS, + order: { createdAt: 'DESC' }, + take: HISTORY_LIMIT, + }); + + const movements = await this.movementRepo.find({ + // Own view: moves I made. All view: every user-attributed move (skip the + // system-written loaded/reposition legs that carry no mover). + where: userId + ? { movedByUserId: userId } + : { movedByUserId: Not(IsNull()) }, + relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true }, + order: { occurredAt: 'DESC' }, + take: HISTORY_LIMIT, + }); + + return { requests, movements }; + } + /** Withdraw a still-PENDING request. */ async cancelRequest(id: string): Promise { const request = await this.findById(id); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 4bb1afd33..107a31e7e 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Wagon } from './entities/wagon.entity'; +import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @Module({ - imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])], + imports: [ + TypeOrmModule.forFeature([ + Wagon, + WagonMovement, + WagonTransferRequest, + Train, + Yard, + ]), + ], controllers: [ WagonsController, TrainWagonsReorderController, diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index ad39fbf7a..55dc177c9 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -180,6 +180,7 @@ export class WagonsService { async bulkTransfer( dto: BulkTransferWagonsDto, userId?: string | null, + opts?: { transferRequestId?: string | null }, ): Promise<{ moved: number }> { const { wagonIds, toYardId } = dto; if (!wagonIds.length) return { moved: 0 }; @@ -215,6 +216,7 @@ export class WagonsService { toYardId, kind: WagonMovementKind.Manual, movedByUserId: userId ?? null, + transferRequestId: opts?.transferRequestId ?? null, occurredAt: new Date(), }), ); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 7c3fe0700..6e4ec81b1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ 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('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), 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'), @@ -446,6 +447,9 @@ export const FREIGHT_PERMS = { // 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', + // Admin: read every staffer's transfer history. Without it, a user only sees + // their own (the /history endpoint uses the caller id, backend-enforced). + transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', }, trains: { view: 'edr_freight_app:trains:view', diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 223b357c1..0cd00d63b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -49,6 +49,7 @@ import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, useContractDetail, @@ -180,6 +181,9 @@ export default function GlCreateBookingForm() { }>(); const [searchParams] = useSearchParams(); const requestIdParam = searchParams.get("requestId"); + // Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same + // contract (GL only picks a new schedule). Set by the clearance Rebook action. + const copyFromParam = searchParams.get("copyFrom"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); @@ -205,6 +209,13 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // The expired booking a Rebook is copying from (its cargo seeds the form). + const { data: copyFromBooking } = useQuery({ + queryKey: ["rebook-copy-from", copyFromParam], + queryFn: () => bookingsService.getById(copyFromParam!), + enabled: Boolean(copyFromParam), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -363,6 +374,28 @@ export default function GlCreateBookingForm() { if (bookingRequest.notes) setNotes(bookingRequest.notes); }, [bookingRequest, prefilled]); + // Rebook seed: copy the source booking's container lines once. (Bulk weight / + // item count isn't on the booking payload yet, so bulk rebooks fall through to + // the normal contract seed and GL re-enters the quantity.) + useEffect(() => { + if (!copyFromBooking || prefilled) return; + const lines = copyFromBooking.bookingContainers ?? []; + if (!lines.length) return; + setPrefilled(true); + setContainerLines( + lines.map((c) => { + const qty = Math.max(1, c.quantity); + return { + containerSize: String(c.containerType?.sizeFt ?? ""), + quantity: String(qty), + hazardousQuantity: "0", + reeferQuantity: "0", + units: Array.from({ length: qty }, emptyUnit), + }; + }), + ); + }, [copyFromBooking, prefilled]); + // Seed one shipment line per contracted size exactly once — same seeding the // portal form does. Subsequent renders reuse the lines. useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx new file mode 100644 index 000000000..0e4bddbc3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx @@ -0,0 +1,103 @@ +import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { MapPin } from "lucide-react"; +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; +}; + +/** + * Relocate the train to another yard. The consist moves as one unit — every + * coupled locomotive and wagon follows, so their current yards always match + * the train's. + */ +export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) { + const { toast } = useToast(); + const [yardId, setYardId] = useState(""); + + const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); + const setYard = useMutation(api.trainBuilder.setYard.mutationOptions()); + + useEffect(() => { + if (opened) setYardId(composition?.currentYard?.id ?? ""); + }, [opened, composition]); + + const handleSave = async () => { + if (!composition || !yardId) return; + try { + await setYard.mutateAsync({ id: composition.id, currentYardId: yardId }); + toast({ title: "Train relocated" }); + onClose(); + } catch (err) { + toast({ + title: "Relocation failed", + description: parseError(err, "Could not change the yard"), + variant: "destructive", + }); + } + }; + + const memberCount = + (composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0); + + return ( + Change yard — train {composition?.code}} + radius="lg" + centered + > + + }> + The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "} + locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "} + {composition?.totals.wagonCount ?? 0} wagon + {(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles) + are relocated so their current yard always matches the train's. Wagon moves are + recorded in the movement ledger. + +