mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
changes
This commit is contained in:
@@ -1391,8 +1391,6 @@ export class TrainSchedulingService {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainSetId = schedule.trainSetId;
|
||||
|
||||
await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
|
||||
|
||||
const deletedAllocationIds =
|
||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
|
||||
|
||||
@@ -1528,7 +1526,6 @@ export class TrainSchedulingService {
|
||||
(sb) => sb.bookingId !== bookingId,
|
||||
);
|
||||
if (remainingBookings.length === 0) {
|
||||
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
|
||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
||||
schedule.trainSetId,
|
||||
manager,
|
||||
@@ -1768,7 +1765,18 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
|
||||
}
|
||||
|
||||
const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const slotIds = new Set(slots.map((w) => w.id));
|
||||
const slotById = new Map(slots.map((w) => [w.id, w]));
|
||||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
|
||||
// Occupancy is judged against THIS schedule's own slots only — a wagon
|
||||
// pinned on another schedule (e.g. the same train's July 17 run) stays
|
||||
// pinnable here.
|
||||
const slotIdByPhysicalId = new Map(
|
||||
slots
|
||||
.filter((w) => w.physicalWagonId)
|
||||
.map((w) => [w.physicalWagonId as string, w.id]),
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const assignment of dto.assignments) {
|
||||
@@ -1784,29 +1792,61 @@ export class TrainSchedulingService {
|
||||
if (!physicalWagon) {
|
||||
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
|
||||
}
|
||||
if (
|
||||
physicalWagon.status !== WagonStatus.Available &&
|
||||
physicalWagon.currentTrainScheduleId !== scheduleId
|
||||
) {
|
||||
const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
|
||||
if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
|
||||
const occupyingSlot = slotById.get(occupyingSlotId);
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is not available`,
|
||||
`Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
|
||||
);
|
||||
}
|
||||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||||
);
|
||||
if (builtTrainId) {
|
||||
// Train-bound schedule: only the built train's own consist may be
|
||||
// pinned — wherever the wagons currently sit, they travel with the
|
||||
// train, so no yard/status gate applies.
|
||||
if (physicalWagon.trainId !== builtTrainId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (physicalWagon.trainId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
|
||||
);
|
||||
}
|
||||
if (!this.isWagonPhysicallyUsable(physicalWagon)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
physicalWagon.currentTrainScheduleId &&
|
||||
physicalWagon.currentTrainScheduleId !== scheduleId
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
|
||||
);
|
||||
}
|
||||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
|
||||
// its status untouched so other schedules can still use the wagon.
|
||||
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
||||
physicalWagonId: assignment.physicalWagonId,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
|
||||
trainSetWagonId: assignment.trainSetWagonId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
for (const [physicalId, slotId] of slotIdByPhysicalId) {
|
||||
if (slotId === assignment.trainSetWagonId) {
|
||||
slotIdByPhysicalId.delete(physicalId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1860,6 +1900,22 @@ export class TrainSchedulingService {
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
||||
// Same rule for wagons: many schedules may pin the same wagon, but it can
|
||||
// only be OUT on one dispatched train at a time.
|
||||
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (pinnedPhysicalIds.length) {
|
||||
const rolling = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
|
||||
});
|
||||
const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
|
||||
if (busy.length) {
|
||||
throw new ConflictException(
|
||||
`Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -3735,10 +3791,11 @@ export class TrainSchedulingService {
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||
const [wagons, wagonTypes, builtTrainId] = await Promise.all([
|
||||
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find(),
|
||||
this.dataSource.getRepository(WagonType).find(),
|
||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||
]);
|
||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||
const counts = new Map<string, { code: string; available: number }>();
|
||||
@@ -3750,10 +3807,20 @@ export class TrainSchedulingService {
|
||||
if (builtTrainId) {
|
||||
if (wagon.trainId !== builtTrainId) continue;
|
||||
} else {
|
||||
const pinnedOnTarget = targetScheduleId
|
||||
? wagon.currentTrainScheduleId === targetScheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
|
||||
// Schedule-scoped availability: pins held by OTHER schedules never
|
||||
// consume a wagon here — the same physical wagon may serve the July 17
|
||||
// and the July 20 run. A wagon is unusable only when it is coupled to a
|
||||
// built train's consist, physically blocked, or out on a dispatched
|
||||
// train right now.
|
||||
const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
|
||||
if (wagon.trainId) continue;
|
||||
if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
|
||||
if (
|
||||
wagon.currentTrainScheduleId &&
|
||||
wagon.currentTrainScheduleId !== targetScheduleId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (wagon.currentYardId !== originYardId) continue;
|
||||
}
|
||||
|
||||
@@ -3812,22 +3879,59 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
||||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
||||
const physicalIds = slots
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (!physicalIds.length) return;
|
||||
const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } });
|
||||
for (const wagon of wagons) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
// Built-train wagons stay coupled to their train (ASSIGNED); loose
|
||||
// wagons return to the open AVAILABLE pool.
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
trainSetWagonId: null,
|
||||
currentTrainScheduleId: null,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* A wagon in a blocked physical state can never be planned or pinned.
|
||||
* ASSIGNED no longer blocks: it only means the wagon is coupled to a built
|
||||
* train or stamped by a live run — schedule-level occupancy is tracked on
|
||||
* the schedule's own TrainSetWagon slots, never on the Wagon entity.
|
||||
*/
|
||||
private isWagonPhysicallyUsable(wagon: Wagon): boolean {
|
||||
return (
|
||||
wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical wagons already pinned to THIS schedule's slots. Availability is
|
||||
* schedule-scoped: only a duplicate pin within the same schedule conflicts;
|
||||
* pins held by other schedules of the same train are irrelevant.
|
||||
*/
|
||||
private async pinnedPhysicalWagonIdsForSchedule(
|
||||
scheduleId: string | undefined,
|
||||
manager?: EntityManager,
|
||||
): Promise<Set<string>> {
|
||||
if (!scheduleId) return new Set();
|
||||
const runner = manager ?? this.dataSource;
|
||||
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||||
`SELECT tsw.physical_wagon_id
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE ts.id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND tsw.physical_wagon_id IS NOT NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
return new Set(rows.map((row) => row.physical_wagon_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
|
||||
* schedule. Used to guard consist trims — the Wagon entity itself carries no
|
||||
* schedule-occupancy state anymore.
|
||||
*/
|
||||
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
|
||||
const runner = manager ?? this.dataSource;
|
||||
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||||
`SELECT DISTINCT tsw.physical_wagon_id
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND tsw.physical_wagon_id IS NOT NULL`,
|
||||
);
|
||||
return new Set(rows.map((row) => row.physical_wagon_id));
|
||||
}
|
||||
|
||||
private async autoPinWagonsForSchedule(
|
||||
@@ -3839,6 +3943,10 @@ export class TrainSchedulingService {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
||||
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
|
||||
scheduleId,
|
||||
manager,
|
||||
);
|
||||
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
||||
|
||||
const planSlots = [...slots]
|
||||
@@ -3857,6 +3965,7 @@ export class TrainSchedulingService {
|
||||
scheduleId,
|
||||
originYardId,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
@@ -3874,18 +3983,17 @@ export class TrainSchedulingService {
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
// Pin lives ONLY on the schedule's own slot — the Wagon entity is never
|
||||
// touched here, so the same physical wagon stays free for every other
|
||||
// schedule (it gets stamped at dispatch, when it physically leaves).
|
||||
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
||||
physicalWagonId: physical.id,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await manager.getRepository(Wagon).update(physical.id, {
|
||||
trainSetWagonId: slot.trainSetWagonId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
assignedPhysicalIds.add(physical.id);
|
||||
}
|
||||
}
|
||||
@@ -3898,9 +4006,10 @@ export class TrainSchedulingService {
|
||||
): Promise<string[]> {
|
||||
if (!wagonPlan.length) return [];
|
||||
|
||||
const [wagons, builtTrainId] = await Promise.all([
|
||||
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find(),
|
||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||
]);
|
||||
return this.findUnpinnableWagonSlots(
|
||||
wagonPlan.map((slot) => ({
|
||||
@@ -3913,6 +4022,7 @@ export class TrainSchedulingService {
|
||||
targetScheduleId,
|
||||
originYardId,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3927,6 +4037,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
@@ -3939,6 +4050,7 @@ export class TrainSchedulingService {
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
@@ -3964,14 +4076,22 @@ export class TrainSchedulingService {
|
||||
originYardId: string,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
): Wagon | undefined {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = scheduleId
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
||||
// Loose pool never lends a wagon coupled to a built train's consist.
|
||||
if (wagon.trainId) return false;
|
||||
// Out on a dispatched train right now — physically gone.
|
||||
if (
|
||||
wagon.currentTrainScheduleId &&
|
||||
wagon.currentTrainScheduleId !== scheduleId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
|
||||
return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
|
||||
};
|
||||
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
|
||||
// wherever they currently sit (they travel with the train), never a loose
|
||||
@@ -4746,6 +4866,7 @@ export class TrainSchedulingService {
|
||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||
.map((slot) => slot.physicalWagonId as string),
|
||||
);
|
||||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
|
||||
|
||||
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||||
@@ -4802,8 +4923,8 @@ export class TrainSchedulingService {
|
||||
wagons: wagons.map((wagon) => ({
|
||||
...mapWagon(wagon),
|
||||
loaded: loadedWagonIds.has(wagon.id),
|
||||
// Free = not pinned to any run; only free wagons can be trimmed.
|
||||
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
|
||||
// Free = not pinned to any live run's slot; only free wagons can be trimmed.
|
||||
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
|
||||
})),
|
||||
addableWagons: addableWagons.map(mapWagon),
|
||||
adjustments: adjustments.map((log) => ({
|
||||
@@ -4882,13 +5003,14 @@ export class TrainSchedulingService {
|
||||
const consistById = new Map(consist.map((w) => [w.id, w]));
|
||||
|
||||
// --- validate removals: must be coupled and free (no cargo, no pin) ---
|
||||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
|
||||
const removed: Wagon[] = [];
|
||||
for (const wagonId of removeWagonIds) {
|
||||
const wagon = consistById.get(wagonId);
|
||||
if (!wagon) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||||
}
|
||||
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
|
||||
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
|
||||
);
|
||||
@@ -5369,10 +5491,10 @@ export class TrainSchedulingService {
|
||||
/**
|
||||
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
|
||||
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
||||
* train capacity (not fully allocated). Wagon availability is deliberately NOT
|
||||
* checked here: whether a matching wagon currently sits in the right yard is an
|
||||
* operational question staff resolve when they approve or reject the booking,
|
||||
* not something the customer can act on while choosing a date. Same
|
||||
* train capacity (not fully allocated) AND its wagon stock can physically carry
|
||||
* the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
|
||||
* NOT gated — a booking bigger than the free capacity is accepted and the batch
|
||||
* engine offers a partial split later. No counts are exposed: same
|
||||
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||||
* not a train.
|
||||
*/
|
||||
@@ -5380,9 +5502,11 @@ export class TrainSchedulingService {
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
totalWeightTons?: number;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
containerTypeIds?: string[];
|
||||
}): Promise<{ days: string[] }> {
|
||||
const schedules = await this.getBookableScheduleEntities(
|
||||
input.originYardId,
|
||||
@@ -5390,17 +5514,233 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (schedules.length === 0) return { days: [] };
|
||||
|
||||
const withCapacity = schedules.filter(
|
||||
(s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
|
||||
);
|
||||
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
for (const s of compatible) {
|
||||
if (s.scheduledDepartureDate)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon-TYPE compatibility gate (customer booking): keep only the schedules
|
||||
* whose wagon stock can physically carry the selected cargo — every container
|
||||
* line (or the bulk cargo type) must map to at least one wagon type the
|
||||
* schedule's stock actually has. Stock = the built train's own consist, or the
|
||||
* origin yard's loose pool for schedules assembled from loose locomotives.
|
||||
* QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
|
||||
* a partial split offer from the batch engine later.
|
||||
*/
|
||||
private async filterCargoCompatibleSchedules(
|
||||
schedules: TrainSchedule[],
|
||||
cargo: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
containerTypeIds?: string[];
|
||||
},
|
||||
): Promise<TrainSchedule[]> {
|
||||
if (!schedules.length) return schedules;
|
||||
const required = await this.requiredWagonTypeSets(cargo);
|
||||
// No cargo identity supplied — nothing to gate on (legacy callers).
|
||||
if (required === null) return schedules;
|
||||
|
||||
const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
|
||||
return schedules.filter((s) => {
|
||||
const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
|
||||
return required.every((set) => {
|
||||
for (const typeId of set) if (stock.has(typeId)) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One Set of allowed wagon-type ids per required cargo dimension: per
|
||||
* container line's type (or per container size when only sizes are known),
|
||||
* or a single set for the bulk cargo type. `null` = no cargo identity given,
|
||||
* skip gating. An EMPTY set means "nothing can carry this" (no wagon types
|
||||
* configured) — the gate then blocks every schedule, mirroring the hard
|
||||
* config violation scheduling raises for the same state.
|
||||
*/
|
||||
private async requiredWagonTypeSets(cargo: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
containerTypeIds?: string[];
|
||||
}): Promise<Set<string>[] | null> {
|
||||
if (cargo.freightType === 'CONTAINER') {
|
||||
const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
|
||||
if (typeIds.length) {
|
||||
const rows: { container_type_id: string; wagon_type_id: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
|
||||
FROM freight.container_types ct
|
||||
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||||
LEFT JOIN freight.wagon_types wt
|
||||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||
WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
|
||||
[typeIds],
|
||||
);
|
||||
const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
|
||||
for (const row of rows) {
|
||||
if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
|
||||
}
|
||||
return [...byType.values()];
|
||||
}
|
||||
// Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
|
||||
// ANY active container type of that size has a matching wagon type.
|
||||
const sizes = [
|
||||
...new Set(
|
||||
(cargo.containers ?? [])
|
||||
.map((line) => parseInt(String(line.containerSize), 10))
|
||||
.filter((n) => Number.isFinite(n) && n > 0),
|
||||
),
|
||||
];
|
||||
if (!sizes.length) return null;
|
||||
const rows: { size_ft: number; wagon_type_id: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ct.size_ft, wt.id AS wagon_type_id
|
||||
FROM freight.container_types ct
|
||||
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||||
LEFT JOIN freight.wagon_types wt
|
||||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||
WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
|
||||
AND (ct.is_active IS DISTINCT FROM false)`,
|
||||
[sizes],
|
||||
);
|
||||
const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
|
||||
for (const row of rows) {
|
||||
if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
|
||||
}
|
||||
return [...bySize.values()];
|
||||
}
|
||||
|
||||
if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
|
||||
const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
|
||||
`SELECT wt.id AS wagon_type_id
|
||||
FROM freight.cargo_types c
|
||||
LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
|
||||
LEFT JOIN freight.wagon_types wt
|
||||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
|
||||
[cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
|
||||
);
|
||||
const set = new Set<string>();
|
||||
for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
|
||||
return [set];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon-type ids each schedule's stock can offer: the built train's own
|
||||
* consist for train-bound schedules, the origin yard's loose usable pool
|
||||
* otherwise. Batched — two queries for the whole schedule list.
|
||||
*/
|
||||
private async scheduleWagonTypeStock(
|
||||
schedules: TrainSchedule[],
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
const builtTrainIds = [
|
||||
...new Set(
|
||||
schedules
|
||||
.map((s) => s.trainSet?.trainId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const looseOriginYardIds = [
|
||||
...new Set(
|
||||
schedules
|
||||
.filter((s) => !s.trainSet?.trainId)
|
||||
.map((s) => s.originStationId)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
|
||||
const [trainRows, yardRows] = await Promise.all([
|
||||
builtTrainIds.length
|
||||
? (this.dataSource.query(
|
||||
`SELECT train_id, wagon_type_id
|
||||
FROM freight.wagons
|
||||
WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||||
GROUP BY train_id, wagon_type_id`,
|
||||
[builtTrainIds],
|
||||
) as Promise<{ train_id: string; wagon_type_id: string }[]>)
|
||||
: Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
|
||||
looseOriginYardIds.length
|
||||
? (this.dataSource.query(
|
||||
`SELECT current_yard_id, wagon_type_id
|
||||
FROM freight.wagons
|
||||
WHERE train_id IS NULL AND deleted_at IS NULL
|
||||
AND status IN ('AVAILABLE', 'ASSIGNED')
|
||||
AND current_yard_id = ANY($1::uuid[])
|
||||
GROUP BY current_yard_id, wagon_type_id`,
|
||||
[looseOriginYardIds],
|
||||
) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
|
||||
: Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
|
||||
]);
|
||||
|
||||
const byTrain = new Map<string, Set<string>>();
|
||||
for (const row of trainRows) {
|
||||
const set = byTrain.get(row.train_id) ?? new Set<string>();
|
||||
set.add(row.wagon_type_id);
|
||||
byTrain.set(row.train_id, set);
|
||||
}
|
||||
const byYard = new Map<string, Set<string>>();
|
||||
for (const row of yardRows) {
|
||||
const set = byYard.get(row.current_yard_id) ?? new Set<string>();
|
||||
set.add(row.wagon_type_id);
|
||||
byYard.set(row.current_yard_id, set);
|
||||
}
|
||||
|
||||
const result = new Map<string, Set<string>>();
|
||||
for (const s of schedules) {
|
||||
const trainId = s.trainSet?.trainId;
|
||||
result.set(
|
||||
s.id,
|
||||
trainId
|
||||
? byTrain.get(trainId) ?? new Set()
|
||||
: byYard.get(s.originStationId) ?? new Set(),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-time gate for a chosen day: does the route have an OPEN departure
|
||||
* that day at all, and can any of that day's departures physically carry the
|
||||
* cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
|
||||
* partial split offer instead).
|
||||
*/
|
||||
async checkDayCargoCompatibility(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
cargo: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
containerTypeIds?: string[];
|
||||
},
|
||||
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||||
const schedules = await this.getBookableScheduleEntities(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const onDay = schedules.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
|
||||
);
|
||||
if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
|
||||
const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
|
||||
return { hasDeparture: true, hasCompatible: compatible.length > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||
@@ -5553,6 +5893,7 @@ export class TrainSchedulingService {
|
||||
status: schedule.status,
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
maxWagons: schedule.maxWagons ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
requiresLoadingConfirmation,
|
||||
loadingConfirmed,
|
||||
|
||||
Reference in New Issue
Block a user