Refactor wagon type handling and container wagon calculations

- Removed maxWagonsPerTrain from WagonType entity and related DTOs.
- Updated containerWagonsForLines function to calculate required wagons based on container lines more accurately.
- Added unit tests for containerWagonsForLines to ensure correct calculations.
- Adjusted related services and scripts to reflect the removal of maxWagonsPerTrain.
- Enhanced booking and contract components to use new status labels for better user experience.
- Implemented validation for unique container numbers in shipment forms.
This commit is contained in:
Marshal
2026-07-09 03:36:35 +00:00
parent addee34d5d
commit ddddcfb71f
39 changed files with 753 additions and 121 deletions

View File

@@ -993,6 +993,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
@@ -1023,6 +1024,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id = :originYardId', { originYardId })
.andWhere('booking.destination_yard_id = :destinationYardId', {
@@ -1061,6 +1063,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
@@ -1125,6 +1128,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
@@ -1138,6 +1142,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.getMany();

View File

@@ -49,6 +49,23 @@ export class CreateLocomotiveDto {
@Min(0)
maxTrainLengthMeters!: number;
// Allowed deviation above maxPullWeightTons before scheduling blocks the train
// (e.g. 90 lets a 3,500T-rated locomotive pull up to 3,590T). Omit/0 = strict cap.
@ApiPropertyOptional({ example: 90 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
overageToleranceTons?: number;
// Allowed deviation above maxTrainLengthMeters before scheduling blocks the train.
@ApiPropertyOptional({ example: 0 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
overageToleranceMeters?: number;
@ApiPropertyOptional({ example: 4200 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))

View File

@@ -39,6 +39,26 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
maxTrainLengthMeters!: number;
/** Allowed deviation above maxPullWeightTons before a train is blocked (e.g. the 37th PW2 wagon in the fertilizer example runs 90T over 3,500T and is still accepted). Null/0 = no tolerance. */
@Column({
name: 'overage_tolerance_tons',
type: 'numeric',
precision: 10,
scale: 3,
nullable: true,
})
overageToleranceTons?: number | null;
/** Allowed deviation above maxTrainLengthMeters before a train is blocked. Null/0 = no tolerance. */
@Column({
name: 'overage_tolerance_meters',
type: 'numeric',
precision: 10,
scale: 3,
nullable: true,
})
overageToleranceMeters?: number | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;

View File

@@ -64,6 +64,8 @@ export class LocomotivesService {
maxPullWeightTons:
dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
overageToleranceTons: dto.overageToleranceTons ?? null,
overageToleranceMeters: dto.overageToleranceMeters ?? null,
powerKw: dto.powerKw ?? null,
tractionForceKn: dto.tractionForceKn ?? null,
maxSpeedKmh: dto.maxSpeedKmh ?? null,

View File

@@ -163,7 +163,7 @@ describe('BookingBatchService — PAID reconcile', () => {
});
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
@@ -181,6 +181,77 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('extendPaymentPhaseForTopUp', () => {
const schedRepo = () => dataSource.getRepository();
it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => {
const soon = new Date(Date.now() + 5_000); // phase almost over
const departure = new Date(Date.now() + 24 * 3_600_000);
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: soon,
scheduledDepartureDate: departure,
});
await service.extendPaymentPhaseForTopUp(scheduleId);
// paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon.
expect(schedRepo().update).toHaveBeenCalledWith(
scheduleId,
expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }),
);
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan(
soon.getTime(),
);
});
it('does not pull the deadline in when the current end is already later', async () => {
const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: far,
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
});
await service.extendPaymentPhaseForTopUp(scheduleId);
expect(schedRepo().update).not.toHaveBeenCalled();
});
it('is a no-op outside the PAYMENT phase', async () => {
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'OPEN',
paymentPhaseEndsAt: null,
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
});
await service.extendPaymentPhaseForTopUp(scheduleId);
expect(schedRepo().update).not.toHaveBeenCalled();
});
it('never extends past departure', async () => {
const departure = new Date(Date.now() + 60_000); // 1 min away
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: new Date(Date.now() + 1_000),
scheduledDepartureDate: departure,
});
await service.extendPaymentPhaseForTopUp(scheduleId);
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual(
departure.getTime(),
);
});
});
describe('fillRouteDay — day-level distribution', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';

View File

@@ -42,7 +42,10 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
} from './wagon-plan.util';
import {
Capacity,
CorridorBudget,
@@ -1014,17 +1017,22 @@ export class BookingBatchService implements OnModuleInit {
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
}
/** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> {
/**
* Fill one schedule from its priority-ordered pool until full. Returns the
* number of commercial units it RESERVED this pass (0 for government-only or
* no-fit passes) so a top-up caller can extend the payment phase only when a
* fresh pay window actually opened.
*/
async fillSchedule(scheduleId: string): Promise<number> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || !this.isFillable(schedule)) return;
if (!schedule || !this.isFillable(schedule)) return 0;
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
);
return;
return 0;
}
const rules = await this.loadGlobalRules();
@@ -1034,13 +1042,14 @@ export class BookingBatchService implements OnModuleInit {
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
if (budget.maxRemaining().wagons <= 0) {
await this.setWindow(scheduleId, "FULL");
return;
return 0;
}
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
const units = this.groupConsolidatedPool(pool);
let armed = false;
let reservedThisPass = 0;
let commercialReserved = 0;
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
// reservations trickle instead of landing in one pass (a reserve() throwing
@@ -1106,6 +1115,7 @@ export class BookingBatchService implements OnModuleInit {
await this.reserve(booking, scheduleId);
if (partner) await this.reserve(partner, scheduleId);
armed = true;
commercialReserved += 1;
}
budget.subtract(need, leg);
reservedThisPass += 1;
@@ -1125,6 +1135,7 @@ export class BookingBatchService implements OnModuleInit {
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
return commercialReserved;
}
/**
@@ -1499,7 +1510,13 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
);
await this.fillSchedule(scheduleId);
const topUpReserved = await this.fillSchedule(scheduleId);
// A top-up opened a fresh pay window for waiting bookings — push the
// schedule's PAYMENT phase out so the window tick's concludeCycle doesn't
// fire before those customers' new deadlines and expire them prematurely.
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(scheduleId);
}
}
}
@@ -1509,7 +1526,10 @@ export class BookingBatchService implements OnModuleInit {
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
await this.settleReserved(scheduleId, true);
await this.fillSchedule(scheduleId);
const topUpReserved = await this.fillSchedule(scheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(scheduleId);
}
void this.triggerWagonAllocation(scheduleId);
}
@@ -1613,8 +1633,12 @@ export class BookingBatchService implements OnModuleInit {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
await this.expire(booking);
if (booking.trainScheduleId)
await this.fillSchedule(booking.trainScheduleId);
if (booking.trainScheduleId) {
const topUpReserved = await this.fillSchedule(booking.trainScheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(booking.trainScheduleId);
}
}
}
// ---- intercity ride-along API ---------------------------------------------
@@ -1671,6 +1695,28 @@ export class BookingBatchService implements OnModuleInit {
* so the engine sets it as it picks the train.
*/
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
// Idempotency guard: a booking already reserved (pay window open) or already
// paid on THIS schedule must never be re-reserved — that would fire a second
// `payNow` and reset its deadline, the "asked to pay again after paying"
// symptom. Read fresh state (the in-memory `booking` may be stale from the
// pooled query). Only bookings not yet committed to this train pass through.
const fresh = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: booking.id } });
if (
fresh &&
fresh.trainScheduleId === scheduleId &&
(fresh.status === "SELECTED_FOR_BATCH" ||
fresh.status === "AWAITING_PAYMENT" ||
fresh.status === "PAID" ||
fresh.paymentStatus === "PAID")
) {
this.logger.debug(
`[BATCH] reserve skipped for ${booking.reference} — already ` +
`${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`,
);
return;
}
const now = new Date();
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
await this.bookingsRepository.update(booking.id, {
@@ -2020,14 +2066,15 @@ export class BookingBatchService implements OnModuleInit {
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
return Math.ceil(booking.wagonsRequired);
}
const fromContainers = (booking.bookingContainers ?? []).reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
return Math.max(
DEFAULT_WAGONS_PER_BOOKING,
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
// booking.wagonsRequired is NULL for most rows (only set on certain
// scheduling paths). Derive from the container lines, TEU-aware: two 20ft
// share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw
// container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and
// wrongly filled the train.
const fromContainers = containerWagonsForLines(
booking.bookingContainers ?? [],
);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
}
/** What one booking consumes along all three capacity axes. */
@@ -2061,6 +2108,8 @@ export class BookingBatchService implements OnModuleInit {
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
},
wagonTypes,
{
@@ -2256,6 +2305,45 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* A top-up reservation (settle freed capacity mid-cycle, so the next waiting
* booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's
* `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run
* concludeCycle — was frozen when the phase started. Without this, concludeCycle
* fires before the top-up customer's deadline and expires a booking that still
* had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment
* window from now, but never past departure. Only while the schedule is still
* in the PAYMENT phase (a reopened cycle manages its own phase).
*/
async extendPaymentPhaseForTopUp(scheduleId: string): Promise<void> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
const windowMs = await this.paymentWindowMs();
let target = new Date(Date.now() + windowMs);
if (
schedule.scheduledDepartureDate &&
target > schedule.scheduledDepartureDate
) {
target = schedule.scheduledDepartureDate;
}
// Only ever push the deadline OUT, never pull it in.
if (
schedule.paymentPhaseEndsAt &&
schedule.paymentPhaseEndsAt.getTime() >= target.getTime()
) {
return;
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { paymentPhaseEndsAt: target });
this.logger.log(
`[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` +
`(top-up reservation opened a fresh pay window)`,
);
}
private removeTimeout(scheduleId: string): void {
const name = this.timeoutName(scheduleId);
try {

View File

@@ -15,7 +15,6 @@ const nw5: WagonType = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,

View File

@@ -4,6 +4,7 @@ import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
containerWagonsForLines,
roundTons,
type WagonPlanSlot,
} from './wagon-plan.util';
@@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
return Math.max(1, Math.ceil(weight / capacity));
}
const lineSlots = (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
0,
);
return Math.max(1, lineSlots);
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
// fraction. Ceiling per line would over-count split 20ft lines.
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
}
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {

View File

@@ -1,6 +1,7 @@
import {
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
} from './train-capacity.util';
describe('train-capacity.util', () => {
@@ -38,4 +39,31 @@ describe('train-capacity.util', () => {
).toBe(28);
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('extends weight/length caps by the locomotive overage tolerance (fertilizer +90T example)', () => {
const pw2 = { lengthMeters: 17.066, capacityTons: 70 };
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
[pw2],
);
expect(derived.maxWeightTons).toBe(3590);
});
it('ignores overage tolerance when unset (strict cap, no behavior change)', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[{ lengthMeters: 14, capacityTons: 70 }],
);
expect(derived.maxWeightTons).toBe(3500);
expect(derived.maxLengthMeters).toBe(760);
});
it('takes the weakest locomotive tolerance across a multi-locomotive set', () => {
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(20);
});
});

View File

@@ -7,6 +7,10 @@ export type WagonTypeDimensions = {
export type LocomotiveLimits = {
maxPullWeightTons: number;
maxTrainLengthMeters: number;
/** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */
overageToleranceTons?: number | null;
/** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */
overageToleranceMeters?: number | null;
};
export type DerivedTrainCapacity = {
@@ -29,14 +33,19 @@ export function deriveTrainCapacityFromLocomotive(
wagonTypes: WagonTypeDimensions[],
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): DerivedTrainCapacity {
const maxWeightTons = Math.min(
Number(locomotive.maxPullWeightTons) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
);
const maxLengthMeters = Math.min(
Number(locomotive.maxTrainLengthMeters) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
);
const overageTons = Number(locomotive.overageToleranceTons) || 0;
const overageMeters = Number(locomotive.overageToleranceMeters) || 0;
const maxWeightTons =
Math.min(
Number(locomotive.maxPullWeightTons) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
) + overageTons;
const maxLengthMeters =
Math.min(
Number(locomotive.maxTrainLengthMeters) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
) + overageMeters;
const types =
wagonTypes.length > 0
@@ -75,7 +84,10 @@ export const MAX_FALLBACK_LENGTH = 760;
* across all assigned locomotives. Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
return {
@@ -85,6 +97,13 @@ export function minLocomotiveLimits(
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
),
// Weakest locomotive's tolerance governs the set, same as its caps.
overageToleranceTons: Math.min(
...locomotives.map((l) => Number(l.overageToleranceTons) || 0),
),
overageToleranceMeters: Math.min(
...locomotives.map((l) => Number(l.overageToleranceMeters) || 0),
),
};
}

View File

@@ -14,7 +14,6 @@ const nw5 = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
@@ -35,7 +34,6 @@ const cw3 = {
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,

View File

@@ -975,13 +975,19 @@ export class TrainSchedulingService {
throw new BadRequestException('Schedule train set has no locomotives');
}
// 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) {
// validator has already surfaced it as a warning in that case. Each
// locomotive's overageToleranceTons/Meters extends the hard cap before that
// override is even needed (e.g. the fertilizer example's +90T deviation).
const weightCapWithOverage =
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
@@ -2916,8 +2922,10 @@ export class TrainSchedulingService {
}
if (
setLimits &&
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
totalWeightTons ||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
totalLengthMeters)
) {
pushLimit([
'Assigned locomotives cannot support the total train weight and length',
@@ -2935,8 +2943,10 @@ export class TrainSchedulingService {
if (
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) >= totalWeightTons &&
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
totalWeightTons &&
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
totalLengthMeters,
)
) {
pushLimit(['No locomotive can support the total train weight and length']);
@@ -2991,7 +3001,10 @@ export class TrainSchedulingService {
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
},
locomotive?: Pick<Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
locomotive?: Pick<
Locomotive,
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
>,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
@@ -3018,6 +3031,8 @@ export class TrainSchedulingService {
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
},
wagonTypes,
{
@@ -3688,10 +3703,17 @@ export class TrainSchedulingService {
if (locomotive.status !== 'AVAILABLE') {
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
if (
Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) <
totalWeightTons
) {
throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
if (
Number(locomotive.maxTrainLengthMeters) +
(Number(locomotive.overageToleranceMeters) || 0) <
totalLengthMeters
) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);

View File

@@ -6,6 +6,7 @@ import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
containerWagonsForLines,
expandBookingContainerUnits,
expandContainerItems,
roundTons,
@@ -20,7 +21,6 @@ const nw5: WagonType = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
@@ -32,7 +32,6 @@ const cw3: WagonType = {
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
@@ -200,3 +199,60 @@ describe('wagon-plan.util', () => {
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
});
});
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
quantity,
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
});
it('20×20ft = 10 wagons (not 20)', () => {
expect(containerWagonsForLines([line(20, 0.5)])).toBe(10);
});
it('38×20ft = 19 wagons', () => {
expect(containerWagonsForLines([line(38, 0.5)])).toBe(19);
});
it('2×20ft = 1 wagon', () => {
expect(containerWagonsForLines([line(2, 0.5)])).toBe(1);
});
it('odd 3×20ft = 2 wagons (single line ceils)', () => {
expect(containerWagonsForLines([line(3, 0.5)])).toBe(2);
});
it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => {
// per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3.
expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3);
});
it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => {
// per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2.
expect(
containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]),
).toBe(2);
});
it('5×20ft + 2×40ft = 5 wagons', () => {
expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5);
});
it('21×40ft = 21 wagons', () => {
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
});
it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => {
// No containerType relation loaded → use the stored (0.5-aware) fraction.
expect(
containerWagonsForLines([
{ quantity: 20, wagonsRequired: 10 } as never,
]),
).toBe(10);
});
it('empty line set = 0 wagons', () => {
expect(containerWagonsForLines([])).toBe(0);
});
});

View File

@@ -89,18 +89,38 @@ export function containersPerWagonFromType(wagonsPerUnit: number): number {
return Math.max(1, Math.round(1 / wpu));
}
function lineWagonsRequired(line: {
type ContainerLine = {
quantity?: number | null;
wagonsRequired?: number | null;
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
}): number {
};
/**
* RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4).
*/
function lineWagonsRaw(line: ContainerLine): number {
const qty = Number(line.quantity ?? 0);
if (qty <= 0) return 0;
const wpu = Number(line.containerType?.wagonsPerUnit);
if (Number.isFinite(wpu) && wpu > 0) {
return Math.ceil(qty * wpu);
return qty * wpu;
}
return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1)));
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else
// treat the whole line as one wagon.
const stored = Number(line.wagonsRequired);
return Number.isFinite(stored) && stored > 0 ? stored : 1;
}
/**
* Whole wagons a set of container lines needs: ceil the summed RAW fraction so a
* half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0.
*/
export function containerWagonsForLines(lines: ContainerLine[]): number {
const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0);
return raw > 0 ? Math.ceil(raw) : 0;
}
/**
@@ -110,15 +130,16 @@ export function buildContainerWagonPlan(
bookings: Booking[],
wagonType: WagonType,
): WagonPlanSlot[] {
// Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit
// can share a wagon with another 20ft of the SAME booking, never across
// bookings), then sum. Ceiling per line instead would over-count a booking
// that splits its 20ft units across several lines.
const totalSlots = bookings.reduce((sum, booking) => {
const lineSlots = (booking.bookingContainers ?? []).reduce(
(lineSum, line) => lineSum + lineWagonsRequired(line),
0,
);
return sum + Math.max(lineSlots, 1);
const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []);
return sum + Math.max(bookingSlots, 1);
}, 0);
const slots = Math.max(1, Math.ceil(totalSlots));
const slots = Math.max(1, totalSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
wagonTypeId: wagonType.id,
@@ -424,7 +445,7 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string
export function validateTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonType: WagonType,
wagonType: Pick<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig,
): string[] {
const violations: string[] = [];
@@ -478,7 +499,7 @@ export function validateMixedTrainLimits(
return validateTrainLimits(
wagonPlan,
{ maxWagonsPerTrain } as WagonType,
{ lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain },
);
}

View File

@@ -3,7 +3,6 @@ import { Transform } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
@@ -14,9 +13,6 @@ import {
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
const toOptionalNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
@@ -60,13 +56,6 @@ export class CreateWagonTypeDto {
@Min(0.001)
lengthMeters!: number;
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
@IsOptional()
@Transform(toOptionalNumber)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
@ApiPropertyOptional({
description: 'Supported load types, e.g. CONTAINER,BULK',
type: [String],

View File

@@ -19,9 +19,6 @@ export class WagonType extends BaseEntity {
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
lengthMeters!: number;
@Column({ name: 'max_wagons_per_train', type: 'int', nullable: true })
maxWagonsPerTrain?: number | null;
@Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' })
supportedLoadTypes!: string[];

View File

@@ -80,7 +80,6 @@ export class WagonTypesService {
name: dto.name.trim(),
capacityTons: dto.capacityTons,
lengthMeters: dto.lengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? [],
isActive: dto.isActive ?? true,
});
@@ -101,8 +100,6 @@ export class WagonTypesService {
...dto,
...(nextCode ? { code: nextCode } : {}),
...(dto.name ? { name: dto.name.trim() } : {}),
maxWagonsPerTrain:
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
});