mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,6 @@ const nw5: WagonType = {
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user