mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
add lashing surcharge for cargo types with hasLashing flag
add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user