diff --git a/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts b/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts new file mode 100644 index 000000000..f5a916bd3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The draft/finalize phase is abolished: train schedules are created SCHEDULED + * and the Finalize button is gone from the backoffice. Promote every surviving + * DRAFT schedule so it stays reachable (dispatch requires SCHEDULED and there + * is no manual promotion path anymore). Idempotent; one-way — the original + * DRAFT set is not recorded, so down() cannot restore it. + */ +export class PromoteDraftSchedulesToScheduled3100000000000 implements MigrationInterface { + name = "PromoteDraftSchedulesToScheduled3100000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE freight.train_schedules + SET status = 'SCHEDULED' + WHERE status = 'DRAFT' + AND deleted_at IS NULL`, + ); + } + + public async down(): Promise { + // One-way data promotion — nothing to restore. + } +} diff --git a/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..0e1d13175 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Consist adjustments can now happen mid-route (train standing at a stop), so + * each history row records WHERE it happened. Nullable — rows written before + * this column simply have no yard. + */ +export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface { + name = "AddYardToScheduleWagonAdjustmentLogs3110000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + ADD COLUMN IF NOT EXISTS yard_id uuid`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + DROP COLUMN IF EXISTS yard_id`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 99f053545..095928bb3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -135,11 +135,20 @@ export class BookingContractService { async generateContractForGovernment(bookingId: string): Promise { const booking = await this.requireBooking(bookingId); if (!booking.isGovernment || booking.contractGeneratedAt) return; + const templateKey = this.templateResolver.resolve(booking); await this.bookingsRepository.update(bookingId, { contractSummary: this.buildContractSummary(booking), - contractTemplateKey: this.templateResolver.resolve(booking), + contractTemplateKey: templateKey, contractGeneratedAt: new Date(), } as never); + // Render the PDF eagerly but NEVER block creation on it — Chromium can take + // seconds (or hang on assets); the document re-renders on view/download. + void this.upsertContractPdf(bookingId, booking.reference, templateKey).catch( + (err) => + this.logger.warn( + `Government contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ), + ); } async streamContract(bookingId: string) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 3a17784f3..7b1f34f36 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -464,6 +464,26 @@ export class BookingsController { res.send(buffer); } + @Get(':id/carriage-acceptance-sheet') + @ApiOperation({ + summary: + 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', + }) + async carriageAcceptanceSheet( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 7f9b3b9a0..b16febe2e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -23,6 +23,7 @@ import { DocumentReviewStatus, } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; @@ -201,10 +202,12 @@ export class BookingsRepository extends BaseRepository { vgmPerUnitTons: number; hazardousQuantity?: number; reeferQuantity?: number; + containerNumbers?: string[]; weightResult: ContainerWeightResult; }>, ): Promise { const containerRepo = this.dataSource.getRepository(BookingContainer); + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); const typeRepo = this.dataSource.getRepository(ContainerType); const saved: BookingContainer[] = []; @@ -230,7 +233,26 @@ export class BookingsRepository extends BaseRepository { isOverweight: item.weightResult.isOverweight, overweightExcessTons: item.weightResult.overweightExcessTons, }); - saved.push(await containerRepo.save(row)); + const savedRow = await containerRepo.save(row); + saved.push(savedRow); + + // Physical container numbers, one unit row each (capped to the line + // quantity; blanks skipped). Optional — units can also be entered later. + const numbers = (item.containerNumbers ?? []) + .map((n) => n.trim()) + .filter(Boolean) + .slice(0, item.quantity); + let sortOrder = 0; + for (const containerNumber of numbers) { + await unitRepo.save( + unitRepo.create({ + bookingContainerId: savedRow.id, + containerNumber, + vgmTons: item.vgmPerUnitTons, + sortOrder: sortOrder++, + }), + ); + } } return saved; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d54bd1190..f9103ddbb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -70,6 +70,23 @@ export interface PaginatedBookings { }; } +/** One wagon line on the carriage acceptance sheet (raw SQL projection). */ +interface CarriageAcceptanceWagonRow { + sequenceNo: number; + wagonType: string | null; + wagonNumber: string | null; + tareWeightTons: string | null; + equatedLength: string | null; + loadCapacityTons: string | null; + allocatedWeightTons: string | null; + trainNumber: string | null; + departureAt: Date | null; + marshalledAt: string | null; + arrivalAt: string | null; + containerNumbers: string | null; + sealNumbers: string | null; +} + const URGENT_PRIORITY_THRESHOLD = 1000; const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', @@ -208,6 +225,230 @@ export class BookingsService { }; } + /** + * Carriage acceptance sheet — one per booking, listing every wagon the booking + * occupies. Handed to the customer when EDR accepts the cargo (export) and when + * the wagons are allocated before marshalling (import), so it is only available + * once the booking has wagon allocations. + */ + async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + const wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + COALESCE(wt.code, wt.name) AS "wagonType", + w.wagon_number AS "wagonNumber", + wt.tare_weight_tons AS "tareWeightTons", + tsw.length_meters AS "equatedLength", + tsw.capacity_tons AS "loadCapacityTons", + a.allocated_weight_tons AS "allocatedWeightTons", + s.train_number AS "trainNumber", + s.scheduled_departure_date AS "departureAt", + so.label AS "marshalledAt", + sd.label AS "arrivalAt", + string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", + string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw + ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.train_schedules s + ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label + ORDER BY tsw.sequence_no`, + [bookingId], + ); + if (wagons.length === 0) { + throw new BadRequestException( + 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation', + ); + } + + const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons); + const buffer = await this.pdfRender.htmlToPdfBuffer(html, { + label: 'carriage acceptance sheet', + fallback: (prepared) => buildTabularFallbackPdf(prepared), + }); + return { + filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + + /** + * Split the booking amount across its wagons, proportional to allocated weight + * (equal shares when no weights are recorded). The last row absorbs the rounding + * remainder so the Price column always sums to the Total Amount on the sheet. + */ + private splitAmountAcrossWagons(total: number, weights: number[]): number[] { + const sum = weights.reduce((acc, w) => acc + w, 0); + const shares = weights.map((w) => + Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100, + ); + const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100; + shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100; + return shares; + } + + private buildCarriageAcceptanceSheetHtml( + booking: Booking, + wagons: CarriageAcceptanceWagonRow[], + ): string { + const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); + const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits); + const money = (v: number) => + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; + const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; + const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + const currency = booking.paymentCurrency ?? 'ETB'; + const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; + const prices = this.splitAmountAcrossWagons( + totalAmount, + wagons.map((w) => Number(w.allocatedWeightTons) || 0), + ); + const header = wagons[0]; + const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); + + const totals = wagons.reduce( + (acc, w) => ({ + tare: acc.tare + (Number(w.tareWeightTons) || 0), + capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), + load: acc.load + (Number(w.allocatedWeightTons) || 0), + length: acc.length + (Number(w.equatedLength) || 0), + }), + { tare: 0, capacity: 0, load: 0, length: 0 }, + ); + // A wagon carrying no weight and no container is running empty under this booking. + const fullWagons = wagons.filter( + (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), + ).length; + + const rows = wagons + .map( + (w, i) => ` + ${i + 1} + ${esc(w.wagonType)} + ${esc(w.wagonNumber)} + ${num(w.tareWeightTons, 2)} + ${num(w.equatedLength)} + ${num(w.loadCapacityTons)} + ${esc(arrivalStation)} + ${esc(cargoName)} + ${esc(departureStation)} + ${esc(w.containerNumbers)} + ${esc(w.sealNumbers)} + ${money(prices[i])} + `, + ) + .join(''); + + return ` + + + + Carriage Acceptance Sheet + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Carriage Acceptance Sheet

+
Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}
+
+
+ Sheet No. + CAS-${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ +
+
Marshalled at${esc(header.marshalledAt ?? departureStation)}
+
Arrival at${esc(header.arrivalAt ?? arrivalStation)}
+
Date and time${esc(sheetDate.toLocaleString('en-GB'))}
+
Train No.${esc(header.trainNumber)}
+
Customer${esc(booking.company?.name)}
+
Cargo${esc(cargoName)}
+
+ + + + + + + + + + + + + + + + + + + + ${rows} + + + + + + + + + + + +
SNType of WagonWagon No.Tare WeightEquated LengthLoad CapacityArrival StationCargo NameDeparture StationContainer No.Seal No.Price (${esc(currency)})
Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})${num(totals.tare, 2)}${num(totals.length)}${num(totals.capacity)}Gross weight (tare + load): ${num(totals.tare + totals.load)} T${money(totalAmount)}
+ +
+ The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}. + Wagon identity, container and seal numbers must be verified against the physical consist + before the sheet is signed. +
+ +
+
Signed by — EDR operations / date
+
Signed by — customer or agent / date
+
Signed by — marshalling yard / date
+
+ +`; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ /** * An intercity corridor is valid when both yards are Ethiopian and at least @@ -934,6 +1175,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, + containerNumbers: c.containerNumbers, weightResult: ruleResult.containerWeightResults[i], })), ); diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts new file mode 100644 index 000000000..195e0cab0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -0,0 +1,26 @@ +import { BookingsService } from './bookings.service'; + +// The split is a pure helper on the prototype (never touches `this`), so it can be +// exercised without constructing the service and its dependency graph. +const split = (total: number, weights: number[]): number[] => + ( + BookingsService.prototype as unknown as { + splitAmountAcrossWagons(total: number, weights: number[]): number[]; + } + ).splitAmountAcrossWagons(total, weights); + +describe('carriage acceptance sheet — price split', () => { + it('splits proportionally to allocated weight', () => { + expect(split(100, [30, 10])).toEqual([75, 25]); + }); + + it('splits equally when no weights are recorded', () => { + expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]); + }); + + it('always sums back to the booking total despite rounding', () => { + const shares = split(100, [1, 1, 1]); + expect(shares.reduce((a, b) => a + b, 0)).toBe(100); + expect(shares).toEqual([33.33, 33.33, 33.34]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 074a45323..a9aca53dd 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -74,6 +74,17 @@ export class CreateBookingContainerDto { @Min(0) @Transform(({ value }) => Number(value ?? 0)) reeferQuantity?: number; + + @ApiPropertyOptional({ + description: + 'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)', + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(64, { each: true }) + containerNumbers?: string[]; } /** diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts index 485d29452..56459870c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -1,15 +1,17 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index } from 'typeorm'; -export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const; +export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const; export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; /** * History row for a consist adjustment made from a schedule: staff coupled a - * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train — - * e.g. trimming free wagons whose tare pushed gross weight over the - * locomotives' pull limit. Plain columns (no FK relations) so the history - * survives the wagon or train being deleted later. + * wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon + * under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the + * schedule's built train. `yardId` records WHERE it happened: the origin yard + * before departure, or the mid-route stop the train was standing at. Plain + * columns (no FK relations) so the history survives the wagon or train being + * deleted later. */ @Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' }) @Index(['trainScheduleId']) @@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity { @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) adjustedByUserId!: string | null; + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId!: string | null; + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) occurredAt!: Date; } 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 06e8a5f1c..7541e4bd1 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 @@ -614,6 +614,26 @@ export class BookingBatchService implements OnModuleInit { const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + // Intercity is allocated MANUALLY: payment secures the ride, staff then + // place it on whichever same-route train suits (intercity panel). Unpin + // from the train it reserved against — that train may be the wrong one by + // the time it departs — and return it to the waiting pool as PAID. + if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) { + await this.dataSource.getRepository(Booking).update(bookingId, { + trainScheduleId: null, + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + } as never); + this.logger.log( + `[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`, + ); + void this.completeTrackingMilestones(bookingId, [ + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced"); + return; + } if (!linked) { if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; await this.allocate(booking.trainScheduleId, booking, "paid"); @@ -3143,6 +3163,19 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(scheduleId, 'intercity_accepted'); return; } + // Manual placement of an ALREADY-PAID intercity booking: payment landed + // earlier (and unpinned it back to the pool) — staff are now choosing its + // train, so link directly. No new pay window; wagon assignment stays with + // staff in the workspace. + if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: scheduleId }); + booking.trainScheduleId = scheduleId; + await this.allocate(scheduleId, booking, 'paid'); + this.notifyBoardChanged(scheduleId, 'intercity_accepted'); + return; + } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); @@ -3322,6 +3355,22 @@ export class BookingBatchService implements OnModuleInit { booking: Booking, reason: "paid" | "gov", ): Promise { + // Stamp the computed wagon need on the link. Several callers pass a booking + // loaded without cargo relations (ensurePaidBookingAllocated), and a NULL + // wagonsRequired makes every capacity/occupancy reader miscount this + // booking as 1 wagon — reload with the relations wagonsFor sizes from. + const wagonDims = await this.loadWagonDims(); + const full = + booking.bookingContainers || booking.cargoType + ? booking + : await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + const wagonsRequired = this.wagonsFor(full ?? booking, wagonDims); await this.dataSource.transaction(async (manager) => { const exists = await this.trainScheduleBookingsRepository.existsForBooking( @@ -3338,6 +3387,7 @@ export class BookingBatchService implements OnModuleInit { status: reason === "paid" ? "PAID" : booking.status, schedulingStatus: "SCHEDULED", scheduledAt: new Date(), + wagonsRequired, paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -3346,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, ); this.notifier.secured(booking, reason, scheduleId); - void this.triggerWagonAllocation(scheduleId); + // Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto + // wagon assignment is for the import/export batch flow only. + if (booking.tradeDirection !== 'DOMESTIC') { + void this.triggerWagonAllocation(scheduleId); + } void this.markWagonAllocatedMilestone(booking.id); // Customer tracking: freight payment settled (commercial pay-window path). // Government allocations don't pay upfront — theirs stay pending. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts index e031bf9f0..215b6c806 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts @@ -1,5 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsOptional, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +export class ConsistWagonSwitchDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' }) + @IsUUID() + fromWagonId!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' }) + @IsUUID() + toWagonId!: string; +} export class AdjustScheduleConsistDto { @ApiPropertyOptional({ @@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto { @IsArray() @IsUUID('all', { each: true }) removeWagonIds?: string[]; + + @ApiPropertyOptional({ + type: [ConsistWagonSwitchDto], + description: + "Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ConsistWagonSwitchDto) + switches?: ConsistWagonSwitchDto[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 4d5ae2330..5775148d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -330,8 +330,10 @@ export class IntercityService { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) .andWhere('booking.train_schedule_id IS NULL') + // PAID = customer paid but staff have not placed it on a train yet + // (intercity allocation is manual) — it stays in the pool until they do. .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + `((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID')) OR (booking.is_government = true AND booking.status = 'APPROVED'))`, ) .orderBy('booking.is_government', 'DESC') @@ -382,8 +384,12 @@ export class IntercityService { if (booking.trainScheduleId) { return 'Already assigned to a train'; } - const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; - if (booking.status !== readyStatus) { + // Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed, + // awaiting manual placement) links straight onto the chosen train. + const readyStatuses = booking.isGovernment + ? ['APPROVED'] + : ['FULLY_EXECUTED', 'PAID']; + if (!readyStatuses.includes(booking.status)) { return `Not ready to board (status ${booking.status})`; } if (!this.corridorOnRoute(booking, milestoneSeq)) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 4f0c8903a..7b0f37b66 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -195,6 +195,16 @@ export class TrainSchedulingController { ); } + @Get("schedules/:id/history") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first", + }) + getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleHistory(id); + } + @Get("bookable-schedules") // No staff guard: customers hit this while creating a booking to find OPEN // same-route schedules. Do not attach train_scheduling permissions here. 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 e4af8e6b5..d5a5161fb 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 @@ -43,6 +43,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { Contract } from '../contracts/entities/contract.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -63,6 +64,7 @@ import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-c import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -123,11 +125,13 @@ import { sumWagonsRequired, type TrainLimitConfig, maxEdgeConsistUsage, + perEdgeConsistUsage, validateContainerPlacements, validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; +import { CorridorBudget } from './corridor-capacity.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { @@ -1246,8 +1250,31 @@ export class TrainSchedulingService { }; } + /** + * Limits for a preview aimed at an EXISTING schedule must be the schedule's + * own: its locomotive set and its built-consist wagon cap. Resolving from + * the dto alone re-derived the global wagon cap (53) and rejected a + * physically-coupled 54-wagon train the assign path would accept. + */ + private async resolvePreviewLimitConfig(dto: { + targetScheduleId?: string; + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }): Promise> { + const target = dto.targetScheduleId + ? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId) + : null; + if (!target) return this.resolveTrainLimitConfig(dto); + return this.resolveTrainLimitConfig( + dto, + combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)), + target.maxWagons ?? undefined, + ); + } + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1262,7 +1289,7 @@ export class TrainSchedulingService { } async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1277,7 +1304,7 @@ export class TrainSchedulingService { } async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1511,7 +1538,9 @@ export class TrainSchedulingService { originStationId: route.originYardId, destinationStationId: route.destinationYardId, scheduledDepartureDate: departure, - status: TrainScheduleStatusEnum.Draft, + // Born SCHEDULED: there is no draft/finalize phase — a created train + // is immediately visible and bookable to customers. + status: TrainScheduleStatusEnum.Scheduled, direction, trainNumber: pairTrainNumber ?? undefined, maxWagons, @@ -1615,7 +1644,11 @@ export class TrainSchedulingService { const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined; - const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); + const limits = await this.resolveTrainLimitConfig( + previewDto, + limitLoco, + schedule.maxWagons ?? undefined, + ); // Callers that add bookings without hand-picking container slots (the // workspace "Add from pool" button, re-adding a removed booking) send no @@ -1769,20 +1802,38 @@ export class TrainSchedulingService { // hauled at the same time. Coupled-but-unplanned wagons ride every edge, // so their tare rides on top of the binding edge. const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons); - const edgeUsage = maxEdgeConsistUsage( - wagonPlan, - await this.stopYardsForSchedule(schedule), - ); - const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons); - if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { + const scheduleStops = await this.stopYardsForSchedule(schedule); + const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops); + const stopLabels = await this.yardLabelMap(scheduleStops); + // Each edge is its own consist — name EVERY leg that breaks the limit, + // not just the heaviest figure, so staff see where along A→…→E it fails. + const legName = (edge: number) => + scheduleStops.length > 2 + ? `${stopLabels.get(scheduleStops[edge]) ?? scheduleStops[edge]} → ${ + stopLabels.get(scheduleStops[edge + 1]) ?? scheduleStops[edge + 1] + }` + : 'the route'; + const overweightLegs = perEdge + .map((e) => ({ + edge: e.edge, + grossWeightTons: roundTons(e.grossWeightTons + emptyConsistTareTons), + })) + .filter((e) => e.grossWeightTons > weightCapWithOverage); + if (!dto.forceAssign && overweightLegs.length) { throw new BadRequestException( - `Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`, + `Train set locomotives cannot pull the gross weight on ${overweightLegs + .map((e) => `leg ${legName(e.edge)} (${e.grossWeightTons}T)`) + .join(', ')} — limit ${roundTons(weightCapWithOverage)}T incl. tolerance`, ); } - const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); - if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) { + const overlongLegs = perEdge + .map((e) => ({ edge: e.edge, lengthMeters: roundTons(e.lengthMeters) })) + .filter((e) => e.lengthMeters > lengthCapWithOverage); + if (!dto.forceAssign && overlongLegs.length) { throw new BadRequestException( - `Train set locomotives cannot support ${maxEdgeLengthMeters}m`, + `Train set locomotives cannot support the train length on ${overlongLegs + .map((e) => `leg ${legName(e.edge)} (${e.lengthMeters}m)`) + .join(', ')} — limit ${roundTons(lengthCapWithOverage)}m incl. tolerance`, ); } @@ -2296,6 +2347,12 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } + // Schedules are born SCHEDULED now — finalize is a no-op for them so the + // allocate wizard and the window auto-finalize keep working. The DRAFT + // branch below only still runs for legacy rows. + if (schedule.status === TrainScheduleStatusEnum.Scheduled) { + return this.getTrainScheduleById(scheduleId); + } if (schedule.status !== TrainScheduleStatusEnum.Draft) { throw new BadRequestException('Only DRAFT schedules can be finalized'); } @@ -4175,12 +4232,16 @@ export class TrainSchedulingService { wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]), ).values(), ]; + const stopLabelMap = + stops.length > 2 ? await this.yardLabelMap(stops) : new Map(); + const stopLabels = stops.map((yardId) => stopLabelMap.get(yardId) ?? yardId); pushLimit( validateMixedTrainLimitsPerEdge( wagonPlan, plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], trainLimits, stops, + stopLabels, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -4210,11 +4271,17 @@ export class TrainSchedulingService { ); // Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge // above — the whole-route totals here are informational (summary) only. The - // locomotive checks below also compare the heaviest single edge: a train is - // never heavier than its heaviest leg, so disjoint legs must not be summed. - const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops); - const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons); - const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); + // locomotive checks below also compare per edge: a train is never heavier + // than its heaviest leg, so disjoint legs must not be summed. + const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops); + const maxEdgeGrossTons = roundTons( + Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)), + ); + const maxEdgeLengthMeters = roundTons( + Math.max(0, ...perEdgeUsage.map((e) => e.lengthMeters)), + ); + const legName = (edge: number) => + stops.length > 2 ? `${stopLabels[edge]} → ${stopLabels[edge + 1]}` : 'the route'; let assignedLocomotives: Locomotive[] = []; if (targetScheduleId) { @@ -4234,16 +4301,29 @@ export class TrainSchedulingService { `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); } - if ( - setLimits && - (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - maxEdgeGrossTons || - setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < - maxEdgeLengthMeters) - ) { - pushLimit([ - 'Assigned locomotives cannot support the total train weight and length', - ]); + if (setLimits) { + // Name every leg the set cannot pull — staff must see WHERE along the + // corridor the train is too heavy/long, not just that it is somewhere. + const weightCap = + setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0); + const lengthCap = + setLimits.maxTrainLengthMeters + + (Number(setLimits.overageToleranceMeters) || 0); + const legIssues = perEdgeUsage.flatMap((e) => { + const issues: string[] = []; + if (roundTons(e.grossWeightTons) > weightCap) { + issues.push( + `Assigned locomotives cannot pull ${roundTons(e.grossWeightTons)}T gross on leg ${legName(e.edge)} (limit ${roundTons(weightCap)}T incl. tolerance)`, + ); + } + if (roundTons(e.lengthMeters) > lengthCap) { + issues.push( + `Assigned locomotives cannot support ${roundTons(e.lengthMeters)}m train length on leg ${legName(e.edge)} (limit ${roundTons(lengthCap)}m incl. tolerance)`, + ); + } + return issues; + }); + if (legIssues.length) pushLimit(legIssues); } } else { const inServiceLocomotives = await this.locomotivesRepository.findAll({ @@ -4320,6 +4400,7 @@ export class TrainSchedulingService { maxWagonsPerTrain?: number; }, locomotive?: LocomotiveLimits | null, + builtWagonCount?: number, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -4362,10 +4443,18 @@ export class TrainSchedulingService { return { maxWeightTons: derived.maxWeightTons, maxLengthMeters: derived.maxLengthMeters, + // A built train's own consist is the real capacity — the length-derived + // slot count is only an estimate for trains with no wagons coupled yet. + // Without this override, validation re-derives a DIFFERENT wagon cap + // than the one the train was actually built with (e.g. a 54-wagon + // consist rejected against a re-derived 53-slot cap that never matched + // what staff physically coupled). maxWagonsPerTrain: dto?.maxWagonsPerTrain != null ? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots)) - : derived.maxWagonSlots, + : builtWagonCount && builtWagonCount > 0 + ? builtWagonCount + : derived.maxWagonSlots, max20ftContainerWeightTons: this.positiveNumber( undefined, Number(row?.max20ftContainerWeightTons) || @@ -4592,16 +4681,29 @@ export class TrainSchedulingService { * schedule. Used to guard consist trims — the Wagon entity itself carries no * schedule-occupancy state anymore. */ - private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> { + /** + * Physical wagons pinned to any live run's slot. `excludeTrainId` drops the + * pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g. + * the paired return leg): a consist edit is an edit of the TRAIN, sibling + * runs ride whatever it is composed of and their pins are re-pointed by the + * edit itself. Only pins held by live schedules of OTHER trains block it. + */ + private async wagonIdsPinnedToLiveSchedules( + manager?: EntityManager, + excludeTrainId?: string, + ): Promise> { const runner = manager ?? this.dataSource; const rows: { physical_wagon_id: string }[] = await runner.query( `SELECT DISTINCT tsw.physical_wagon_id FROM freight.train_set_wagons tsw JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + JOIN freight.train_sets tset ON tset.id = tsw.train_set_id WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') AND ts.deleted_at IS NULL AND tsw.deleted_at IS NULL - AND tsw.physical_wagon_id IS NOT NULL`, + AND tsw.physical_wagon_id IS NOT NULL + AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`, + [excludeTrainId ?? null], ); return new Set(rows.map((row) => row.physical_wagon_id)); } @@ -5646,6 +5748,70 @@ export class TrainSchedulingService { }); } + /** + * Where consist work can physically happen right now. Before departure it is + * the built train's own yard. After dispatch it is the route stop the train + * is STANDING AT per its latest checkpoint — null while rolling between + * stops or when the last checkpoint is off-route, and consist work is closed + * there. Arrived/cancelled schedules always return null (history only). + */ + private async currentConsistYardId( + schedule: TrainSchedule, + ): Promise { + if ( + schedule.status === TrainScheduleStatusEnum.Draft || + schedule.status === TrainScheduleStatusEnum.Scheduled + ) { + return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId; + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null; + const rows: Array<{ yard_id: string | null }> = await this.dataSource.query( + `SELECT yard_id + FROM freight.train_checkpoint_events + WHERE train_schedule_id = $1 + ORDER BY occurred_at DESC, created_at DESC + LIMIT 1`, + [schedule.id], + ); + const yardId = rows[0]?.yard_id ?? null; + if (!yardId) return null; + return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId) + ? yardId + : null; + } + + /** + * Physical wagons whose cargo still RIDES beyond the given stop: any + * allocation whose booking alights strictly after it. Cargo whose + * destination is this stop (or an earlier one) has been offloaded here and + * no longer blocks its wagon — that wagon may be trimmed or switched away. + * Before departure the stop is the origin, so every allocated wagon counts + * as aboard — one rule covers both phases. Unknown destinations and + * off-route stops stay conservative (aboard). + */ + // ponytail: trusts booking.destinationYardId, not a physical unload + // confirmation — if staff trim before actually unloading, the cargo strands. + // Wire the journey unload flag in if that ever bites. + private wagonIdsWithCargoBeyond( + schedule: TrainSchedule, + atYardId: string | null, + ): Set { + const stops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const atIdx = atYardId ? stops.indexOf(atYardId) : -1; + const aboard = new Set(); + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue; + const ridesOn = (slot.allocations ?? []).some((allocation) => { + const destination = allocation.booking?.destinationYardId; + const destIdx = destination ? stops.indexOf(destination) : -1; + if (destIdx < 0 || atIdx < 0) return true; + return destIdx > atIdx; + }); + if (ridesOn) aboard.add(slot.physicalWagonId); + } + return aboard; + } + /** * Consist snapshot for the adjust-consist UI: the built train's wagons with * loaded/removable flags, gross weight (cargo + FULL consist tare) and length @@ -5662,32 +5828,41 @@ export class TrainSchedulingService { ); } + // Where the train stands right now — the origin yard before departure, the + // checkpoint stop after it. Null = rolling; the consist is view-only then. + const currentYardId = await this.currentConsistYardId(schedule); + const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, relations: { wagonType: true }, order: { sequenceNumber: 'ASC' }, }); - const addableWagons = await this.dataSource.getRepository(Wagon).find({ - where: { - trainId: IsNull(), - status: WagonStatus.Available, - currentYardId: builtTrain.currentYardId ?? undefined, - }, - relations: { wagonType: true }, - order: { wagonNumber: 'ASC' }, - }); + const addableWagons = currentYardId + ? await this.dataSource.getRepository(Wagon).find({ + where: { + trainId: IsNull(), + status: WagonStatus.Available, + currentYardId, + }, + relations: { wagonType: true }, + order: { wagonNumber: 'ASC' }, + }) + : []; const adjustments = await this.dataSource .getRepository(ScheduleWagonAdjustmentLog) .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); - // Slots with cargo aboard — their physical wagons are "loaded" and can - // never be trimmed. - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), + // Slots whose cargo still rides beyond the current stop — those wagons + // cannot be trimmed, only switched. Cargo offloaded at this stop (or + // earlier) has released its wagon. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); + // Only OTHER trains' pins block edits here — this train's own schedules + // (incl. the paired return run) have their pins managed by the edit itself + // (removal clears, switch re-points). + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules( + undefined, + builtTrain.id, ); - const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(); const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); @@ -5752,12 +5927,23 @@ export class TrainSchedulingService { bookingWindowStatus: schedule.bookingWindowStatus ?? null, } : null, - wagons: wagons.map((wagon) => ({ - ...mapWagon(wagon), - loaded: loadedWagonIds.has(wagon.id), - // Free = not pinned to any live run's slot; only free wagons can be trimmed. - removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id), - })), + wagons: wagons.map((wagon) => { + const loaded = loadedWagonIds.has(wagon.id); + const pinnedElsewhere = pinnedToLiveIds.has(wagon.id); + return { + ...mapWagon(wagon), + loaded, + removable: !pinnedElsewhere && !loaded, + // A loaded wagon can't leave, but its SLOT can change wagon: switch + // moves the cargo allocations onto a same-type replacement. + switchable: !pinnedElsewhere, + blockReason: pinnedElsewhere + ? 'Pinned by another live schedule' + : loaded + ? 'Cargo aboard rides beyond this stop — switch it instead' + : null, + }; + }), addableWagons: addableWagons.map(mapWagon), adjustments: adjustments.map((log) => ({ id: log.id, @@ -5765,9 +5951,26 @@ export class TrainSchedulingService { wagonId: log.wagonId, wagonNumber: log.wagonNumber, adjustedByUserId: log.adjustedByUserId, + yardId: log.yardId ?? null, occurredAt: log.occurredAt, })), - editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status), + // Editable before departure, and after it whenever the train is standing + // at a route stop (mid-route wagon work at station B); frozen while + // rolling and once arrived/cancelled. + editable: + ['DRAFT', 'SCHEDULED'].includes(schedule.status) || + (schedule.status === TrainScheduleStatusEnum.Dispatched && + currentYardId != null), + currentStop: currentYardId + ? { + yardId: currentYardId, + label: + this.mapScheduleStops(schedule).find( + (s) => s.yardId === currentYardId, + )?.label ?? currentYardId, + isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched, + } + : null, }; } @@ -5786,19 +5989,38 @@ export class TrainSchedulingService { ) { const addWagonIds = [...new Set(dto.addWagonIds ?? [])]; const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; - if (!addWagonIds.length && !removeWagonIds.length) { - throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); + const switches = dto.switches ?? []; + if (!addWagonIds.length && !removeWagonIds.length && !switches.length) { + throw new BadRequestException( + 'Nothing to adjust — pass wagons to add, remove and/or switch', + ); } - const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id)); - if (overlap.length) { - throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); + const switchFromIds = switches.map((s) => s.fromWagonId); + const switchToIds = switches.map((s) => s.toWagonId); + const touched = new Map(); + for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) { + touched.set(id, (touched.get(id) ?? 0) + 1); + } + if ([...touched.values()].some((count) => count > 1)) { + throw new BadRequestException( + 'Each wagon may appear once per adjustment — not in two lists or two switches', + ); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + // Consist edits are open before departure, and after it whenever the train + // is STANDING AT a route stop (checkpointed): that is exactly the "switch + // wagons at station B" window. Rolling between stops → frozen. + const currentYardId = await this.currentConsistYardId(schedule); + const editableStatus = + ['DRAFT', 'SCHEDULED'].includes(schedule.status) || + schedule.status === TrainScheduleStatusEnum.Dispatched; + if (!editableStatus || !currentYardId) { throw new BadRequestException( - 'The consist is frozen once the train is dispatched — adjust before departure', + schedule.status === TrainScheduleStatusEnum.Dispatched + ? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)' + : 'The consist can no longer be adjusted — the run is over', ); } const builtTrainRef = schedule.trainSet?.train; @@ -5807,11 +6029,9 @@ export class TrainSchedulingService { 'This schedule was not created from a built train — its consist cannot be adjusted here', ); } - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), - ); + // Wagons whose cargo still rides beyond the current stop: never removable, + // but switchable — the replacement inherits the slot, cargo included. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), @@ -5834,25 +6054,41 @@ export class TrainSchedulingService { }); const consistById = new Map(consist.map((w) => [w.id, w])); - // --- validate removals: must be coupled and free (no cargo, no pin) --- - const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager); + // --- validate removals: coupled, cargo offloaded, no foreign pin --- + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules( + manager, + train.id, + ); + // Every live train set of THIS built train (this run + siblings, e.g. + // the paired return leg) — their pins follow the consist edit. + const ownSetIds = ( + await manager.getRepository(TrainSet).find({ + where: { trainId: train.id }, + select: { id: true }, + }) + ).map((set) => set.id); const removed: Wagon[] = []; for (const wagonId of removeWagonIds) { const wagon = consistById.get(wagonId); if (!wagon) { throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); } - if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) { + if (loadedWagonIds.has(wagon.id)) { throw new ConflictException( - `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, + `Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`, + ); + } + if (pinnedToLiveIds.has(wagon.id)) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`, ); } removed.push(wagon); } - // --- validate additions: AVAILABLE, loose, standing in the train's yard --- - const added: Wagon[] = []; - for (const wagonId of addWagonIds) { + // Shared gate for every incoming wagon (couple or switch replacement): + // AVAILABLE, loose, and standing where the train stands right now. + const lockIncomingWagon = async (wagonId: string): Promise => { // No `relations` on this query: Postgres refuses FOR UPDATE through the // nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be // applied to the nullable side of an outer join"). Lock the row alone, @@ -5870,21 +6106,57 @@ export class TrainSchedulingService { `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`, ); } - if (wagon.currentYardId !== train.currentYardId) { + if (wagon.currentYardId !== currentYardId) { throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`, + `Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`, ); } wagon.wagonType = (await manager .getRepository(WagonType) .findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined; - added.push(wagon); + return wagon; + }; + + const added: Wagon[] = []; + for (const wagonId of addWagonIds) { + added.push(await lockIncomingWagon(wagonId)); } - // --- headroom check (only additions can push the train over a cap) --- + // --- validate switches: outgoing coupled + not foreign-pinned; the + // replacement passes the incoming gate AND matches the wagon type, so + // the slot's cargo (weight, TEU geometry) rides it unchanged --- + const switchPairs: Array<{ from: Wagon; to: Wagon }> = []; + for (const { fromWagonId, toWagonId } of switches) { + const from = consistById.get(fromWagonId); + if (!from) { + throw new NotFoundException( + `Wagon ${fromWagonId} is not coupled to train ${train.code}`, + ); + } + if (pinnedToLiveIds.has(from.id)) { + throw new ConflictException( + `Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`, + ); + } + const to = await lockIncomingWagon(toWagonId); + if (to.wagonTypeId !== from.wagonTypeId) { + throw new BadRequestException( + `Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`, + ); + } + switchPairs.push({ from, to }); + } + + // --- headroom check (only additions can push the train over a cap; + // switches are same-type and cancel out, but are computed honestly) --- const removedIds = new Set(removed.map((w) => w.id)); - const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added]; + const switchedFromIds = new Set(switchPairs.map((p) => p.from.id)); + const finalConsist = [ + ...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)), + ...added, + ...switchPairs.map((p) => p.to), + ]; const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0); const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0); const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0)); @@ -5902,21 +6174,72 @@ export class TrainSchedulingService { ); } - // --- apply: detach trims, couple additions, compact the sequence --- + // --- apply: detach trims, couple additions, swap switches, compact --- + // A wagon leaving the train stands wherever the train stands — stamping + // the stop yard is what makes it findable (and re-couplable) at B. + const detachPatch = { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + trainSetWagonId: null, + currentTrainScheduleId: null, + currentYardId, + }; for (const wagon of removed) { - await manager.getRepository(Wagon).update(wagon.id, { - trainId: null, - sequenceNumber: null, - status: WagonStatus.Available, - }); + await manager.getRepository(Wagon).update(wagon.id, detachPatch); } - const remaining = consist.filter((w) => !removedIds.has(w.id)); - for (let i = 0; i < remaining.length; i++) { - if (remaining[i].sequenceNumber !== i + 1) { - await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); + if (removed.length && ownSetIds.length) { + // This train's own pins (all its runs) on trimmed wagons are stale — + // clear them so the freed wagon isn't still claimed by slots it left. + await manager + .getRepository(TrainSetWagon) + .update( + { trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) }, + { physicalWagonId: null }, + ); + } + + // Switches: the replacement takes the outgoing wagon's position AND its + // slot pins, so every cargo allocation now rides the new wagon. The + // outgoing wagon is left standing at the stop. + for (const { from, to } of switchPairs) { + const slots = ownSetIds.length + ? await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId: In(ownSetIds), physicalWagonId: from.id }, + }) + : []; + for (const slot of slots) { + await manager + .getRepository(TrainSetWagon) + .update(slot.id, { physicalWagonId: to.id }); + } + const ownSlot = + slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0]; + await manager.getRepository(Wagon).update(to.id, { + trainId: train.id, + sequenceNumber: from.sequenceNumber, + status: WagonStatus.Assigned, + trainSetWagonId: ownSlot?.id ?? null, + currentTrainScheduleId: from.currentTrainScheduleId ?? null, + }); + // Mirror on the in-memory row — the compaction below sorts by it. + to.sequenceNumber = from.sequenceNumber; + await manager.getRepository(Wagon).update(from.id, detachPatch); + } + + const remaining = consist.filter( + (w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id), + ); + const switchedIn = switchPairs.map((p) => p.to); + const compacted = [...remaining, ...switchedIn].sort( + (a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0), + ); + for (let i = 0; i < compacted.length; i++) { + if (compacted[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 }); } } - let sequence = remaining.length; + let sequence = compacted.length; for (const wagon of added) { sequence += 1; await manager.getRepository(Wagon).update(wagon.id, { @@ -5935,16 +6258,31 @@ export class TrainSchedulingService { const now = new Date(); await logRepo.save( [ - ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), - ...added.map((wagon) => ({ action: 'ADD' as const, wagon })), - ].map(({ action, wagon }) => + ...removed.map((wagon) => ({ + action: 'REMOVE' as const, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + })), + ...added.map((wagon) => ({ + action: 'ADD' as const, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + })), + ...switchPairs.map(({ from, to }) => ({ + action: 'SWITCH' as const, + wagonId: to.id, + // varchar(50) — two long wagon numbers could overflow the column. + wagonNumber: `${from.wagonNumber} → ${to.wagonNumber}`.slice(0, 50), + })), + ].map((entry) => logRepo.create({ trainScheduleId: scheduleId, trainId: train.id, - action, - wagonId: wagon.id, - wagonNumber: wagon.wagonNumber, + action: entry.action, + wagonId: entry.wagonId, + wagonNumber: entry.wagonNumber, adjustedByUserId: userId ?? null, + yardId: currentYardId, occurredAt: now, }), ), @@ -5984,6 +6322,72 @@ export class TrainSchedulingService { return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } + /** + * Unified change history for the schedule detail "History" tab: wagon + * consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened + * at) merged with booking composition removals, newest first. Actor resolves + * through iam.users; rows survive wagon/train deletion (log tables carry + * plain columns, no FKs). + */ + async getScheduleHistory(scheduleId: string) { + type HistoryRow = { + id: string; + kind: 'WAGON' | 'BOOKING'; + action: string; + subject: string | null; + yardLabel: string | null; + actor: string | null; + note: string | null; + occurredAt: Date; + }; + const wagonRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT l.id, + l.action, + l.wagon_number AS "subject", + COALESCE(y.label, y.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + l.occurred_at AS "occurredAt" + FROM freight.schedule_wagon_adjustment_logs l + LEFT JOIN freight.yards y ON y.id = l.yard_id + LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id + WHERE l.train_schedule_id = $1 + AND l.deleted_at IS NULL + ORDER BY l.occurred_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'WAGON' as const, + note: null, + })); + const bookingRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT r.id, + r.booking_reference AS "subject", + r.notes AS "note", + COALESCE(u.username, u.email) AS "actor", + r.removed_at AS "occurredAt" + FROM freight.train_composition_removal_logs r + LEFT JOIN iam.users u ON u.id = r.removed_by_user_id + WHERE r.schedule_id = $1 + AND r.deleted_at IS NULL + ORDER BY r.removed_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_REMOVED', + yardLabel: null, + })); + return [...wagonRows, ...bookingRows].sort( + (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), + ); + } + /** * Re-derive a built train's lifecycle status from its schedules after one of * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → @@ -6274,7 +6678,15 @@ export class TrainSchedulingService { route: { milestones: true }, originStation: true, destinationStation: true, - scheduleBookings: { booking: true }, + // Cargo relations feed effectiveWagonsRequired for legacy links whose + // stored wagonsRequired is NULL — without them such a booking counts + // as 1 wagon and per-leg occupancy under-reports. + scheduleBookings: { + booking: { + bookingContainers: { containerType: true }, + cargoType: { wagonTypes: true }, + }, + }, }, order: { scheduledDepartureDate: 'ASC' }, }); @@ -6332,12 +6744,54 @@ export class TrainSchedulingService { }); } + /** + * Wagon slots still free for a leg of the schedule's corridor, per edge: + * capacity minus every linked booking ON ITS OWN LEG — wagon sharing means a + * booking alighting at a mid-stop frees its slots for the edges past it, so a + * train full Mojo→Dire can still sell Dire→DCT. Works for any corridor length + * (a→b→…→h). No leg given → the most open edge (can anything board at all?). + */ + private remainingWagonsForLeg( + schedule: TrainSchedule, + originYardId?: string, + destinationYardId?: string, + ): number { + const stops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const budget = new CorridorBudget(stops, { + wagons: Number(schedule.maxWagons ?? 0), + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }); + for (const sb of schedule.scheduleBookings ?? []) { + if (!sb.booking) continue; + budget.subtract( + { + wagons: this.effectiveWagonsRequired(sb.booking), + weightTons: 0, + lengthMeters: 0, + }, + budget.legForYards(sb.booking.originYardId, sb.booking.destinationYardId), + ); + } + const leg = + originYardId && destinationYardId + ? budget.legOf(originYardId, destinationYardId) + : null; + const remaining = leg ? budget.remainingFor(leg) : budget.maxRemaining(); + return Math.max(0, remaining.wagons); + } + async getBookableSchedules(originYardId?: string, destinationYardId?: string) { const schedules = await this.getBookableScheduleEntities( originYardId, destinationYardId, ); - return schedules.map((s) => this.mapScheduleListItem(s)); + return schedules.map((s) => ({ + ...this.mapScheduleListItem(s), + // Leg-aware: the list item's own remainingWagons is consist-based + // (maxWagons − coupled wagons) and reads 0 on any fully-consisted train. + remainingWagons: this.remainingWagonsForLeg(s, originYardId, destinationYardId), + })); } /** @@ -6383,8 +6837,16 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; + // Leg-aware: a train full on Mojo→Dire still sells Dire→DCT — gate on the + // REQUESTED leg's free slots, not on how many wagons are coupled to the + // consist (a fully-consisted train read 0 remaining and hid its days). const withCapacity = schedules.filter( - (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0, + (s) => + this.remainingWagonsForLeg( + s, + input.originYardId, + input.destinationYardId, + ) > 0, ); const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input); @@ -6815,11 +7277,21 @@ export class TrainSchedulingService { (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), ); + // Booking has no ORM relation to Contract (FK only) — fetched separately + // by id so the "on this train" cards can show the contract reference. + const contractIds = [ + ...new Set( + (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking?.contractId) + .filter((id): id is string => Boolean(id)), + ), + ]; + // All independent lookups fired at once — they used to run one after // another, stacking round-trips onto every detail request. // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the // number the locomotive actually hauls against its pull limit. - const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] = + const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] = await Promise.all([ this.loadWagonTareDims(), requiresLoadingConfirmation @@ -6849,7 +7321,13 @@ export class TrainSchedulingService { order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' }, }) : [], + contractIds.length + ? this.dataSource + .getRepository(Contract) + .find({ where: { id: In(contractIds) }, select: { id: true, reference: true } }) + : [], ]); + const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference])); const loadingConfirmed = requiresLoadingConfirmation ? Boolean(importOp?.loadedOnTrainAt) : true; @@ -6906,6 +7384,9 @@ export class TrainSchedulingService { ? roundTons(Number(wagon.wagonType.tareWeightTons)) : null, status: 'EMPTY', + // Coupled wagons ride the whole corridor — they count on every leg. + boardYardId: null, + alightYardId: null, physicalWagonId: wagon.id, physicalWagonNumber: wagon.wagonNumber ?? null, wagonType: wagon.wagonType @@ -7098,6 +7579,10 @@ export class TrainSchedulingService { ? roundTons(Number(wagon.wagonType.tareWeightTons)) : null, status: wagon.status, + // Corridor span this slot rides (null = schedule endpoint) — + // lets the UI compute per-leg utilization from real slots. + boardYardId: wagon.boardYardId ?? null, + alightYardId: wagon.alightYardId ?? null, physicalWagonId: frozenSlot ? frozenSlot.physicalWagonId : wagon.physicalWagonId ?? null, @@ -7184,10 +7669,11 @@ export class TrainSchedulingService { sb.booking?.destinationYard?.label ?? sb.booking?.destinationYard?.code ?? null, - wagonsRequired: - sb.booking?.wagonsRequired != null - ? Number(sb.booking.wagonsRequired) - : null, + wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null, + contractReference: + (sb.booking?.contractId + ? contractReferenceById.get(sb.booking.contractId) + : null) ?? null, loadedAt: sb.booking?.loadedAt?.toISOString() ?? null, arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null, // Loaded/unloaded is tracked on the schedule↔booking link, not the @@ -7212,6 +7698,15 @@ export class TrainSchedulingService { ) : null; })(), + // Length ceiling per leg, same shape as maxGrossWeightTons: the set's + // most restrictive locomotive length plus its overage tolerance. + maxLengthMeters: (() => { + const setLimits = trainSetLocomotiveLimits(schedule.trainSet); + const cap = + Number(setLimits?.maxTrainLengthMeters) + + (Number(setLimits?.overageToleranceMeters) || 0); + return setLimits && Number.isFinite(cap) ? roundTons(cap) : null; + })(), // True when the wagon plan above is served from the frozen snapshot (schedule // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge // it "historical" and skip re-pin affordances. @@ -7220,6 +7715,38 @@ export class TrainSchedulingService { }; } + /** + * A booking's wagon footprint with a computed fallback: rows linked by paths + * that never stamped `wagonsRequired` (legacy allocate) read NULL, and every + * occupancy consumer then counted them as 1 wagon — a 23-wagon booking showed + * a near-empty leg. Falls back to the TEU/weight-derived count when the cargo + * relations are loaded; a bare booking still degrades to 1. + */ + private effectiveWagonsRequired(booking: Booking): number { + const stored = Number(booking.wagonsRequired); + if (stored > 0) return Math.ceil(stored); + const bulkCapacities = (booking.cargoType?.wagonTypes ?? []) + .map((wt) => Number(wt.capacityTons)) + .filter((c) => c > 0); + const bulkCapacity = + booking.freightType === 'BULK' && bulkCapacities.length + ? Math.max(...bulkCapacities) + : undefined; + return wagonsRequiredForBooking(booking, bulkCapacity); + } + + /** + * yardId → display label for error messages that name corridor legs. One + * query; unknown ids fall back to the raw id so a message never goes blank. + */ + private async yardLabelMap(yardIds: string[]): Promise> { + if (!yardIds.length) return new Map(); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(yardIds) } }); + return new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + } + /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ private mapScheduleStops( schedule: TrainSchedule, @@ -7310,6 +7837,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); const validation = await this.validateBookingsForScheduling( @@ -7439,6 +7967,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); const validation = await this.validateBookingsForScheduling( previewDto, @@ -7573,6 +8102,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); let validation: Awaited>; @@ -8161,6 +8691,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); let validation: Awaited>; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index a59e87950..619ebbde6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -549,9 +549,12 @@ export function validateMixedTrainLimitsPerEdge( wagonTypes: Array>, limits: TrainLimitConfig | undefined, stops: string[], + /** Display names parallel to `stops` — violations then name the leg they hit. */ + stopLabels?: string[], ): string[] { if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); const spans = slotSpans(wagonPlan, stops); + const label = (i: number) => stopLabels?.[i] ?? stops[i]; const violations = new Set(); for (let edge = 0; edge < stops.length - 1; edge += 1) { const active = wagonPlan.filter( @@ -559,7 +562,7 @@ export function validateMixedTrainLimitsPerEdge( ); if (!active.length) continue; for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { - violations.add(violation); + violations.add(`Leg ${label(edge)} → ${label(edge + 1)}: ${violation}`); } } return [...violations]; @@ -603,7 +606,37 @@ export function maxEdgeConsistUsage( wagonPlan: EdgeUsageSlot[], stops: string[], ): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } { - const totals = (slots: EdgeUsageSlot[]) => ({ + return perEdgeConsistUsage(wagonPlan, stops).reduce( + (max, e) => ({ + grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons), + lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), + loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount), + }), + { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 }, + ); +} + +/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */ +export type EdgeConsistUsage = { + edge: number; + grossWeightTons: number; + lengthMeters: number; + loadedWagonCount: number; + wagonCount: number; +}; + +/** + * Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own + * consist totals, so callers can name WHICH leg breaks a limit instead of + * only reporting the heaviest figure. Two stops or fewer collapse to a + * single whole-route edge. + */ +export function perEdgeConsistUsage( + wagonPlan: EdgeUsageSlot[], + stops: string[], +): EdgeConsistUsage[] { + const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({ + edge, grossWeightTons: slots.reduce( (sum, w) => sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), @@ -611,19 +644,16 @@ export function maxEdgeConsistUsage( ), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length, + wagonCount: slots.length, }); - if (stops.length <= 2) return totals(wagonPlan); + if (stops.length <= 2) return [totals(0, wagonPlan)]; const spans = slotSpans(wagonPlan, stops); - const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 }; - for (let edge = 0; edge < stops.length - 1; edge += 1) { - const active = totals( + return Array.from({ length: stops.length - 1 }, (_, edge) => + totals( + edge, wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to), - ); - usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons); - usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters); - usage.loadedWagonCount = Math.max(usage.loadedWagonCount, active.loadedWagonCount); - } - return usage; + ), + ); } export function validate20ftContainerRules( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx index 14dd60912..8cdbe66bf 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx @@ -9,16 +9,18 @@ import { Modal, Progress, ScrollArea, + Select, Stack, Text, + Tooltip, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isAxiosError } from "axios"; -import { AlertTriangle, History, Minus, Plus } from "lucide-react"; +import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { api } from "@/services/api"; -import type { ConsistWagonRef } from "@/services/trainBuilder.service"; +import type { ConsistWagonRef, ScheduleConsist } from "@/services/trainBuilder.service"; import { useToast } from "@/hooks/use-toast"; const parseError = (error: unknown, fallback: string) => { @@ -34,11 +36,16 @@ const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0; const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0; const round2 = (v: number) => Math.round(v * 100) / 100; +type ConsistWagon = ScheduleConsist["wagons"][number]; + /** * Adjust the built train's consist from a schedule: trim free wagons (their - * tare no longer rides — the fix when gross weight beats the pull limit) or - * couple extra yard wagons while weight/length headroom remains. Changes are - * permanent on the train and logged on the schedule. + * tare no longer rides — the fix when gross weight beats the pull limit), + * couple extra yard wagons while weight/length headroom remains, or SWITCH a + * wagon for a same-type replacement — the replacement inherits the slot, cargo + * included, which is the only way a loaded wagon leaves the train. Works + * before departure and mid-route while the train stands at a checkpointed + * stop. Changes are permanent on the train and logged on the schedule. */ export default function AdjustConsistModal({ scheduleId, @@ -48,6 +55,9 @@ export default function AdjustConsistModal({ const { toast } = useToast(); const [removeIds, setRemoveIds] = useState([]); const [addIds, setAddIds] = useState([]); + // fromWagonId → toWagonId. A switch is same-type, so it never moves the + // weight/length/slot projections — it only changes which steel rides. + const [switchMap, setSwitchMap] = useState>({}); const consistQuery = useQuery( api.trainScheduling.scheduleConsist.queryOptions({ @@ -62,13 +72,20 @@ export default function AdjustConsistModal({ if (opened) { setRemoveIds([]); setAddIds([]); + setSwitchMap({}); } }, [opened]); + const switchCount = Object.keys(switchMap).length; + const usedReplacementIds = useMemo( + () => new Set(Object.values(switchMap)), + [switchMap], + ); + // Live projection: gross = cargo + tare of (consist − trims + adds), plus // the schedule's wagon-slot picture — the consist IS the booking capacity // (weight/length only bind while assembling the consist), so trims/adds - // move the FULL line in real time. + // move the FULL line in real time. Switches are same-type and cancel out. const projection = useMemo(() => { if (!data) return null; const removed = new Set(removeIds); @@ -117,25 +134,58 @@ export default function AdjustConsistModal({ }; }, [data, removeIds, addIds]); - const hasChanges = removeIds.length > 0 || addIds.length > 0; + const hasChanges = removeIds.length > 0 || addIds.length > 0 || switchCount > 0; const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); + // Same-type replacements standing at the current stop, minus wagons already + // spoken for by another switch or a couple selection. + const switchOptionsFor = (wagon: ConsistWagon) => + (data?.addableWagons ?? []) + .filter( + (candidate) => + candidate.wagonType?.id === wagon.wagonType?.id && + !addIds.includes(candidate.id) && + (!usedReplacementIds.has(candidate.id) || + switchMap[wagon.id] === candidate.id), + ) + .map((candidate) => ({ value: candidate.id, label: candidate.wagonNumber })); + + const setSwitch = (fromId: string, toId: string | null) => + setSwitchMap((prev) => { + const next = { ...prev }; + if (toId) next[fromId] = toId; + else delete next[fromId]; + return next; + }); + const handleSubmit = async () => { - if (!removeIds.length && !addIds.length) return; + if (!hasChanges) return; try { const result = await adjust.mutateAsync({ scheduleId, payload: { ...(addIds.length ? { addWagonIds: addIds } : {}), ...(removeIds.length ? { removeWagonIds: removeIds } : {}), + ...(switchCount + ? { + switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({ + fromWagonId, + toWagonId, + })), + } + : {}), }, }); toast({ - title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${ - removeIds.length && addIds.length ? ", " : "" - }${addIds.length ? `${addIds.length} added` : ""}`, + title: `Consist updated — ${[ + removeIds.length ? `${removeIds.length} trimmed` : "", + addIds.length ? `${addIds.length} added` : "", + switchCount ? `${switchCount} switched` : "", + ] + .filter(Boolean) + .join(", ")}`, }); // Schedule-impact warnings from the API: window reopened / now FULL / // consist trimmed below what bookings already hold. @@ -151,6 +201,7 @@ export default function AdjustConsistModal({ } setRemoveIds([]); setAddIds([]); + setSwitchMap({}); } catch (err) { toast({ title: "Adjustment failed", @@ -170,7 +221,7 @@ export default function AdjustConsistModal({ } radius="lg" - size={860} + size={920} centered > {consistQuery.isLoading || !data ? ( @@ -181,9 +232,19 @@ export default function AdjustConsistModal({ ) : ( + {data.currentStop?.isMidRoute ? ( + }> + Standing at {data.currentStop.label} — mid-route + consist work is open: couple or switch wagons standing at this + stop, trim wagons whose cargo was offloaded here. Detached wagons + stay at {data.currentStop.label}. + + ) : null} {!data.editable ? ( }> - The consist is frozen once the train is dispatched. + {data.schedule.status === "DISPATCHED" + ? "The train is rolling — consist changes are only possible while it stands at a route stop." + : "The consist can no longer be adjusted — the run is over."} ) : null} @@ -256,37 +317,39 @@ export default function AdjustConsistModal({ ) : null} - + - Trim coupled wagons ({data.totals.wagonCount}) + Coupled wagons ({data.totals.wagonCount}) - Only free (unloaded, unpinned) wagons can be detached. Detaching is - permanent — the wagon returns to the yard as available. + Trim only wagons carrying nothing beyond this stop. A loaded + wagon can't leave — but it can be switched: + the same-type replacement takes its position and its cargo + slot. Detaching is permanent. - + {data.wagons.map((wagon) => ( - ))} - + @@ -295,25 +358,30 @@ export default function AdjustConsistModal({ - AVAILABLE wagons standing in the train's yard. Blocked when they push - gross weight or length past the locomotive limits incl. tolerance. + AVAILABLE wagons standing at{" "} + {data.currentStop?.label ?? "the train's yard"}. Blocked when + they push gross weight or length past the locomotive limits + incl. tolerance. - + {data.addableWagons.length ? ( - data.addableWagons.map((wagon) => ( - - )) + data.addableWagons.map((wagon) => { + const takenBySwitch = usedReplacementIds.has(wagon.id); + return ( + + ); + }) ) : ( - No available wagons in this yard + No available wagons at this stop )} @@ -322,6 +390,19 @@ export default function AdjustConsistModal({ + {switchCount ? ( + } py={8}> + {Object.entries(switchMap) + .map(([fromId, toId]) => { + const from = data.wagons.find((w) => w.id === fromId); + const to = data.addableWagons.find((w) => w.id === toId); + return `${from?.wagonNumber ?? "?"} → ${to?.wagonNumber ?? "?"}`; + }) + .join(" · ")}{" "} + — cargo allocations move to the replacement wagon(s). + + ) : null} + {data.adjustments.length ? ( <> @@ -339,9 +420,19 @@ export default function AdjustConsistModal({ - {log.action === "ADD" ? "Added" : "Trimmed"} + {log.action === "ADD" + ? "Added" + : log.action === "SWITCH" + ? "Switched" + : "Trimmed"} {log.wagonNumber} @@ -369,15 +460,19 @@ export default function AdjustConsistModal({ loading={adjust.isPending} disabled={ !data.editable || - (!removeIds.length && !addIds.length) || + !hasChanges || (addIds.length > 0 && (projection?.overWeight || projection?.overLength)) } onClick={handleSubmit} > Apply{" "} - {removeIds.length ? `−${removeIds.length}` : ""} - {removeIds.length && addIds.length ? " / " : ""} - {addIds.length ? `+${addIds.length}` : ""} + {[ + removeIds.length ? `−${removeIds.length}` : "", + addIds.length ? `+${addIds.length}` : "", + switchCount ? `⇄${switchCount}` : "", + ] + .filter(Boolean) + .join(" / ")} @@ -429,7 +524,89 @@ function LimitGauge({ ); } -function WagonRow({ +/** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */ +function CoupledWagonRow({ + wagon, + checked, + editable, + switchValue, + switchOptions, + onToggleRemove, + onSwitch, +}: { + wagon: ConsistWagon; + checked: boolean; + editable: boolean; + switchValue: string | null; + switchOptions: Array<{ value: string; label: string }>; + onToggleRemove: (id: string, checked: boolean) => void; + onSwitch: (fromId: string, toId: string | null) => void; +}) { + const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null; + const checkbox = ( + onToggleRemove(wagon.id, e.currentTarget.checked)} + aria-label={`Trim wagon ${wagon.wagonNumber}`} + /> + ); + return ( + + {wagon.blockReason ? ( + + {checkbox} + + ) : ( + checkbox + )} + + + {wagon.wagonNumber} + + + {wagon.wagonType + ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m` + : "Unknown type"} + + + {badge ? ( + + {badge} + + ) : null} + {editable && wagon.switchable && switchOptions.length ? ( + setReturnedBy(val as "EDR" | "CUSTOMER" | null)} + data={[ + { value: "EDR", label: "EDR Truck" }, + { value: "CUSTOMER", label: "Customer Truck" }, + ]} + required + /> +