add hard capacity ceiling to weight limit rules

This commit is contained in:
Marshal
2026-07-04 01:11:23 +00:00
parent 97cc9d76b1
commit 8ea2c8e95a
19 changed files with 340 additions and 196 deletions

View File

@@ -605,16 +605,22 @@ export class TrainSchedulingService {
);
if (!validation.valid) {
// Put the violation detail in the message itself — global exception
// filters flatten the body, and "Booking validation failed" alone tells
// staff nothing (e.g. which wagon type is missing at the yard).
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.length) {
const shortfall = validation.deferredBookings
.map((d) => `${d.reference}: ${d.reason}`)
.join('; ');
throw new BadRequestException({
message: 'No bookings fit on available fleet wagons',
message: `No wagons available for the selected bookings${shortfall ? `${shortfall}` : ''}`,
violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
@@ -628,12 +634,14 @@ export class TrainSchedulingService {
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (limitLoco.maxPullWeightTons < totalWeightTons) {
// forceAssign lets staff overload the locomotive set knowingly — the
// validator has already surfaced it as a warning in that case.
if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
@@ -2251,9 +2259,16 @@ export class TrainSchedulingService {
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
// wrong status) can never be forced and stay violations.
const pushLimit = (issues: string[]) =>
forceAssign ? warnings.push(...issues) : violations.push(...issues);
if (resolvedMode === 'MIXED') {
violations.push(
...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
pushLimit(
validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
);
if (requireContainerPlacements) {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
@@ -2270,7 +2285,7 @@ export class TrainSchedulingService {
);
}
} else {
violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits));
pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits));
if (requireContainerPlacements && resolvedMode === 'CONTAINER') {
violations.push(
@@ -2293,8 +2308,8 @@ export class TrainSchedulingService {
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message)) {
violations.push(message);
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
}
@@ -2321,9 +2336,9 @@ export class TrainSchedulingService {
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
pushLimit([
'Assigned locomotives cannot support the total train weight and length',
);
]);
}
} else {
const inServiceLocomotives = await this.locomotivesRepository.findAll({
@@ -2341,7 +2356,7 @@ export class TrainSchedulingService {
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No locomotive can support the total train weight and length');
pushLimit(['No locomotive can support the total train weight and length']);
}
}
@@ -3740,7 +3755,7 @@ export class TrainSchedulingService {
if (!validation.valid) {
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});

View File

@@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
const perWagon = containersPerWagonFromType(wagonsPerUnit);
const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of
// the line maps to units[i] (sortOrder order); the line-level number is only
// a legacy fallback — never invent numbers here.
const units = [...(line.units ?? [])].sort(
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
);
for (let i = 0; i < qty; i += 1) {
const unit = units[i];
rows.push({
bookingId: booking.id,
bookingReference: booking.reference,
@@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
containerTypeId: line.containerTypeId ?? '',
containerTypeCode: code,
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(line.vgmPerUnitTons),
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt,
wagonsPerUnit,
containersPerWagon: perWagon,
teuSlots,
containerNumber: line.containerNumber ?? null,
containerNumber:
unit?.containerNumber?.trim() || line.containerNumber || null,
});
}
}