export flow and fix intercity issue

This commit is contained in:
Marshal
2026-07-30 13:15:56 +00:00
parent e80aac877f
commit 2cdf8bd161
2 changed files with 105 additions and 19 deletions

View File

@@ -1088,6 +1088,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
* the whole (route, day) pool rather than bookings pre-targeted to one train.
*/
day?: string;
/**
* The schedule's ordered route stops. When given, the corridor filter
* replaces the exact origin/destination match: any booking whose BOTH yards
* lie on the route qualifies (sub-corridor bookings like Dire→DCT on a
* GMT→Dire→DCT train — the caller still checks stop ORDER). Dateless
* DOMESTIC (intercity) bookings also join the pool: they ride any train on
* their corridor.
*/
corridorYardIds?: string[];
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
@@ -1111,8 +1120,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
// booking that still carries train_schedule_id).
if (options.day) {
// Dateless DOMESTIC (intercity) bookings ride any train on their corridor
// — no scheduled_date to match, so the day filter must not hide them.
qb.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
`(DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day
OR (booking.trade_direction = 'DOMESTIC' AND booking.scheduled_date IS NULL))`,
{ day: options.day },
);
} else if (options.trainScheduleId) {
@@ -1125,15 +1137,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
}
if (options.originStationId) {
qb.andWhere('booking.originYardId = :originStationId', {
originStationId: options.originStationId,
});
}
if (options.destinationStationId) {
qb.andWhere('booking.destinationYardId = :destinationStationId', {
destinationStationId: options.destinationStationId,
if (options.corridorYardIds?.length) {
qb.andWhere('booking.originYardId IN (:...corridorYardIds)', {
corridorYardIds: options.corridorYardIds,
}).andWhere('booking.destinationYardId IN (:...corridorYardIds)', {
corridorYardIds: options.corridorYardIds,
});
} else {
if (options.originStationId) {
qb.andWhere('booking.originYardId = :originStationId', {
originStationId: options.originStationId,
});
}
if (options.destinationStationId) {
qb.andWhere('booking.destinationYardId = :destinationStationId', {
destinationStationId: options.destinationStationId,
});
}
}
if (options.schedulingStatus) {
qb.andWhere('booking.scheduling_status = :schedulingStatus', {

View File

@@ -638,23 +638,41 @@ export class TrainSchedulingService {
let day: string | undefined;
let originStationId = query.originStationId;
let destinationStationId = query.destinationStationId;
// Corridor mode: a schedule with intermediate stops pools every booking
// whose leg lies ON its route (Dire→DCT on a GMT→Dire→DCT train), not just
// exact endpoint matches — otherwise a mid-corridor booking unassigned from
// a wagon vanishes from the "Paid · unassigned" pool forever.
let corridorStops: string[] | undefined;
if (query.trainScheduleId) {
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
if (schedule?.scheduledDepartureDate) {
day = eatDay(schedule.scheduledDepartureDate);
originStationId = originStationId ?? schedule.originStationId;
destinationStationId = destinationStationId ?? schedule.destinationStationId;
const stops = await this.stopYardsForSchedule(schedule);
if (stops.length > 2) corridorStops = stops;
}
}
const bookings = await this.bookingsRepository.findEligibleForScheduling({
let bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType,
originStationId,
destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
day,
corridorYardIds: corridorStops,
});
if (corridorStops) {
// The IN-filter admits both yards anywhere on the route; only origin
// strictly before destination is actually rideable on this train.
const stopIdx = new Map(corridorStops.map((yardId, i) => [yardId, i]));
bookings = bookings.filter((b) => {
const from = stopIdx.get(b.originYardId);
const to = stopIdx.get(b.destinationYardId);
return from != null && to != null && from < to;
});
}
const tareDims = await this.loadWagonTareDims();
return {
count: bookings.length,
@@ -4604,8 +4622,12 @@ export class TrainSchedulingService {
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
trainSetWagonId: slot.id,
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
}));
const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId);
const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : [];
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
wagons,
@@ -4613,6 +4635,7 @@ export class TrainSchedulingService {
originYardId,
builtTrainId,
pinnedToScheduleIds,
stops,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -4621,14 +4644,16 @@ export class TrainSchedulingService {
});
}
const assignedPhysicalIds = new Set<string>();
const occupiedSpans = new Map<string, Array<[number, number]>>();
for (const slot of planSlots) {
const span = this.slotSpanOf(slot, stops);
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
originYardId,
assignedPhysicalIds,
occupiedSpans,
span,
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
@@ -4642,7 +4667,9 @@ export class TrainSchedulingService {
physicalWagonId: physical.id,
status: 'RESERVED',
});
assignedPhysicalIds.add(physical.id);
const pinnedSpans = occupiedSpans.get(physical.id) ?? [];
pinnedSpans.push(span);
occupiedSpans.set(physical.id, pinnedSpans);
}
}
@@ -4659,44 +4686,74 @@ export class TrainSchedulingService {
this.builtTrainIdOfSchedule(targetScheduleId),
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
]);
const targetSchedule = targetScheduleId
? await this.trainSchedulesRepository.findById(targetScheduleId)
: null;
const stops = targetSchedule
? await this.stopYardsForSchedule(targetSchedule)
: [];
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: slot.wagonTypeCode,
boardYardId: slot.boardYardId ?? null,
alightYardId: slot.alightYardId ?? null,
})),
wagons,
targetScheduleId,
originYardId,
builtTrainId,
pinnedToScheduleIds,
stops,
);
}
/**
* Stop-index span [board, alight) a slot occupies along the route. Slots with
* unknown/missing yards conservatively span the whole route (never share).
*/
private slotSpanOf(
slot: { boardYardId?: string | null; alightYardId?: string | null },
stops: string[],
): [number, number] {
const last = Math.max(1, stops.length - 1);
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : last;
if (from < 0 || to < 0 || from >= to) return [0, last];
return [from, to];
}
private findUnpinnableWagonSlots(
slots: Array<{
sequenceNo: number;
wagonTypeId: string;
wagonTypeCode: string;
boardYardId?: string | null;
alightYardId?: string | null;
}>,
wagons: Wagon[],
scheduleId: string | undefined,
originYardId: string,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
stops: string[] = [],
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set<string>();
// One physical wagon may serve several slots whose leg spans don't overlap
// (freed at its alight yard, reloaded downstream) — track occupied spans
// per wagon, not a flat taken-set.
const occupiedSpans = new Map<string, Array<[number, number]>>();
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const span = this.slotSpanOf(slot, stops);
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
originYardId,
assignedPhysicalIds,
occupiedSpans,
span,
builtTrainId,
pinnedToScheduleIds,
);
@@ -4706,7 +4763,9 @@ export class TrainSchedulingService {
);
continue;
}
assignedPhysicalIds.add(physical.id);
const spans = occupiedSpans.get(physical.id) ?? [];
spans.push(span);
occupiedSpans.set(physical.id, spans);
}
return violations;
@@ -4722,14 +4781,21 @@ export class TrainSchedulingService {
wagons: Wagon[],
scheduleId: string | undefined,
originYardId: string,
assignedPhysicalIds: Set<string>,
occupiedSpans: Map<string, Array<[number, number]>>,
span: [number, number],
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
): Wagon | undefined {
// Free for this slot = no already-assigned span on this wagon overlaps the
// slot's own leg. Disjoint legs (alight before board) share the wagon.
const spanFree = (wagonId: string): boolean =>
(occupiedSpans.get(wagonId) ?? []).every(
([from, to]) => to <= span[0] || span[1] <= from,
);
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
if (!spanFree(wagon.id)) return false;
// 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.
@@ -4755,7 +4821,7 @@ export class TrainSchedulingService {
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
!assignedPhysicalIds.has(w.id),
spanFree(w.id),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {