mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 04:48:18 +00:00
intercity fix
This commit is contained in:
@@ -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<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.train_schedules
|
||||||
|
SET status = 'SCHEDULED'
|
||||||
|
WHERE status = 'DRAFT'
|
||||||
|
AND deleted_at IS NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// One-way data promotion — nothing to restore.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,11 +135,20 @@ export class BookingContractService {
|
|||||||
async generateContractForGovernment(bookingId: string): Promise<void> {
|
async generateContractForGovernment(bookingId: string): Promise<void> {
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
if (!booking.isGovernment || booking.contractGeneratedAt) return;
|
if (!booking.isGovernment || booking.contractGeneratedAt) return;
|
||||||
|
const templateKey = this.templateResolver.resolve(booking);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
contractSummary: this.buildContractSummary(booking),
|
contractSummary: this.buildContractSummary(booking),
|
||||||
contractTemplateKey: this.templateResolver.resolve(booking),
|
contractTemplateKey: templateKey,
|
||||||
contractGeneratedAt: new Date(),
|
contractGeneratedAt: new Date(),
|
||||||
} as never);
|
} 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) {
|
async streamContract(bookingId: string) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
DocumentReviewStatus,
|
DocumentReviewStatus,
|
||||||
} from './entities/booking-document-review.entity';
|
} from './entities/booking-document-review.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.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 { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
@@ -201,10 +202,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
vgmPerUnitTons: number;
|
vgmPerUnitTons: number;
|
||||||
hazardousQuantity?: number;
|
hazardousQuantity?: number;
|
||||||
reeferQuantity?: number;
|
reeferQuantity?: number;
|
||||||
|
containerNumbers?: string[];
|
||||||
weightResult: ContainerWeightResult;
|
weightResult: ContainerWeightResult;
|
||||||
}>,
|
}>,
|
||||||
): Promise<BookingContainer[]> {
|
): Promise<BookingContainer[]> {
|
||||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||||
|
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||||
const typeRepo = this.dataSource.getRepository(ContainerType);
|
const typeRepo = this.dataSource.getRepository(ContainerType);
|
||||||
const saved: BookingContainer[] = [];
|
const saved: BookingContainer[] = [];
|
||||||
|
|
||||||
@@ -230,7 +233,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
isOverweight: item.weightResult.isOverweight,
|
isOverweight: item.weightResult.isOverweight,
|
||||||
overweightExcessTons: item.weightResult.overweightExcessTons,
|
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;
|
return saved;
|
||||||
|
|||||||
@@ -934,6 +934,7 @@ export class BookingsService {
|
|||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||||
hazardousQuantity: c.hazardousQuantity,
|
hazardousQuantity: c.hazardousQuantity,
|
||||||
reeferQuantity: c.reeferQuantity,
|
reeferQuantity: c.reeferQuantity,
|
||||||
|
containerNumbers: c.containerNumbers,
|
||||||
weightResult: ruleResult.containerWeightResults[i],
|
weightResult: ruleResult.containerWeightResults[i],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export class CreateBookingContainerDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
@Transform(({ value }) => Number(value ?? 0))
|
@Transform(({ value }) => Number(value ?? 0))
|
||||||
reeferQuantity?: number;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3322,6 +3322,22 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
booking: Booking,
|
booking: Booking,
|
||||||
reason: "paid" | "gov",
|
reason: "paid" | "gov",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 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) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const exists =
|
const exists =
|
||||||
await this.trainScheduleBookingsRepository.existsForBooking(
|
await this.trainScheduleBookingsRepository.existsForBooking(
|
||||||
@@ -3338,6 +3354,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
status: reason === "paid" ? "PAID" : booking.status,
|
status: reason === "paid" ? "PAID" : booking.status,
|
||||||
schedulingStatus: "SCHEDULED",
|
schedulingStatus: "SCHEDULED",
|
||||||
scheduledAt: new Date(),
|
scheduledAt: new Date(),
|
||||||
|
wagonsRequired,
|
||||||
paymentDeadline: null,
|
paymentDeadline: null,
|
||||||
selectedForBatchAt: null,
|
selectedForBatchAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-c
|
|||||||
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
@@ -123,11 +124,13 @@ import {
|
|||||||
sumWagonsRequired,
|
sumWagonsRequired,
|
||||||
type TrainLimitConfig,
|
type TrainLimitConfig,
|
||||||
maxEdgeConsistUsage,
|
maxEdgeConsistUsage,
|
||||||
|
perEdgeConsistUsage,
|
||||||
validateContainerPlacements,
|
validateContainerPlacements,
|
||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from './wagon-plan.util';
|
} from './wagon-plan.util';
|
||||||
|
import { CorridorBudget } from './corridor-capacity.util';
|
||||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||||
import {
|
import {
|
||||||
@@ -1511,7 +1514,9 @@ export class TrainSchedulingService {
|
|||||||
originStationId: route.originYardId,
|
originStationId: route.originYardId,
|
||||||
destinationStationId: route.destinationYardId,
|
destinationStationId: route.destinationYardId,
|
||||||
scheduledDepartureDate: departure,
|
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,
|
direction,
|
||||||
trainNumber: pairTrainNumber ?? undefined,
|
trainNumber: pairTrainNumber ?? undefined,
|
||||||
maxWagons,
|
maxWagons,
|
||||||
@@ -1615,7 +1620,11 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||||||
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
|
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
|
// Callers that add bookings without hand-picking container slots (the
|
||||||
// workspace "Add from pool" button, re-adding a removed booking) send no
|
// 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,
|
// hauled at the same time. Coupled-but-unplanned wagons ride every edge,
|
||||||
// so their tare rides on top of the binding edge.
|
// so their tare rides on top of the binding edge.
|
||||||
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
|
||||||
const edgeUsage = maxEdgeConsistUsage(
|
const scheduleStops = await this.stopYardsForSchedule(schedule);
|
||||||
wagonPlan,
|
const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops);
|
||||||
await this.stopYardsForSchedule(schedule),
|
const stopLabels = await this.yardLabelMap(scheduleStops);
|
||||||
);
|
// Each edge is its own consist — name EVERY leg that breaks the limit,
|
||||||
const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
|
// not just the heaviest figure, so staff see where along A→…→E it fails.
|
||||||
if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
|
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(
|
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);
|
const overlongLegs = perEdge
|
||||||
if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
|
.map((e) => ({ edge: e.edge, lengthMeters: roundTons(e.lengthMeters) }))
|
||||||
|
.filter((e) => e.lengthMeters > lengthCapWithOverage);
|
||||||
|
if (!dto.forceAssign && overlongLegs.length) {
|
||||||
throw new BadRequestException(
|
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) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
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) {
|
if (schedule.status !== TrainScheduleStatusEnum.Draft) {
|
||||||
throw new BadRequestException('Only DRAFT schedules can be finalized');
|
throw new BadRequestException('Only DRAFT schedules can be finalized');
|
||||||
}
|
}
|
||||||
@@ -4175,12 +4208,16 @@ export class TrainSchedulingService {
|
|||||||
wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]),
|
wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]),
|
||||||
).values(),
|
).values(),
|
||||||
];
|
];
|
||||||
|
const stopLabelMap =
|
||||||
|
stops.length > 2 ? await this.yardLabelMap(stops) : new Map<string, string>();
|
||||||
|
const stopLabels = stops.map((yardId) => stopLabelMap.get(yardId) ?? yardId);
|
||||||
pushLimit(
|
pushLimit(
|
||||||
validateMixedTrainLimitsPerEdge(
|
validateMixedTrainLimitsPerEdge(
|
||||||
wagonPlan,
|
wagonPlan,
|
||||||
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
||||||
trainLimits,
|
trainLimits,
|
||||||
stops,
|
stops,
|
||||||
|
stopLabels,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||||
@@ -4210,11 +4247,17 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
// Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
|
||||||
// above — the whole-route totals here are informational (summary) only. The
|
// above — the whole-route totals here are informational (summary) only. The
|
||||||
// locomotive checks below also compare the heaviest single edge: a train is
|
// locomotive checks below also compare per edge: a train is never heavier
|
||||||
// never heavier than its heaviest leg, so disjoint legs must not be summed.
|
// than its heaviest leg, so disjoint legs must not be summed.
|
||||||
const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
|
const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops);
|
||||||
const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
|
const maxEdgeGrossTons = roundTons(
|
||||||
const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
|
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[] = [];
|
let assignedLocomotives: Locomotive[] = [];
|
||||||
if (targetScheduleId) {
|
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`,
|
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (setLimits) {
|
||||||
setLimits &&
|
// Name every leg the set cannot pull — staff must see WHERE along the
|
||||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
// corridor the train is too heavy/long, not just that it is somewhere.
|
||||||
maxEdgeGrossTons ||
|
const weightCap =
|
||||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0);
|
||||||
maxEdgeLengthMeters)
|
const lengthCap =
|
||||||
) {
|
setLimits.maxTrainLengthMeters +
|
||||||
pushLimit([
|
(Number(setLimits.overageToleranceMeters) || 0);
|
||||||
'Assigned locomotives cannot support the total train weight and length',
|
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 {
|
} else {
|
||||||
const inServiceLocomotives = await this.locomotivesRepository.findAll({
|
const inServiceLocomotives = await this.locomotivesRepository.findAll({
|
||||||
@@ -4320,6 +4376,7 @@ export class TrainSchedulingService {
|
|||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
},
|
},
|
||||||
locomotive?: LocomotiveLimits | null,
|
locomotive?: LocomotiveLimits | null,
|
||||||
|
builtWagonCount?: number,
|
||||||
): Promise<Required<TrainLimitConfig>> {
|
): Promise<Required<TrainLimitConfig>> {
|
||||||
const row = await this.loadGlobalRulesRow();
|
const row = await this.loadGlobalRulesRow();
|
||||||
const configured = this.configService?.get<{
|
const configured = this.configService?.get<{
|
||||||
@@ -4362,10 +4419,18 @@ export class TrainSchedulingService {
|
|||||||
return {
|
return {
|
||||||
maxWeightTons: derived.maxWeightTons,
|
maxWeightTons: derived.maxWeightTons,
|
||||||
maxLengthMeters: derived.maxLengthMeters,
|
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:
|
maxWagonsPerTrain:
|
||||||
dto?.maxWagonsPerTrain != null
|
dto?.maxWagonsPerTrain != null
|
||||||
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
|
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
|
||||||
: derived.maxWagonSlots,
|
: builtWagonCount && builtWagonCount > 0
|
||||||
|
? builtWagonCount
|
||||||
|
: derived.maxWagonSlots,
|
||||||
max20ftContainerWeightTons: this.positiveNumber(
|
max20ftContainerWeightTons: this.positiveNumber(
|
||||||
undefined,
|
undefined,
|
||||||
Number(row?.max20ftContainerWeightTons) ||
|
Number(row?.max20ftContainerWeightTons) ||
|
||||||
@@ -6274,7 +6339,15 @@ export class TrainSchedulingService {
|
|||||||
route: { milestones: true },
|
route: { milestones: true },
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: 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' },
|
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) {
|
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
|
||||||
const schedules = await this.getBookableScheduleEntities(
|
const schedules = await this.getBookableScheduleEntities(
|
||||||
originYardId,
|
originYardId,
|
||||||
destinationYardId,
|
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: [] };
|
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(
|
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);
|
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
|
||||||
|
|
||||||
@@ -6906,6 +7029,9 @@ export class TrainSchedulingService {
|
|||||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||||
: null,
|
: null,
|
||||||
status: 'EMPTY',
|
status: 'EMPTY',
|
||||||
|
// Coupled wagons ride the whole corridor — they count on every leg.
|
||||||
|
boardYardId: null,
|
||||||
|
alightYardId: null,
|
||||||
physicalWagonId: wagon.id,
|
physicalWagonId: wagon.id,
|
||||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||||
wagonType: wagon.wagonType
|
wagonType: wagon.wagonType
|
||||||
@@ -7098,6 +7224,10 @@ export class TrainSchedulingService {
|
|||||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||||
: null,
|
: null,
|
||||||
status: wagon.status,
|
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
|
physicalWagonId: frozenSlot
|
||||||
? frozenSlot.physicalWagonId
|
? frozenSlot.physicalWagonId
|
||||||
: wagon.physicalWagonId ?? null,
|
: wagon.physicalWagonId ?? null,
|
||||||
@@ -7184,10 +7314,7 @@ export class TrainSchedulingService {
|
|||||||
sb.booking?.destinationYard?.label ??
|
sb.booking?.destinationYard?.label ??
|
||||||
sb.booking?.destinationYard?.code ??
|
sb.booking?.destinationYard?.code ??
|
||||||
null,
|
null,
|
||||||
wagonsRequired:
|
wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null,
|
||||||
sb.booking?.wagonsRequired != null
|
|
||||||
? Number(sb.booking.wagonsRequired)
|
|
||||||
: null,
|
|
||||||
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||||||
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||||||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||||||
@@ -7212,6 +7339,15 @@ export class TrainSchedulingService {
|
|||||||
)
|
)
|
||||||
: null;
|
: 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
|
// 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
|
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||||
// it "historical" and skip re-pin affordances.
|
// 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<Map<string, string>> {
|
||||||
|
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). */
|
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||||
private mapScheduleStops(
|
private mapScheduleStops(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
@@ -7310,6 +7478,7 @@ export class TrainSchedulingService {
|
|||||||
const limits = await this.resolveTrainLimitConfig(
|
const limits = await this.resolveTrainLimitConfig(
|
||||||
undefined,
|
undefined,
|
||||||
trainSetLocomotiveLimits(schedule.trainSet),
|
trainSetLocomotiveLimits(schedule.trainSet),
|
||||||
|
schedule.maxWagons ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const validation = await this.validateBookingsForScheduling(
|
const validation = await this.validateBookingsForScheduling(
|
||||||
@@ -7439,6 +7608,7 @@ export class TrainSchedulingService {
|
|||||||
const limits = await this.resolveTrainLimitConfig(
|
const limits = await this.resolveTrainLimitConfig(
|
||||||
undefined,
|
undefined,
|
||||||
trainSetLocomotiveLimits(schedule.trainSet),
|
trainSetLocomotiveLimits(schedule.trainSet),
|
||||||
|
schedule.maxWagons ?? undefined,
|
||||||
);
|
);
|
||||||
const validation = await this.validateBookingsForScheduling(
|
const validation = await this.validateBookingsForScheduling(
|
||||||
previewDto,
|
previewDto,
|
||||||
@@ -7573,6 +7743,7 @@ export class TrainSchedulingService {
|
|||||||
const limits = await this.resolveTrainLimitConfig(
|
const limits = await this.resolveTrainLimitConfig(
|
||||||
undefined,
|
undefined,
|
||||||
trainSetLocomotiveLimits(schedule.trainSet),
|
trainSetLocomotiveLimits(schedule.trainSet),
|
||||||
|
schedule.maxWagons ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||||
@@ -8161,6 +8332,7 @@ export class TrainSchedulingService {
|
|||||||
const limits = await this.resolveTrainLimitConfig(
|
const limits = await this.resolveTrainLimitConfig(
|
||||||
undefined,
|
undefined,
|
||||||
trainSetLocomotiveLimits(schedule.trainSet),
|
trainSetLocomotiveLimits(schedule.trainSet),
|
||||||
|
schedule.maxWagons ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
|
||||||
|
|||||||
@@ -549,9 +549,12 @@ export function validateMixedTrainLimitsPerEdge(
|
|||||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||||
limits: TrainLimitConfig | undefined,
|
limits: TrainLimitConfig | undefined,
|
||||||
stops: string[],
|
stops: string[],
|
||||||
|
/** Display names parallel to `stops` — violations then name the leg they hit. */
|
||||||
|
stopLabels?: string[],
|
||||||
): string[] {
|
): string[] {
|
||||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||||
const spans = slotSpans(wagonPlan, stops);
|
const spans = slotSpans(wagonPlan, stops);
|
||||||
|
const label = (i: number) => stopLabels?.[i] ?? stops[i];
|
||||||
const violations = new Set<string>();
|
const violations = new Set<string>();
|
||||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||||
const active = wagonPlan.filter(
|
const active = wagonPlan.filter(
|
||||||
@@ -559,7 +562,7 @@ export function validateMixedTrainLimitsPerEdge(
|
|||||||
);
|
);
|
||||||
if (!active.length) continue;
|
if (!active.length) continue;
|
||||||
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||||||
violations.add(violation);
|
violations.add(`Leg ${label(edge)} → ${label(edge + 1)}: ${violation}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...violations];
|
return [...violations];
|
||||||
@@ -603,7 +606,37 @@ export function maxEdgeConsistUsage(
|
|||||||
wagonPlan: EdgeUsageSlot[],
|
wagonPlan: EdgeUsageSlot[],
|
||||||
stops: string[],
|
stops: string[],
|
||||||
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
|
): { 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(
|
grossWeightTons: slots.reduce(
|
||||||
(sum, w) =>
|
(sum, w) =>
|
||||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
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),
|
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||||
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
|
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 spans = slotSpans(wagonPlan, stops);
|
||||||
const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 };
|
return Array.from({ length: stops.length - 1 }, (_, edge) =>
|
||||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
totals(
|
||||||
const active = totals(
|
edge,
|
||||||
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
|
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(
|
export function validate20ftContainerRules(
|
||||||
|
|||||||
@@ -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<string, number>,
|
||||||
|
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 (
|
||||||
|
<Stack gap={4} miw={140}>
|
||||||
|
<Text size="sm" fw={600} c={cap != null && used > cap ? "red.7" : undefined}>
|
||||||
|
{round1(used)}
|
||||||
|
{cap != null ? ` / ${round1(cap)}` : ""} {unit}
|
||||||
|
</Text>
|
||||||
|
{cap != null ? <Progress value={pct} color={color} size="sm" radius="xl" /> : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<Alert mt="lg" radius="lg" color="gray" icon={<Info size={16} />}>
|
||||||
|
This schedule has no corridor stops to break into legs.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wagons.length) {
|
||||||
|
return (
|
||||||
|
<Alert mt="lg" radius="lg" color="gray" icon={<Info size={16} />}>
|
||||||
|
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.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legStatus = (e: EdgeUsage) => {
|
||||||
|
if (weightCap != null && e.grossTons > weightCap)
|
||||||
|
return <Badge color="red" variant="filled">Overweight</Badge>;
|
||||||
|
if (lengthCap != null && e.lengthMeters > lengthCap)
|
||||||
|
return <Badge color="red" variant="filled">Over length</Badge>;
|
||||||
|
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 <Badge color="orange" variant="filled">Full</Badge>;
|
||||||
|
return (
|
||||||
|
<Badge color="teal" variant="light">
|
||||||
|
{tonsFree != null ? `${tonsFree}T free` : "Available"}
|
||||||
|
{wagonsFree != null ? ` · ${wagonsFree} wagons` : ""}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Stack gap="lg" mt="lg">
|
||||||
|
<Paper radius="xl" p="lg" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={700}>Per-leg utilization</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
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)" : ""}.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Table.ScrollContainer minWidth={720}>
|
||||||
|
<Table verticalSpacing="sm" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Leg</Table.Th>
|
||||||
|
<Table.Th>Wagons</Table.Th>
|
||||||
|
<Table.Th>Gross weight</Table.Th>
|
||||||
|
<Table.Th>Length</Table.Th>
|
||||||
|
<Table.Th>Bookings</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{edges.map((e) => (
|
||||||
|
<Table.Tr key={e.edge}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{e.from.label} → {e.to.label}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{e.bookingRefs.length ? (
|
||||||
|
<Tooltip
|
||||||
|
label={e.bookingRefs.join(", ")}
|
||||||
|
multiline
|
||||||
|
maw={320}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ cursor: "help" }}>
|
||||||
|
{e.bookingRefs.length}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
0
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{legStatus(e)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{stops.length > 2 ? (
|
||||||
|
<Paper radius="xl" p="lg" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={700}>Availability by origin → destination</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Every bookable pair along the corridor. Room for a pair is the
|
||||||
|
tightest leg it rides — hover a cell to see which leg binds.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Table.ScrollContainer minWidth={520}>
|
||||||
|
<Table verticalSpacing="xs" withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>From \ To</Table.Th>
|
||||||
|
{stops.slice(1).map((s) => (
|
||||||
|
<Table.Th key={s.yardId}>{s.label}</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{stops.slice(0, -1).map((from, fi) => (
|
||||||
|
<Table.Tr key={from.yardId}>
|
||||||
|
<Table.Th>{from.label}</Table.Th>
|
||||||
|
{stops.slice(1).map((to, ci) => {
|
||||||
|
const ti = ci + 1;
|
||||||
|
if (ti <= fi) {
|
||||||
|
return (
|
||||||
|
<Table.Td key={to.yardId}>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<Table.Td key={to.yardId} style={{ background: color }}>
|
||||||
|
<Tooltip
|
||||||
|
withArrow
|
||||||
|
label={`Binding leg: ${avail.binding.from.label} → ${avail.binding.to.label} (${avail.binding.grossTons}T${weightCap != null ? ` of ${weightCap}T` : ""})`}
|
||||||
|
>
|
||||||
|
<Box ta="center" style={{ cursor: "help" }}>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
c={over ? "red.8" : full ? "orange.8" : "teal.8"}
|
||||||
|
>
|
||||||
|
{over
|
||||||
|
? "Overweight"
|
||||||
|
: full
|
||||||
|
? "Full"
|
||||||
|
: `${avail.tonsFree ?? "?"}T free`}
|
||||||
|
</Text>
|
||||||
|
{avail.wagonsFree != null && !over ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{Math.max(0, avail.wagonsFree)} wagons free
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||||
|
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
@@ -1162,6 +1163,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
|
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
|
||||||
Workspace
|
Workspace
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
||||||
|
Leg capacity
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="workflow">
|
<Tabs.Panel value="workflow">
|
||||||
@@ -1243,6 +1247,10 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="legs">
|
||||||
|
<LegCapacityPanel schedule={schedule} />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{scheduleId ? (
|
{scheduleId ? (
|
||||||
|
|||||||
@@ -653,6 +653,9 @@ export interface TrainScheduleDetail {
|
|||||||
/** Empty-wagon weight from the wagon type — gross = tare + cargo. */
|
/** Empty-wagon weight from the wagon type — gross = tare + cargo. */
|
||||||
tareWeightTons?: number | null;
|
tareWeightTons?: number | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
/** Corridor span this slot rides; null = the schedule's own endpoint. */
|
||||||
|
boardYardId?: string | null;
|
||||||
|
alightYardId?: string | null;
|
||||||
physicalWagonId?: string | null;
|
physicalWagonId?: string | null;
|
||||||
physicalWagonNumber?: string | null;
|
physicalWagonNumber?: string | null;
|
||||||
wagonType?: {
|
wagonType?: {
|
||||||
@@ -693,6 +696,8 @@ export interface TrainScheduleDetail {
|
|||||||
stops?: Array<{ yardId: string; label: string }>;
|
stops?: Array<{ yardId: string; label: string }>;
|
||||||
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
|
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
|
||||||
maxGrossWeightTons?: number | null;
|
maxGrossWeightTons?: number | null;
|
||||||
|
/** Train length ceiling incl. overage tolerance — per-leg length is held to it. */
|
||||||
|
maxLengthMeters?: number | null;
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user