import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, In } from 'typeorm'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { Booking } from './entities/booking.entity'; import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { Container20ftUnit, PairingViolation, validate20ftWeightPairing, } from './container-pairing.util'; /** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */ const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10; /** * Booking-time container validations that need the customer-entered per-unit * weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the * rule engine (which works on line totals) because pairing is per physical unit. */ @Injectable() export class ContainerValidationService { constructor(@InjectDataSource() private readonly dataSource: DataSource) {} private async maxPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) .find({ order: { createdAt: 'ASC' }, take: 1 }) .then((rows) => rows[0] ?? null) .catch(() => null); const v = row?.max20ftPairWeightDiffTons; const n = v == null ? NaN : Number(v); return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS; } /** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */ private async load20ftUnits(booking: Booking): Promise { const lines = (booking.bookingContainers ?? []).filter( (bc) => (bc.containerSize ?? '').includes('20'), ); if (!lines.length) return []; const units = await this.dataSource .getRepository(BookingContainerUnit) .find({ where: { bookingContainerId: In(lines.map((l) => l.id)) }, order: { sortOrder: 'ASC' }, }); return units.map((u) => ({ label: u.containerNumber || u.id.slice(0, 8), grossWeightTons: Number(u.vgmTons ?? 0), })); } /** * Validate the 20ft weight-pairing rule for a booking. Returns one message per * pair whose weight difference exceeds the cap; empty when all 20ft can be * balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine — * it flows to consolidation. Callers hard-block a non-empty result. */ async validate20ftPairing(booking: Booking): Promise { // Only bookings whose 20ft lines actually carry per-unit weights can be // checked; contract-drawdown bookings do (units are required there). const containerLines = booking.bookingContainers ?? []; const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); if (!has20ft) return []; const units = await this.load20ftUnits(booking); if (units.length < 2) return []; const maxDiff = await this.maxPairDiffTons(); return validate20ftWeightPairing(units, maxDiff); } }