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/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.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 2536f15c4..f9103ddbb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1175,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/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-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 06e8a5f1c..6326efbbf 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 @@ -3322,6 +3322,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 +3354,7 @@ export class BookingBatchService implements OnModuleInit { status: reason === "paid" ? "PAID" : booking.status, schedulingStatus: "SCHEDULED", scheduledAt: new Date(), + wagonsRequired, paymentDeadline: null, selectedForBatchAt: null, } as never); 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..fe2c6905d 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 @@ -63,6 +63,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 +124,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 { @@ -1511,7 +1514,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 +1620,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 +1778,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 +2323,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 +4208,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 +4247,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 +4277,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 +4376,7 @@ export class TrainSchedulingService { maxWagonsPerTrain?: number; }, locomotive?: LocomotiveLimits | null, + builtWagonCount?: number, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -4362,10 +4419,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) || @@ -6274,7 +6339,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 +6405,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 +6498,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); @@ -6906,6 +7029,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 +7224,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 +7314,7 @@ 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, 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 +7339,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 +7356,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 +7478,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); const validation = await this.validateBookingsForScheduling( @@ -7439,6 +7608,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); const validation = await this.validateBookingsForScheduling( previewDto, @@ -7573,6 +7743,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); let validation: Awaited>; @@ -8161,6 +8332,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/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx new file mode 100644 index 000000000..6fa2f276d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx @@ -0,0 +1,330 @@ +import { useMemo } from "react"; +import { + Alert, + Badge, + Box, + Paper, + Progress, + Stack, + Table, + Text, + Tooltip, +} from "@mantine/core"; +import { Info } from "lucide-react"; + +import type { TrainScheduleDetail } from "@/types/trainScheduling"; + +/** + * Per-leg capacity workspace tab. A multi-stop corridor (A→B→C→D→E) is + * capacity-checked edge by edge, so this shows, for EVERY adjacent leg, the + * wagons/weight/length the consist actually uses there — and for every + * possible origin→destination pair (A→C, B→E, …) the room left, which is the + * minimum over the legs the pair rides. + */ + +interface Stop { + yardId: string; + label: string; +} + +interface EdgeUsage { + edge: number; + from: Stop; + to: Stop; + wagons: number; + grossTons: number; + lengthMeters: number; + bookingRefs: string[]; +} + +const round1 = (n: number) => Math.round(n * 10) / 10; + +function utilizationColor(used: number, cap: number | null): string { + if (cap == null || cap <= 0) return "gray"; + const pct = used / cap; + if (pct > 1) return "red"; + if (pct >= 0.9) return "orange"; + if (pct >= 0.75) return "yellow"; + return "teal"; +} + +/** Mirrors the API's slotSpans: unknown/missing yard = the schedule endpoint. */ +function spanOf( + boardYardId: string | null | undefined, + alightYardId: string | null | undefined, + indexOf: Map, + lastIdx: number, +): { from: number; to: number } { + const fromRaw = boardYardId ? indexOf.get(boardYardId) : 0; + const toRaw = alightYardId ? indexOf.get(alightYardId) : lastIdx; + const from = fromRaw != null && fromRaw >= 0 ? fromRaw : 0; + const to = toRaw != null && toRaw > 0 ? toRaw : lastIdx; + return { from, to }; +} + +function UsageCell({ + used, + cap, + unit, +}: { + used: number; + cap: number | null; + unit: string; +}) { + const color = utilizationColor(used, cap); + const pct = cap ? Math.min(100, (used / cap) * 100) : 0; + return ( + + cap ? "red.7" : undefined}> + {round1(used)} + {cap != null ? ` / ${round1(cap)}` : ""} {unit} + + {cap != null ? : null} + + ); +} + +export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }) { + const stops: Stop[] = schedule.stops ?? []; + const wagons = schedule.trainSet?.wagons ?? []; + const weightCap = schedule.maxGrossWeightTons ?? null; + const lengthCap = schedule.maxLengthMeters ?? null; + const wagonCap = schedule.maxWagons ?? null; + + const edges: EdgeUsage[] = useMemo(() => { + if (stops.length < 2) return []; + const indexOf = new Map(stops.map((s, i) => [s.yardId, i])); + const lastIdx = stops.length - 1; + const spans = wagons.map((w) => + spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx), + ); + return stops.slice(0, -1).map((from, edge) => { + const active = wagons.filter( + (_, i) => spans[i].from <= edge && edge < spans[i].to, + ); + const refs = new Set(); + let grossTons = 0; + let lengthMeters = 0; + for (const w of active) { + grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0); + lengthMeters += Number(w.lengthMeters) || 0; + for (const a of w.allocations ?? []) { + if (a.bookingReference) refs.add(a.bookingReference); + } + } + return { + edge, + from, + to: stops[edge + 1], + wagons: active.length, + grossTons: round1(grossTons), + lengthMeters: round1(lengthMeters), + bookingRefs: [...refs], + }; + }); + }, [stops, wagons]); + + if (stops.length < 2) { + return ( + }> + This schedule has no corridor stops to break into legs. + + ); + } + + if (!wagons.length) { + return ( + }> + No wagon plan yet — leg utilization appears once bookings are allocated + to wagons. The route strip in the header shows the booking-based + estimate meanwhile. + + ); + } + + const legStatus = (e: EdgeUsage) => { + if (weightCap != null && e.grossTons > weightCap) + return Overweight; + if (lengthCap != null && e.lengthMeters > lengthCap) + return Over length; + const wagonsFree = wagonCap != null ? wagonCap - e.wagons : null; + const tonsFree = weightCap != null ? round1(weightCap - e.grossTons) : null; + if ((wagonsFree != null && wagonsFree <= 0) || (tonsFree != null && tonsFree <= 0)) + return Full; + return ( + + {tonsFree != null ? `${tonsFree}T free` : "Available"} + {wagonsFree != null ? ` · ${wagonsFree} wagons` : ""} + + ); + }; + + // Availability for a span = the tightest leg it rides. + const spanAvailability = (from: number, to: number) => { + const slice = edges.slice(from, to); + const wagonsUsed = Math.max(...slice.map((e) => e.wagons)); + const tonsUsed = Math.max(...slice.map((e) => e.grossTons)); + const binding = slice.reduce((worst, e) => (e.grossTons > worst.grossTons ? e : worst)); + return { + wagonsFree: wagonCap != null ? wagonCap - wagonsUsed : null, + tonsFree: weightCap != null ? round1(weightCap - tonsUsed) : null, + tonsUsed, + binding, + }; + }; + + return ( + + + + + Per-leg utilization + + Each adjacent leg is checked as its own train — wagon tare + cargo + against the locomotive limits{weightCap != null ? ` (${weightCap}T` : ""} + {weightCap != null && lengthCap != null ? `, ${lengthCap}m` : ""} + {weightCap != null ? " incl. tolerance)" : ""}. + + + + + + + Leg + Wagons + Gross weight + Length + Bookings + Status + + + + {edges.map((e) => ( + + + + {e.from.label} → {e.to.label} + + + + + + + + + + + + + {e.bookingRefs.length ? ( + + + {e.bookingRefs.length} + + + ) : ( + + 0 + + )} + + {legStatus(e)} + + ))} + +
+
+
+
+ + {stops.length > 2 ? ( + + + + Availability by origin → destination + + Every bookable pair along the corridor. Room for a pair is the + tightest leg it rides — hover a cell to see which leg binds. + + + + + + + From \ To + {stops.slice(1).map((s) => ( + {s.label} + ))} + + + + {stops.slice(0, -1).map((from, fi) => ( + + {from.label} + {stops.slice(1).map((to, ci) => { + const ti = ci + 1; + if (ti <= fi) { + return ( + + + — + + + ); + } + const avail = spanAvailability(fi, ti); + const over = + weightCap != null && avail.tonsUsed > weightCap; + const full = + !over && + ((avail.wagonsFree != null && avail.wagonsFree <= 0) || + (avail.tonsFree != null && avail.tonsFree <= 0)); + const color = over + ? "var(--mantine-color-red-1)" + : full + ? "var(--mantine-color-orange-1)" + : "var(--mantine-color-teal-0)"; + return ( + + + + + {over + ? "Overweight" + : full + ? "Full" + : `${avail.tonsFree ?? "?"}T free`} + + {avail.wagonsFree != null && !over ? ( + + {Math.max(0, avail.wagonsFree)} wagons free + + ) : null} + + + + ); + })} + + ))} + +
+
+
+
+ ) : null} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 63a3f3ed2..d3016ac7a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -50,6 +50,7 @@ import { import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; +import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; @@ -1162,6 +1163,9 @@ export default function TrainScheduleV2DetailPage() { }> Workspace + }> + Leg capacity + @@ -1243,6 +1247,10 @@ export default function TrainScheduleV2DetailPage() { /> ) : null} + + + + {scheduleId ? ( diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 3ac115a76..cd9efbd5a 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -653,6 +653,9 @@ export interface TrainScheduleDetail { /** Empty-wagon weight from the wagon type — gross = tare + cargo. */ tareWeightTons?: number | null; status?: string; + /** Corridor span this slot rides; null = the schedule's own endpoint. */ + boardYardId?: string | null; + alightYardId?: string | null; physicalWagonId?: string | null; physicalWagonNumber?: string | null; wagonType?: { @@ -693,6 +696,8 @@ export interface TrainScheduleDetail { stops?: Array<{ yardId: string; label: string }>; /** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */ maxGrossWeightTons?: number | null; + /** Train length ceiling incl. overage tolerance — per-leg length is held to it. */ + maxLengthMeters?: number | null; warnings?: string[]; }