add cron jobs and aslo

This commit is contained in:
Marshal
2026-06-11 20:59:19 +00:00
parent cde3e462ba
commit 132f2150a8
18 changed files with 787 additions and 54 deletions

View File

@@ -13,3 +13,10 @@ export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;
/**
* Fallback per-wagon length (m) for the batch length budget when global rules don't yet
* define maxTrainLength / maxWagons to derive it from. Used only to estimate train length
* against the locomotive's max train length.
*/
export const DEFAULT_WAGON_LENGTH_METERS = 14;

View File

@@ -11,25 +11,86 @@ import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Locomotive } from '../locomotives/entities/locomotive.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';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import {
BATCH_CRON,
BATCH_TIMEZONE,
DEFAULT_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS,
} from './booking-batch.constants';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
wagons: number;
weightTons: number;
lengthMeters: number;
}
export type BatchBoardBookingState =
| 'ALLOCATED'
| 'AWAITING_PAYMENT'
| 'WAITING'
| 'PENDING_CONTRACT'
| 'EXPIRED';
export interface BatchBoardBooking {
id: string;
reference: string;
company: string;
isGovernment: boolean;
wagons: number;
weightTons: number;
paymentDeadline: string | null;
state: BatchBoardBookingState;
}
export interface BatchBoardSchedule {
scheduleId: string;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
locomotive: {
code: string;
name: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
} | null;
capacity: {
maxWagons: number;
usedWagons: number;
remainingWagons: number;
usedWeightTons: number;
maxWeightTons: number | null;
};
counts: {
allocated: number;
awaitingPayment: number;
waiting: number;
pendingContract: number;
expired: number;
};
bookings: BatchBoardBooking[];
}
/**
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
* by priority, greedily fills the train to capacity (skipping oversized bookings),
* by priority, greedily fills the train to capacity (skipping bookings that don't fit),
* reserves a 1h pay window for commercial customers (government allocated unpaid,
* preempting lower-priority commercial if needed), then settles each batch 1h later —
* allocating those who paid and expiring those who didn't, topping up from the waiting list.
* Capacity here is modelled by wagon count (`schedule.maxWagons`); locomotive weight/length
* is still enforced by the existing assignment path when staff pin wagons.
* Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the
* locomotive's max pull weight, and its max train length (also capped by global rules).
*/
@Injectable()
export class BookingBatchService implements OnModuleInit {
@@ -73,19 +134,122 @@ export class BookingBatchService implements OnModuleInit {
}
}
// ---- monitoring board -----------------------------------------------------
/**
* Read model for the batch monitoring page: every still-relevant schedule (not arrived/
* cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle
* state (allocated / awaiting payment / paid-waiting / pending contract / expired).
*/
async getBatchBoard(): Promise<BatchBoardSchedule[]> {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true },
originStation: true,
destinationStation: true,
route: true,
},
order: { scheduledDepartureDate: 'ASC' },
});
const rules = await this.loadGlobalRules();
const perWagonLength = this.perWagonLength(rules);
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, perWagonLength);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
state: this.boardState(b, linkedIds.has(b.id)),
};
});
const usedWagons = items
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
.reduce((sum, i) => sum + i.wagons, 0);
const usedWeight = items
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
.reduce((sum, i) => sum + i.weightTons, 0);
const loco = s.trainSet?.locomotive ?? null;
board.push({
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: {
maxWagons: s.maxWagons ?? 0,
usedWagons,
remainingWagons: Math.max(0, (s.maxWagons ?? 0) - usedWagons),
usedWeightTons: Math.round(usedWeight * 100) / 100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
},
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
},
bookings: items,
});
}
return board;
}
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
if (linked) return 'ALLOCATED';
if (booking.status === 'AWAITING_PAYMENT') return 'AWAITING_PAYMENT';
if (booking.status === 'EXPIRED') return 'EXPIRED';
if (booking.status === 'PAID') return 'WAITING';
return 'PENDING_CONTRACT';
}
// ---- core fill ------------------------------------------------------------
/** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return;
if (!schedule.trainSetId || !schedule.trainSet?.locomotive) {
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`);
return;
}
let remaining = await this.remainingWagons(schedule);
if (remaining <= 0) {
const rules = await this.loadGlobalRules();
const perWagonLength = this.perWagonLength(rules);
const limits = this.capacityLimits(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, perWagonLength);
if (budget.wagons <= 0) {
await this.setWindow(scheduleId, 'FULL');
return;
}
@@ -94,14 +258,14 @@ export class BookingBatchService implements OnModuleInit {
let armed = false;
for (const booking of pool) {
const need = this.wagonsFor(booking);
const need = this.needFor(booking, perWagonLength);
if (need > remaining) {
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
remaining = await this.preemptForGovernment(scheduleId, need, remaining);
if (need > remaining) continue; // still doesn't fit even after preempt
budget = await this.preemptForGovernment(scheduleId, need, budget, perWagonLength);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip oversized commercial, try the next
continue; // skip a booking that exceeds weight/length/wagons, try the next
}
}
@@ -111,11 +275,11 @@ export class BookingBatchService implements OnModuleInit {
await this.reserve(booking);
armed = true;
}
remaining -= need;
if (remaining <= 0) break;
budget = this.subtract(budget, need);
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
}
if (remaining <= 0) await this.setWindow(scheduleId, 'FULL');
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
if (armed) this.armSettle(scheduleId);
}
@@ -280,9 +444,10 @@ export class BookingBatchService implements OnModuleInit {
*/
private async preemptForGovernment(
scheduleId: string,
need: number,
remaining: number,
): Promise<number> {
need: Capacity,
budget: Capacity,
perWagonLength: number,
): Promise<Capacity> {
const reservedCommercial = (
await this.bookingsRepository.findReservedForSchedule(scheduleId)
).filter((b) => !b.isGovernment);
@@ -294,8 +459,9 @@ export class BookingBatchService implements OnModuleInit {
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
);
let freed = budget;
for (const victim of candidates) {
if (need <= remaining) break;
if (this.fits(need, freed)) break;
await this.dataSource.transaction(async (manager) => {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
@@ -309,9 +475,9 @@ export class BookingBatchService implements OnModuleInit {
} as never);
});
this.notifier.displaced(victim);
remaining += this.wagonsFor(victim);
freed = this.add(freed, this.needFor(victim, perWagonLength));
}
return remaining;
return freed;
}
// ---- capacity helpers -----------------------------------------------------
@@ -327,6 +493,86 @@ export class BookingBatchService implements OnModuleInit {
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING);
}
/** What one booking consumes along all three capacity axes. */
private needFor(booking: Booking, perWagonLength: number): Capacity {
const wagons = this.wagonsFor(booking);
return {
wagons,
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
lengthMeters: wagons * perWagonLength,
};
}
private fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&
need.weightTons <= budget.weightTons &&
need.lengthMeters <= budget.lengthMeters
);
}
private subtract(budget: Capacity, need: Capacity): Capacity {
return {
wagons: budget.wagons - need.wagons,
weightTons: budget.weightTons - need.weightTons,
lengthMeters: budget.lengthMeters - need.lengthMeters,
};
}
private add(budget: Capacity, freed: Capacity): Capacity {
return {
wagons: budget.wagons + freed.wagons,
weightTons: budget.weightTons + freed.weightTons,
lengthMeters: budget.lengthMeters + freed.lengthMeters,
};
}
/** The train's hard caps: wagon count, locomotive pull weight, locomotive/global length. */
private capacityLimits(
schedule: TrainSchedule,
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Capacity {
const locoWeight = Number(locomotive.maxPullWeightTons) || Infinity;
const locoLength = Number(locomotive.maxTrainLengthMeters) || Infinity;
const ruleWeight = rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : Infinity;
const ruleLength = rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : Infinity;
return {
wagons: schedule.maxWagons ?? 0,
weightTons: Math.min(locoWeight, ruleWeight),
lengthMeters: Math.min(locoLength, ruleLength),
};
}
/** Per-wagon length, derived from global rules (maxLength / maxWagons) or a fallback. */
private perWagonLength(rules: TrainSchedulingGlobalRules | null): number {
const len = rules ? Number(rules.maxTrainLengthMeters) : 0;
const wagons = rules ? Number(rules.maxWagonsPerTrain) : 0;
if (len > 0 && wagons > 0) return len / wagons;
return DEFAULT_WAGON_LENGTH_METERS;
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} });
}
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
private async remainingCapacity(
schedule: TrainSchedule,
limits: Capacity,
perWagonLength: number,
): Promise<Capacity> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, perWagonLength)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
);
return this.subtract(limits, used);
}
/** maxWagons minus wagons already taken by allocated + reserved bookings. */
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const allocated = (schedule.scheduleBookings ?? [])

View File

@@ -17,6 +17,14 @@ export class GetEligibleBookingsDto {
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Scope to bookings that targeted this specific schedule (batch parity).',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional()
@IsOptional()
schedulingStatus?: string;

View File

@@ -12,6 +12,11 @@ export class GetEligibleBulkBookingsDto {
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
schedulingStatus?: string;

View File

@@ -12,6 +12,11 @@ export class GetEligibleContainerBookingsDto {
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
schedulingStatus?: string;

View File

@@ -57,6 +57,13 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getEligibleBookings(query);
}
@Get('batch-board')
@TrainSchedulingView()
@ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' })
getBatchBoard() {
return this.bookingBatchService.getBatchBoard();
}
@Get('bookable-schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })

View File

@@ -113,6 +113,7 @@ export class TrainSchedulingService {
originStationId: query.originStationId,
destinationStationId: query.destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
}
@@ -272,6 +273,20 @@ export class TrainSchedulingService {
throw new BadRequestException('Schedule has no train set');
}
// Batch parity: a schedule may only allocate bookings that targeted it. This mirrors
// the automatic fill, which only pulls bookings whose train_schedule_id is this schedule.
if (dto.bookingIds.length) {
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId);
if (stray.length) {
throw new BadRequestException(
`These bookings are not assigned to this schedule: ${stray
.map((b) => b.reference ?? b.id)
.join(', ')}`,
);
}
}
const previewDto = {
bookingIds: dto.bookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),