Enhance overview and train scheduling features

- Updated OverviewContractsTabPanel to include a new donut chart for freight type distribution.
- Modified OverviewOperationsTabPanel to improve data visualization with additional charts and refactored data handling.
- Introduced CreateScheduleWindowFields component for configuring booking windows in train scheduling.
- Added new API endpoints for allocation candidates and booking allocation in trainScheduling.service.
- Enhanced BookingRequestsPage to support allocation of paid bookings with a modal for selecting alternative dates.
- Updated QUERY_KEYS and URLS constants to accommodate new operations and features.
- Improved type definitions for overview and train scheduling to support new functionalities.
This commit is contained in:
Marshal
2026-08-03 21:06:59 +00:00
parent 488c2465be
commit e68bdb7a1a
30 changed files with 1580 additions and 82 deletions

View File

@@ -151,6 +151,14 @@ export interface ExportTrainOption {
}>;
}
/** A train a paid-unallocated booking can board (route + capacity verified). */
export interface AllocationCandidate {
id: string;
reference: string | null;
direction: string | null;
scheduledDepartureDate: Date;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
@@ -3040,6 +3048,104 @@ export class BookingBatchService implements OnModuleInit {
this.notifyBoardChanged(newScheduleId, "booking_moved");
}
/**
* Trains a paid-but-unallocated booking can board right now: OPEN window,
* future departure, route covers the booking's leg, and remaining corridor
* capacity fits it. Split by the booking's own scheduled day so the UI can
* offer one-click same-day allocation vs an explicit "another date" choice.
*/
async allocationCandidates(bookingId: string): Promise<{
sameDay: AllocationCandidate[];
otherDays: AllocationCandidate[];
}> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: {
bookingContainers: { containerType: true },
// wagonTypes drives the break-bulk items-per-wagon fit — size the
// booking exactly as the intercity accept check does.
cargoType: { wagonTypes: true },
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const today = eatDay(new Date());
const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
const sameDay: AllocationCandidate[] = [];
const otherDays: AllocationCandidate[] = [];
for (const s of schedules) {
if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue;
if (s.bookingWindowStatus !== "OPEN") continue;
if (s.id === booking.trainScheduleId) continue;
const stops = await this.stopsForSchedule(s);
const fromIdx = stops.indexOf(booking.originYardId);
const toIdx = stops.indexOf(booking.destinationYardId);
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue;
// ponytail: full capacity build per candidate is heavy; the set is small
// (future OPEN trains on the booking's route) — precompute if it grows.
const cap = await this.intercityCapacity(s.id);
if (!cap) continue;
const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!cap.budget.fits(cap.needFor(booking), leg)) continue;
const candidate: AllocationCandidate = {
id: s.id,
reference: s.reference ?? s.trainNumber ?? null,
direction: s.direction ?? null,
scheduledDepartureDate: s.scheduledDepartureDate,
};
(eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate);
}
const byDate = (a: AllocationCandidate, b: AllocationCandidate) =>
new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime();
sameDay.sort(byDate);
otherDays.sort(byDate);
return { sameDay, otherDays };
}
/**
* Place a PAID booking that lost (or never got) its train: re-point via
* moveToSchedule (window/route validation + day sync), then allocate it
* immediately — payment already landed, so no new pay window opens. The
* customer gets an in-app notice when the new train departs on a different
* day than their original choice.
*/
async allocatePaid(bookingId: string, scheduleId: string): Promise<void> {
const before = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!before) throw new NotFoundException(`Booking ${bookingId} not found`);
if (before.paymentStatus !== "PAID" && before.status !== "PAID") {
throw new BadRequestException(
"Booking is not paid — use the regular scheduling flow",
);
}
const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null;
await this.moveToSchedule(bookingId, scheduleId);
const fresh = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!fresh) return;
if (!(await this.holdIfWagonShort(scheduleId, fresh))) {
await this.allocate(scheduleId, fresh, "paid");
}
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (
previousDay &&
schedule?.scheduledDepartureDate &&
eatDay(schedule.scheduledDepartureDate) !== previousDay
) {
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
}
}
/**
* One reminder per hold, shortly before its pay deadline (the window tick
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
@@ -3526,6 +3632,16 @@ export class BookingBatchService implements OnModuleInit {
}
return;
}
// Paid but detached from any train (staff removed it from an allocation,
// or a sweep caught it unpinned): money was taken, so it must board — it
// stays paid-unallocated for staff to place via the allocate action.
if (paid) {
this.logger.log(
`[BATCH] expire skipped for ${booking.reference} — payment landed ` +
`but no train attached; left paid-unallocated for manual placement`,
);
return;
}
// Reconcile-before-expire (only when a pay window was actually open):
// no webhook arrived, so ask the gateway DIRECTLY whether the money
// landed. A late capture found there is registered as SUCCEEDED and
@@ -3854,12 +3970,26 @@ export class BookingBatchService implements OnModuleInit {
// booking can use — don't kill it for nothing.
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
if (!overlaps) continue;
const victimPaid =
victim.paymentStatus === "PAID" || victim.status === "PAID";
await this.dataSource.transaction(async (manager) => {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
victim.id,
manager,
);
if (victimPaid) {
// Paid bookings are never expired — money was taken, so it boards.
// Detach it so it surfaces in the paid-unallocated queue for staff
// to re-place; the settled invoice stays untouched.
await manager.getRepository(Booking).update(victim.id, {
trainScheduleId: null,
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
return;
}
await manager.getRepository(Booking).update(victim.id, {
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",