enhance shipment form and booking process

This commit is contained in:
Marshal
2026-07-22 17:25:01 +00:00
parent ef840241c0
commit 5709801590
26 changed files with 1413 additions and 330 deletions

View File

@@ -24,9 +24,9 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
@@ -60,11 +60,14 @@ import {
DEFAULT_WAGONS_PER_BOOKING,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
trainHardCaps,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
@@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
@@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
target.scheduleId,
);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) return false;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
@@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit {
}
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) {
throw new ConflictException(
"Export train is no longer available for reservation",
@@ -1351,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit {
};
});
const loco = s.trainSet?.locomotive ?? null;
const loco = trainSetLocomotiveLimits(s.trainSet);
// The board renders ONE booking window — the schedule's own frozen window
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
@@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit {
trainName: s.trainSet.train.trainName ?? null,
}
: null,
// Identity from the primary (legacy) locomotive; limit figures from the
// whole set's effective minimum — what the fill engine actually spends.
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
code: s.trainSet?.locomotive?.code ?? '',
name: s.trainSet?.locomotive?.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
@@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit {
weightTons: number;
lengthMeters: number;
}>,
loco: Locomotive | null,
loco: LocomotiveLimits | null,
maxWagons: number | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
@@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
s: TrainSchedule,
items: BatchBoardBooking[],
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
const loco = trainSetLocomotiveLimits(s.trainSet);
return {
scheduleId: s.id,
@@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit {
trainName: s.trainSet.train.trainName ?? null,
}
: null,
// Identity from the primary (legacy) locomotive; limit figures from the
// whole set's effective minimum — what the fill engine actually spends.
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
code: s.trainSet?.locomotive?.code ?? '',
name: s.trainSet?.locomotive?.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
@@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || !this.isFillable(schedule)) return 0;
const locomotive = schedule.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
@@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit {
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
@@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit {
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
@@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const totalContainers = containers(primary) + containers(partner);
const cargoTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);
// Consolidation shares TEU slots, never rated payload: the pair still needs
// enough wagons to carry its combined cargo, so the weight axis bounds the
@@ -3222,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit {
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
const cargoTons = bookingCargoTons(booking);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
@@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit {
return {
wagons,
weightTons: bookingGrossWeightTons(
Number(booking.cargoTotalWeightVgm ?? 0),
bookingCargoTons(booking),
wagons,
dims.tareWeightTons,
),
@@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit {
* caps deliberately do not apply here (a mis-set global row once capped
* every train at 14m and no export booking could board).
*/
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
@@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit {
*/
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
locomotive: LocomotiveLimits,
): Promise<void> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
const maxWagons =
@@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* Built train: FULL when every physical wagon slot is taken — the consist is
* the capacity, weight/length were settled at build time.
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
* pull weight / train length for even one more loaded wagon. The old
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
* could board.
* FULL is DIRECTIONAL: the schedule's trade direction is full when the
* border-crossing edge (which every export/import must ride) can't take one
* more minimal wagon on any axis — slots for built trains (the consist is
* the capacity, weight/length settled at build), all three axes otherwise
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
* Home-side legs may still run empty; intercity ride-alongs keep filling
* them via the per-leg budget and never consult this flag. Domestic routes
* (no border) are full only when every edge is closed.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
@@ -3663,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit {
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
// Built train: the physical consist is the only capacity axis, and a wagon
// is committed to its booking for the WHOLE trip — wagon allocation has no
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
// the Doraleh→Negad edge it merely passes through. Count commitments
// train-wide, not per corridor edge: the per-edge budget read "free slots"
// on pass-through legs of a sold-out consist, so the window of a full train
// cycled OPEN forever instead of concluding DONE (and the day pool's
// leftover bookings were never expired).
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
return (await this.committedWagons(schedule)) >= physicalWagons;
}
if ((await this.remainingWagons(schedule)) <= 0) return true;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
// "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
// every import must cross the ET↔DJ border edge, so once that edge can't
// take one more minimal wagon the booking window may close — even while
// home-side legs still run empty. Intercity ride-alongs never consult this
// flag; they keep booking the free legs through the per-leg budget.
// A single-country (domestic) corridor has no mandatory edge, so it is
// full only when EVERY edge is closed on some axis.
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const physicalWagons = await this.builtTrainWagonCount(schedule);
let limits: TrainLimits;
if (physicalWagons != null) {
// The consist is the capacity; weight/length were settled at build time.
// remainingBudget swaps in the physical wagon count per edge itself.
limits = {
base: {
wagons: physicalWagons,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
};
} else {
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
// No loco, no built train: only the slot axis exists to bind against.
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
limits = await this.capacityLimits(locomotive);
}
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
const minNeed = this.minPerWagonNeed(wagonDims);
const border = await this.borderLeg(budget.stops);
if (border) {
return !budget.fits(
{
wagons: 1,
weightTons: minNeed.grossWeightTons,
lengthMeters: minNeed.lengthMeters,
},
border,
);
}
return budget.isExhausted(minNeed);
}
/**
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
* regardless of which corridor leg each rides. Deduped by booking id — a
* booking mid-settle can momentarily be both linked and reserved.
* The corridor's single border-crossing edge (last home-country stop → first
* far-country stop), or null when every stop is in one country. This is the
* edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
* books — which makes it the train's directional fullness gauge.
*/
private async committedWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
const byId = new Map(
[...allocated, ...reserved].map((b) => [b.id, b] as const),
);
let total = 0;
for (const booking of byId.values()) {
total += this.wagonsFor(booking, wagonDims);
}
return total;
private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
if (stops.length < 2) return null;
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: { id: In(stops) } });
const countryOf = new Map(yards.map((y) => [y.id, y.country]));
const first = countryOf.get(stops[0]);
if (!first) return null;
const crossIdx = stops.findIndex((id) => {
const country = countryOf.get(id);
return country != null && country !== first;
});
if (crossIdx <= 0) return null;
return { fromEdge: crossIdx - 1, toEdge: crossIdx };
}
/**