update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles

This commit is contained in:
Marshal
2026-07-04 04:59:51 +00:00
parent c650a2dcbd
commit f37078d51d
21 changed files with 478 additions and 86 deletions

View File

@@ -15,7 +15,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Not } from 'typeorm';
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
@@ -38,6 +38,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
@@ -87,10 +89,6 @@ import {
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
import {
getDefaultContainerWagonTypeCode,
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
@@ -2725,28 +2723,98 @@ export class TrainSchedulingService {
return violations;
}
/**
* Resolve the wagon type for a batch through the cargo-type / container-type
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
* when the relevant type has no wagon type configured — scheduling is blocked
* until an admin assigns one on the cargo-type / container-type config screen.
*/
private async resolveWagonType(
freightType: 'CONTAINER' | 'BULK',
bookingIds: string[],
): Promise<WagonType> {
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
if (freightType === 'CONTAINER') {
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
});
if (!wagonType) {
throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
// First container type present on the batch drives the container wagon
// type (matches the prior single-wagon-type-per-consist behavior).
const containerType = bookings
.flatMap((b) => b.bookingContainers ?? [])
.map((line) => line.containerType)
.find((ct): ct is NonNullable<typeof ct> => Boolean(ct));
if (!containerType) {
throw new BadRequestException('No container type found on the container booking(s)');
}
const wagonType = await this.loadWagonTypeForType(
containerType.wagonTypeId ?? null,
`Container type "${containerType.label ?? containerType.code}"`,
);
return wagonType;
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const cargoCode = bookings[0]?.cargoType?.code ?? null;
const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
const picked = pickBulkWagonType(wagonTypes, cargoCode);
if (!picked) {
throw new NotFoundException('No suitable bulk wagon type found');
const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct));
if (!cargoType) {
throw new BadRequestException('No cargo type found on the bulk booking(s)');
}
return picked;
return this.loadWagonTypeForType(
cargoType.wagonTypeId ?? null,
`Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`,
);
}
/**
* Load an active wagon type by FK id, throwing a clear error when the id is
* unset (type not configured) or points at a missing/inactive wagon type.
*/
private async loadWagonTypeForType(
wagonTypeId: string | null,
typeLabel: string,
): Promise<WagonType> {
if (!wagonTypeId) {
throw new BadRequestException(
`${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`,
);
}
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { id: wagonTypeId, isActive: true },
});
if (!wagonType) {
throw new NotFoundException(
`${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`,
);
}
return wagonType;
}
/**
* Soft wagon-type resolution for the customer-facing availability preview
* (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
* returns null (→ "no days") instead of throwing when nothing is configured,
* since this only estimates which days have wagons and creates no booking.
*/
private async resolveWagonTypeForPreview(
freightType: 'CONTAINER' | 'BULK',
cargoTypeCode: string | null,
): Promise<WagonType | null> {
if (freightType === 'BULK') {
if (!cargoTypeCode) return null;
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
where: { code: cargoTypeCode },
relations: { wagonType: true },
});
return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
}
// Container preview: the input carries no specific container type, so use the
// wagon type of the first configured (active) container type.
const containerType = await this.dataSource
.getRepository(ContainerType)
.findOne({
where: { isActive: true, wagonTypeId: Not(IsNull()) },
relations: { wagonType: true },
order: { displayOrder: 'ASC' },
});
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
}
private async persistTrainSetWagons(
@@ -3419,15 +3487,12 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
const wagonTypes = await this.dataSource.getRepository(WagonType).find();
// Resolve the wagon type this cargo needs.
const requiredType =
input.freightType === 'BULK'
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
: wagonTypes.find(
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
);
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
// Soft (customer availability preview): no days if unresolved, never throws.
const requiredType = await this.resolveWagonTypeForPreview(
input.freightType,
input.cargoTypeCode ?? null,
);
if (!requiredType) return { days: [] };
// How many wagons of that type the cargo needs.

View File

@@ -1,49 +0,0 @@
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
COFFEE: 'KW2',
GRAIN: 'KW2',
WHEAT: 'KW2',
SORGHUM: 'KW2',
CORN: 'KW2',
FERTILIZER: 'PW2',
SUGAR: 'PW2',
COAL: 'KW3',
STEEL: 'CW3',
ORE: 'CW3',
};
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
/**
* Resolve wagon type code from cargo type code for bulk freight.
*/
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
const normalized = cargoTypeCode.trim().toUpperCase();
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
}
/**
* Pick the best matching wagon type entity for bulk cargo.
*/
export function pickBulkWagonType(
wagonTypes: WagonType[],
cargoTypeCode?: string | null,
): WagonType | undefined {
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
if (direct) return direct;
return wagonTypes.find(
(wt) =>
wt.isActive &&
!wt.supportsContainer &&
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
);
}
export function getDefaultContainerWagonTypeCode(): string {
return DEFAULT_CONTAINER_WAGON_TYPE;
}