intercity fix

This commit is contained in:
Marshal
2026-07-31 10:26:17 +00:00
parent d07a2e239e
commit 74b7ee81fc
11 changed files with 679 additions and 49 deletions

View File

@@ -135,11 +135,20 @@ export class BookingContractService {
async generateContractForGovernment(bookingId: string): Promise<void> {
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) {

View File

@@ -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<Booking> {
vgmPerUnitTons: number;
hazardousQuantity?: number;
reeferQuantity?: number;
containerNumbers?: string[];
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
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<Booking> {
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;

View File

@@ -934,6 +934,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
containerNumbers: c.containerNumbers,
weightResult: ruleResult.containerWeightResults[i],
})),
);

View File

@@ -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[];
}
/**

View File

@@ -3322,6 +3322,22 @@ export class BookingBatchService implements OnModuleInit {
booking: Booking,
reason: "paid" | "gov",
): 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) => {
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);

View File

@@ -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<string, string>();
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<Required<TrainLimitConfig>> {
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<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). */
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<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
@@ -8161,6 +8332,7 @@ export class TrainSchedulingService {
const limits = await this.resolveTrainLimitConfig(
undefined,
trainSetLocomotiveLimits(schedule.trainSet),
schedule.maxWagons ?? undefined,
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;

View File

@@ -549,9 +549,12 @@ export function validateMixedTrainLimitsPerEdge(
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
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<string>();
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(