add lashing surcharge for cargo types with hasLashing flag

add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
Marshal
2026-07-17 23:25:53 +00:00
parent 6467173c76
commit 3a1a08b1e1
59 changed files with 1919 additions and 140 deletions

View File

@@ -109,12 +109,13 @@ import {
type FleetAvailabilityRow,
} from './fleet-plan.util';
import {
applyWagonOrderReversal,
planWagonsWithStock,
unboundedStock,
type AllowedWagonTypeMap,
type WagonStock,
} from './wagon-plan-flex.util';
import {
containerWagonsForLines,
expandBookingContainerUnits,
getContainerSlotSequenceNos,
roundTons,
@@ -134,8 +135,10 @@ import {
WagonTypeDimensions,
} from './train-capacity.util';
import {
DEFAULT_BULK_WAGON_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
} from './booking-batch.constants';
@@ -180,6 +183,8 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null,
ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null,
};
}
@@ -204,6 +209,8 @@ export function effectiveWindowConfig(
ruleReopenDelayMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
ruleImportCloseOffsetMinutes?: number | null;
ruleExportCloseOffsetMinutes?: number | null;
},
liveCfg: BookingWindowConfig,
): BookingWindowConfig {
@@ -220,6 +227,18 @@ export function effectiveWindowConfig(
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
// The close offset is frozen per-schedule: a snapshot value of null means
// "created with no offset" and must NOT inherit a later live offset (that
// would retro-shrink an open train's window). Only a truly legacy row that
// predates the snapshot column (value undefined) falls back to live config.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
}
@@ -588,7 +607,11 @@ export class TrainSchedulingService {
trainScheduleId: query.trainScheduleId,
day,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
const tareDims = await this.loadWagonTareDims();
return {
count: bookings.length,
items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)),
};
}
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
@@ -633,6 +656,11 @@ export class TrainSchedulingService {
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
// Store 0 as null so "no offset" is a single canonical value.
if (dto.importCloseOffsetMinutes !== undefined)
row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
if (dto.exportCloseOffsetMinutes !== undefined)
row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
@@ -649,7 +677,9 @@ export class TrainSchedulingService {
dto.windowDurationHours != null ||
dto.docReviewMinutes != null ||
dto.paymentWindowMinutes != null ||
dto.exportBookingLeadHours != null;
dto.exportBookingLeadHours != null ||
dto.importCloseOffsetMinutes !== undefined ||
dto.exportCloseOffsetMinutes !== undefined;
const saved = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
@@ -721,6 +751,17 @@ export class TrainSchedulingService {
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
// A per-schedule override isn't a close-offset control, so inherit the
// offset already frozen on the schedule (null = none), or the live one for
// legacy rows — the override must not silently drop the global offset.
importCloseOffsetMinutes:
schedule.ruleImportCloseOffsetMinutes !== undefined
? schedule.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
schedule.ruleExportCloseOffsetMinutes !== undefined
? schedule.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
@@ -1054,6 +1095,13 @@ export class TrainSchedulingService {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
// Offsets are optional: a missing/unset value means "no offset", not a
// numeric default — keep it null so bookingCloseCutoff falls back to
// departure. Zero and negatives are treated as "no offset" too.
const offset = (v: unknown): number | null => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) && n > 0 ? n : null;
};
return {
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
@@ -1062,6 +1110,8 @@ export class TrainSchedulingService {
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
};
}
@@ -1322,6 +1372,7 @@ export class TrainSchedulingService {
direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons,
reverseWagonOrder: dto.reverseWagonOrder ?? false,
...windowFields,
}),
);
@@ -1400,6 +1451,10 @@ export class TrainSchedulingService {
maxTrainWeightTons: dto.maxTrainWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain,
// The reverse-order choice is a property of the SCHEDULE, frozen when it was
// created — every (re)assignment rebuilds the plan under the same flag so the
// stored train order stays consistent no matter how bookings are added.
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
@@ -1476,6 +1531,31 @@ export class TrainSchedulingService {
});
}
// Every REQUESTED booking must have made the plan. Silently dropping a
// deferred one let the workspace "Add from pool" report success while the
// booking never boarded (e.g. it needs a PW2 wagon and the train only has
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
// A stock shortage is a physical impossibility, so forceAssign cannot
// override it either.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id));
if (droppedRequested.length) {
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const details = droppedRequested.map(
(id) =>
reasonById.get(id) ??
`${id}: does not fit the train's wagon stock or capacity`,
);
throw new BadRequestException({
message: `Cannot allocate — ${details.join('; ')}`,
violations: details,
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
@@ -1817,13 +1897,15 @@ export class TrainSchedulingService {
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
const tareDims = await this.loadWagonTareDims();
const items = bookings
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
.map((b) => ({
id: b.id,
reference: b.reference ?? null,
customer: b.company?.name ?? null,
weightTons: b.cargoTotalWeightVgm,
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(b, tareDims),
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
}));
return { count: items.length, items };
@@ -3668,13 +3750,6 @@ export class TrainSchedulingService {
const allowed = await this.loadAllowedWagonTypes(bookings);
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
// Pure demand (unbounded stock) drives the availability report rows.
const demandPlan = planWagonsWithStock({
bookings,
allowed,
stock: unboundedStock(allowed),
}).plan;
const originYardId = dto.originStationId;
let stock: WagonStock;
if (builtTrainId) {
@@ -3711,10 +3786,24 @@ export class TrainSchedulingService {
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
const deferredBookings: DeferredBookingRow[] = planned.deferred;
const wagonPlan = planned.plan;
// Opt-in wagon-order reversal: flip the built plan's order (physically-last
// wagon → position 1) BEFORE legs are stamped and the plan is persisted, so
// the stored train order, allocations and snapshot all carry the reversed
// order together. No-op unless the schedule set the flag.
const wagonPlan = applyWagonOrderReversal(
planned.plan,
(dto as { reverseWagonOrder?: boolean }).reverseWagonOrder,
);
// Availability rows come from the BOUNDED plan — the one that actually
// mixes wagon types against real stock. The old unbounded "pure demand"
// plan had infinite stock of every allowed type, so its tie-break parked a
// booking's ENTIRE need on one arbitrary type and produced false "Fleet
// shortage: need 30 PW2" warnings for bookings the real plan fits fine by
// mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through
// the deferred bookings' own shortage rows.
const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability(
demandPlan,
planned.plan,
stock.remainingByTypeId,
stock.codesByTypeId,
);
@@ -4805,7 +4894,10 @@ export class TrainSchedulingService {
);
}
private mapEligibleBooking(booking: Booking) {
private mapEligibleBooking(
booking: Booking,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
) {
return {
id: booking.id,
reference: booking.reference,
@@ -4819,7 +4911,8 @@ export class TrainSchedulingService {
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
quantity:
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
weightTons: roundTons(booking.cargoTotalWeightVgm),
// GROSS: cargo + tare of the wagons the booking occupies.
weightTons: this.grossBookingWeightTons(booking, tareDims),
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
@@ -6110,6 +6203,85 @@ export class TrainSchedulingService {
}
}
/**
* Per-wagon tare/payload for every wagon type, keyed by id, with the batch
* engine's representative fallbacks for bookings whose cargo/container type
* has no wagon type configured. Loaded once per request before mapping.
*/
private async loadWagonTareDims(): Promise<{
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
bulk: { tareWeightTons: number; capacityTons: number };
container: { tareWeightTons: number; capacityTons: number };
}> {
const types = await this.dataSource.getRepository(WagonType).find();
const byWagonTypeId = new Map(
types.map((t) => [
t.id,
{
tareWeightTons: Number(t.tareWeightTons) || 0,
capacityTons: Number(t.capacityTons) || 0,
},
]),
);
return {
byWagonTypeId,
bulk: {
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS,
},
container: {
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
},
};
}
/**
* Booking weight as the train actually hauls it: cargo VGM plus the tare of
* every wagon the booking occupies — the same gross axis the batch engine
* spends against the locomotive's pull limit. Wagon count mirrors the batch
* engine's sizing (stored wagonsRequired, TEU geometry for containers,
* tons ÷ payload for bulk — whichever is largest).
*/
private grossBookingWeightTons(
booking: Pick<
Booking,
| 'freightType'
| 'cargoTotalWeightVgm'
| 'wagonsRequired'
| 'bookingContainers'
| 'cargoType'
>,
tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
): number {
const cargo = Number(booking.cargoTotalWeightVgm ?? 0);
const fallback =
booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
// Same first-configured-type resolution the batch engine's dimsFor uses.
const wagonTypeId =
booking.freightType === 'BULK'
? booking.cargoType?.wagonTypes?.[0]?.id
: (booking.bookingContainers ?? [])
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wagonType) => wagonType.id)
.find((id): id is string => Boolean(id));
const typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined;
const dims = {
tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons,
capacityTons: typed?.capacityTons || fallback.capacityTons,
};
const stored =
booking.wagonsRequired && booking.wagonsRequired > 0
? Math.ceil(booking.wagonsRequired)
: 0;
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const byWeight =
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
const wagons = Math.max(1, stored, byLength, byWeight);
return roundTons(cargo + wagons * dims.tareWeightTons);
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
@@ -6118,6 +6290,9 @@ export class TrainSchedulingService {
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
// locomotive actually hauls and the axis its pull limit is compared against.
const tareDims = await this.loadWagonTareDims();
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
@@ -6406,7 +6581,9 @@ export class TrainSchedulingService {
id: sb.booking?.id ?? sb.bookingId,
reference: sb.booking?.reference ?? null,
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
weightTons: sb.booking
? this.grossBookingWeightTons(sb.booking, tareDims)
: 0,
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,