feat(train-scheduling): implement day-level booking pool

- Added `unplaced` method in `BookingNotifierService` to log warnings for bookings that cannot be placed on any train.
- Introduced `getAvailableDays` method in `TrainSchedulingService` to retrieve distinct days with open departures for a given route.
- Created `AvailableDaysQueryDto` for querying available days based on origin and destination yards.
- Updated `TrainSchedulingController` to expose an endpoint for available days.
- Modified frontend components to support day-level booking, allowing customers to select only a day without pinning to a specific train.
- Removed references to train schedules in booking forms and review steps, emphasizing day selection.
- Added a database migration to create an index for efficient querying of bookings by route and day.
This commit is contained in:
Marshal
2026-06-18 14:12:46 +00:00
parent 578012dffd
commit 421e0266bc
22 changed files with 626 additions and 332 deletions

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Day-level booking pool: customers select a DAY (route + day), not a specific
* train. The batch engine's pool query filters bookings on
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
* index backs that scan.
*/
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
}
}

View File

@@ -689,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationStationId?: string;
schedulingStatus?: string;
trainScheduleId?: string;
/**
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
* the whole (route, day) pool rather than bookings pre-targeted to one train.
*/
day?: string;
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
@@ -706,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
// Mirror the automatic batch pool: a schedule only ever considers bookings that
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
if (options.trainScheduleId) {
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
// booking that still carries train_schedule_id).
if (options.day) {
qb.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day: options.day },
);
} else if (options.trainScheduleId) {
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
trainScheduleId: options.trainScheduleId,
});
@@ -765,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/**
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
* EAT calendar day, regardless of which train they end up on. Same status
* rules and ordering as {@link findBatchPool}, but keyed on
* (origin, destination, day) instead of train_schedule_id — the engine then
* distributes these across all trains departing that day.
*/
findBatchPoolByRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id = :originYardId', { originYardId })
.andWhere('booking.destination_yard_id = :destinationYardId', {
destinationYardId,
})
.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -273,8 +274,8 @@ export class BookingsService {
companyId = company.id;
}
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } });
@@ -290,6 +291,22 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
dto.destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
}
const reference = dto.reference || (await this.generateReference());

View File

@@ -91,12 +91,21 @@ export class CreateBookingDto {
@IsUUID()
trainId?: string;
/** Target schedule this booking is created against (required by the backoffice create form). */
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
/**
* Staff-only manual pin to a specific train. Customers omit this — they pick a
* DAY via {@link scheduledDate} and the batch engine assigns a train within
* that (route, day) pool. When provided, the schedule must be OPEN and on the
* booking route.
*/
@ApiPropertyOptional({
format: 'uuid',
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
/** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;

View File

@@ -277,7 +277,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
/**
* The train this booking is assigned to. FK to train_schedules.
*
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
* this stays null at creation. The batch engine sets it when it assigns the
* booking to a specific train within its (route, day) pool; staff may also
* pin it manually. The day-level pool is keyed on
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
*/
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;

View File

@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
};
}
/**
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
* key for day-level booking pools — it must match the day the portal calendar
* renders, so always derive day keys through this (never `toISOString().slice`).
*/
export function eatDay(date: Date): string {
const { year, month, day } = eatParts(date);
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc(
year: number,

View File

@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
let bookingsRepository: {
findPaidUnlinkedForSchedule: jest.Mock;
findBatchPool: jest.Mock;
findBatchPoolByRouteDay: jest.Mock;
findReservedForSchedule: jest.Mock;
update: jest.Mock;
};
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
};
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
getBookableSchedules: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
secured: jest.Mock;
expired: jest.Mock;
unplaced: jest.Mock;
};
beforeEach(() => {
bookingsRepository = {
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
findBatchPool: jest.fn().mockResolvedValue([]),
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
findReservedForSchedule: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [],
violations: [],
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
};
const bookingRepo = {
findOne: jest.fn().mockResolvedValue(paidBooking),
update: jest.fn().mockResolvedValue(undefined),
// WagonType.find() / global-rules find() fall back to defaults when empty.
find: jest.fn().mockResolvedValue([]),
};
dataSource = {
getRepository: jest.fn().mockReturnValue(bookingRepo),
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
};
notifier = {
payNow: jest.fn(),
secured: jest.fn(),
expired: jest.fn(),
unplaced: jest.fn(),
};
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
);
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(fillOrder).toBeLessThan(reconcileOrder);
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('fillRouteDay — day-level distribution', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const day = '2026-06-20';
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
const trainA = 'train-a';
const trainB = 'train-b';
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
const commercial = (id: string, priority: number): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
}) as unknown as Booking;
beforeEach(() => {
// Two OPEN trains on the same route + day, train A earlier than train B.
trainSchedulingService.getBookableSchedules.mockResolvedValue([
{
id: trainA,
scheduleDate: '2026-06-20T06:00:00.000Z',
bookingWindowStatus: 'OPEN',
},
{
id: trainB,
scheduleDate: '2026-06-20T09:00:00.000Z',
bookingWindowStatus: 'OPEN',
},
]);
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
Promise.resolve({
id,
maxWagons: 1,
bookingWindowStatus: 'OPEN',
trainSetId: `set-${id}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
}),
);
});
it('spills overflow to the next train by priority, then reports unplaced', async () => {
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
commercial('hi', 30),
commercial('mid', 20),
commercial('lo', 10),
]);
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
originYardId,
destinationYardId,
day,
);
// Both trains were processed.
expect(touched).toEqual([trainA, trainB]);
// Highest priority reserved on train A, next on train B (commercial → reserve).
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
expect(reservedOn).toEqual(['hi', 'mid']);
// The third booking fits no train and is reported unplaced (and only it).
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
});
it('reserves the chosen train id on each commercial booking', async () => {
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
expect(bookingsRepository.update).toHaveBeenCalledWith(
'hi',
expect.objectContaining({
trainScheduleId: trainA,
status: 'SELECTED_FOR_BATCH',
}),
);
});
});
});

View File

@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { groupBookingsIntoBoardWindows } from './batch-window.util';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_CRON,
BATCH_TIMEZONE,
@@ -42,6 +42,14 @@ interface Capacity {
lengthMeters: number;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
destinationYardId: string;
/** EAT calendar day, `yyyy-MM-dd`. */
day: string;
}
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
for (const s of open) {
const groups = await this.openRouteDayGroups();
for (const group of groups) {
try {
await this.processSchedule(s.id);
await this.processRouteDay(group);
} catch (err) {
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
this.logger.warn(
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
const reserved = await this.dataSource
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
/**
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
* (contract sign, payment). Day-level pooling distributes across all of that
* day's trains, so a single schedule id maps to its whole route-day group.
*/
enqueueScheduleProcessing(scheduleId: string): void {
void this.processSchedule(scheduleId).catch((err) =>
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
this.logger.error(
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
),
);
}
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return;
await this.processRouteDay({
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
});
}
/**
* Day-level pipeline: distribute the (route, day) pool across all its trains,
* then settle / reconcile / assign wagons per schedule (those steps stay
* schedule-scoped — only the fill is day-level).
*/
async processRouteDay(group: RouteDayGroup): Promise<void> {
const scheduleIds = await this.fillRouteDay(
group.originYardId,
group.destinationYardId,
group.day,
);
for (const scheduleId of scheduleIds) {
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
}
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise<void> {
await this.fillSchedule(scheduleId);
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
if (!s.scheduledDepartureDate) continue;
const day = eatDay(s.scheduledDepartureDate);
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
if (!groups.has(key)) {
groups.set(key, {
originYardId: s.originStationId,
destinationYardId: s.destinationStationId,
day,
});
}
}
return [...groups.values()];
}
private groupLabel(group: RouteDayGroup): string {
return `${group.originYardId}${group.destinationYardId} on ${group.day}`;
}
/**
* Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
for (const s of open) {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
for (const group of groups) {
try {
await this.processSchedule(s.id);
await this.processRouteDay(group);
} catch (err) {
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
this.logger.error(
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
}
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov');
} else {
await this.reserve(booking);
await this.reserve(booking, scheduleId);
armed = true;
}
budget = this.subtract(budget, need);
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
/**
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
* priority, filling each train (earliest departure first) until it's full and
* spilling overflow to the next. Government bookings that fit no train preempt
* lower-priority commercial; bookings that fit no train at all stay pending and
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
* (or that had remaining pool work) so the caller can settle them per-schedule.
*/
async fillRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<string[]> {
// The day's OPEN bookable schedules on this exact corridor, earliest first.
const bookable = await this.trainSchedulingService.getBookableSchedules(
originYardId,
destinationYardId,
);
const scheduleIds = bookable
.filter(
(s) =>
s.bookingWindowStatus === 'OPEN' &&
s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day,
)
.sort(
(a, b) =>
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
)
.map((s) => s.id);
if (scheduleIds.length === 0) return [];
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
// Live per-schedule budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
originYardId,
destinationYardId,
day,
);
for (const booking of pool) {
const need = this.needFor(booking, wagonLengths);
// First train (earliest departure) that fits this booking as-is.
let target = trains.find((t) => this.fits(need, t.budget));
if (!target && booking.isGovernment) {
// Government booking fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
if (this.fits(need, t.budget)) {
target = t;
break;
}
}
}
if (!target) {
// Fits no train this day — stays in the pool, retried next batch.
this.notifier.unplaced(booking, day);
continue;
}
if (booking.isGovernment) {
await this.allocate(target.id, booking, 'gov');
} else {
await this.reserve(booking, target.id);
target.armed = true;
}
target.budget = this.subtract(target.budget, need);
}
for (const t of trains) {
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
return trains.map((t) => t.id);
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
// ---- mutations ------------------------------------------------------------
/** Reserve capacity for a commercial booking and open its pay window. */
private async reserve(booking: Booking): Promise<void> {
/**
* Reserve capacity for a commercial booking on a specific train and open its
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
* which is all keyed off `booking.trainScheduleId`, can find the train — with
* day-level pooling the booking arrives here with `trainScheduleId` still null,
* so the engine sets it as it picks the train.
*/
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
const now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH',
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
await this.notifier.payNow(booking, deadline);
}
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId);
}
/** Expire an unpaid reservation and free its capacity. */
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
* it failed to pay for — it's back in the day pool for staff to act on.
*/
private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
this.notifier.expired(booking);
}

View File

@@ -67,6 +67,17 @@ export class BookingNotifierService {
);
}
/**
* Staff-facing warning when a pooled booking fits no train on its chosen day.
* It stays pending and is retried next batch; staff can add capacity or pin it
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
*/
unplaced(b: Booking, day: string): void {
this.logger.warn(
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
);
}
displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED');

View File

@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
export class AvailableDaysQueryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originYardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationYardId?: string;
}

View File

@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
);
}
@Get("available-days")
// No staff guard: customers hit this while creating a booking to find which
// DAYS have a departure on their route. Day-level pooling — no capacity is
// returned, only the list of bookable days.
@ApiOperation({
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
})
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
return this.trainSchedulingService.getAvailableDays(
query.originYardId,
query.destinationYardId,
);
}
@Get("container/eligible-bookings")
@TrainSchedulingView()
@ApiOperation({ summary: "List eligible container bookings" })

View File

@@ -87,6 +87,7 @@ import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
) {}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
// resolving the schedule's route + day and filtering on the day instead.
let day: string | undefined;
let originStationId = query.originStationId;
let destinationStationId = query.destinationStationId;
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 bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType,
originStationId: query.originStationId,
destinationStationId: query.destinationStationId,
originStationId,
destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
day,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
}
@@ -1989,6 +2006,33 @@ export class TrainSchedulingService {
return filteredSchedules;
}
/**
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
* departure on the route. Customers pick a DAY (not a train) — so this returns
* only the day strings, no capacity, counts or train info.
*/
async getAvailableDays(
originYardId?: string,
destinationYardId?: string,
): Promise<{ days: string[] }> {
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
const days = new Set<string>();
for (const s of schedules) {
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
}
return { days: [...days].sort() };
}
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
async existsOpenScheduleOnRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
return days.includes(day);
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {

View File

@@ -302,8 +302,9 @@ export default function NewBookingPage() {
const allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const scheduleSatisfied =
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
// Day-level pool: a shipment DAY is enough to proceed. Pinning a specific train
// (trainScheduleId) is an optional staff override — the batch engine otherwise
// assigns the train. A selected schedule implies its day, so either satisfies.
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
const canSubmit =
@@ -311,7 +312,6 @@ export default function NewBookingPage() {
Boolean(destinationYardId) &&
!sameYard &&
Boolean(tradeDirection) &&
scheduleSatisfied &&
Boolean(serviceTypeId) &&
departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
@@ -473,25 +473,25 @@ export default function NewBookingPage() {
</Group>
{hasBookableSchedules ? (
<Select
label="Train schedule"
label="Train schedule (optional)"
placeholder={
originYardId && destinationYardId
? "Select an open schedule on this route"
? "Leave blank to let the batch engine assign a train"
: "Pick origin & destination first"
}
data={scheduleOptions}
value={trainScheduleId}
onChange={setTrainScheduleId}
searchable
required
clearable
disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route"
description="The booking will be batched against this schedule once its contract is signed."
description="Optional: pin to a specific train. Otherwise the booking joins the day pool and the engine assigns a train by priority."
/>
) : originYardId && destinationYardId ? (
<Text size="sm" c="dimmed">
No open train schedule on this route set a preferred departure below. Staff can
link a schedule later.
No open train schedule on this route set a preferred departure below. The batch
engine assigns a train on that day, or staff can pin one later.
</Text>
) : null}
<Group grow align="flex-end">

View File

@@ -100,6 +100,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
},
PAYMENTS: {

View File

@@ -143,7 +143,6 @@ function mapBookingToFormValues(
scheduledDate: booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: "",
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
notes: "",
containers: [],
} as BookingFormInputValues;
@@ -410,7 +409,8 @@ export default function EditBookingPage() {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: undefined,
trainScheduleId: data.trainScheduleId || undefined,
// Day-level pool: the customer edits only the day; the engine assigns the
// train, so trainScheduleId is not sent.
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,

View File

@@ -279,7 +279,8 @@ export default function NewBookingPage() {
destinationYardId: data.destinationYard,
tradeDirection: direction!,
cargoTypeId,
trainScheduleId: data.trainScheduleId,
// Day-level pool: the customer picks only a day (scheduledDate); the batch
// engine assigns the train, so no trainScheduleId is sent.
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled,

View File

@@ -113,8 +113,9 @@ export const bookingFormSchema = z
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
scheduledDate: z.string().min(1, "Select a shipment date."),
trainScheduleId: z.string().min(1, "Select a shipment date."),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]),
@@ -236,7 +237,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "",
shippingLine: "",
scheduledDate: "",
trainScheduleId: "",
cargoWeight: "",
cargoTypePath: [],
cargoFreeText: "",
@@ -272,7 +272,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"containers",
"consolidationEnabled",
],
5: ["scheduledDate", "trainScheduleId"],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],
};

View File

@@ -5,10 +5,9 @@ import {
Button,
Card,
Group,
Modal,
Stack,
Text,
useMantineTheme
useMantineTheme,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
@@ -46,17 +45,15 @@ interface DayData {
isToday: boolean;
isCurrentMonth: boolean;
isSelectedDate: boolean;
schedules: Freight.BookableScheduleItem[];
hasSchedule: boolean;
/** True when the route has at least one departure on this day. */
hasDeparture: boolean;
}
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId");
const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType");
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
[referenceData, destinationYardId],
);
const { data: bookableSchedules } = useQuery(
api.bookings.getBookableSchedules.queryOptions({
// Day-level pool: the customer picks a DAY, not a train. We only fetch which
// days have a departure — no capacity, no per-train detail. The batch engine
// assigns the train later, distributing the day's pool by priority.
const { data: availableDays } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
// Group all schedules per date — multiple departures per day are allowed.
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
// match the format used by the calendar day keys.
const schedulesByDate = useMemo(() => {
const map = new Map<string, Freight.BookableScheduleItem[]>();
if (bookableSchedules) {
for (const s of bookableSchedules) {
const dateKey = s.scheduleDate.slice(0, 10);
const existing = map.get(dateKey) ?? [];
map.set(dateKey, [...existing, s]);
}
}
return map;
}, [bookableSchedules]);
const selectedSchedule = useMemo(
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
[bookableSchedules, selectedScheduleId],
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const availableCount = useMemo(() => {
let count = 0;
schedulesByDate.forEach((schedules) => {
if (schedules.some((s) => s.remainingWagons > 0)) count++;
});
return count;
}, [schedulesByDate]);
const days = useMemo((): DayData[] => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
const schedules = schedulesByDate.get(dateString) ?? [];
return {
day: date.getDate(),
dateString,
isToday: isToday(date),
isCurrentMonth: isSameMonth(date, currentDate),
isSelectedDate: selectedDate === dateString,
schedules,
hasSchedule: schedules.length > 0,
hasDeparture: departureDays.has(dateString),
};
});
}, [currentDate, schedulesByDate, selectedDate]);
}, [currentDate, departureDays, selectedDate]);
const availableCount = useMemo(
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
[days],
);
const cargoSummary = useMemo(() => {
if (!cargoType) return "Not selected";
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7);
const handleDayClick = (day: DayData) => {
if (day.schedules.length > 1) {
setSelectedDayForModal(day);
} else if (day.schedules.length === 1) {
form.setValue("scheduledDate", day.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", day.schedules[0].id, {
shouldValidate: true,
});
}
};
const handleSelectScheduleFromModal = (scheduleId: string) => {
if (selectedDayForModal) {
form.setValue("scheduledDate", selectedDayForModal.dateString, {
shouldValidate: true,
});
form.setValue("trainScheduleId", scheduleId, {
shouldValidate: true,
});
setSelectedDayForModal(null);
}
if (!day.hasDeparture) return;
// Record only the day — no specific train is chosen.
form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
};
return (
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Stack gap={14} px={24} py={18}>
<Text fz={13} fw={600} c="edr-text.0">
{originYardId && destinationYardId
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
: "Select origin and destination to see available departures"}
</Text>
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
}}
>
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
<DayCell
key={di}
day={d}
onDayClick={handleDayClick}
/>
<DayCell key={di} day={d} onDayClick={handleDayClick} />
))}
</Box>
))}
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
value={cargoSummary}
/>
{selectedSchedule && selectedDate && (
{selectedDate && (
<Box
p={14}
style={{
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
c="edr-green.7"
style={{ letterSpacing: "0.08em" }}
>
SELECTED DEPARTURE
SELECTED DAY
</Text>
</Group>
<Text fw={800} fz={16} c="edr-text.0">
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Train
Your train is confirmed by our freight desk after booking.
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
</Text>
</Group>
<Group justify="space-between">
<Text fz={12.5} c="edr-muted">
Wagons available
</Text>
<Text fz={12.5} fw={700} c="edr-text.0">
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
</Text>
</Group>
</Stack>
</Box>
)}
@@ -380,154 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Stack>
</Box>
</Stack>
{/* ── Schedule Selection Modal ──────────────────────────── */}
<Modal
opened={!!selectedDayForModal}
onClose={() => setSelectedDayForModal(null)}
centered
size={520}
radius={18}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
>
{/* Header */}
<Box
px={24}
py={20}
style={{
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
}}
>
<Group gap={7} align="center" mb={6}>
<CalendarIcon size={15} color="#9FE9CC" />
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
Available departures
</Text>
</Group>
<Text fw={800} fz={19} c="#fff">
{selectedDayForModal
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
: ""}
</Text>
<Text fz={12.5} c="#A9BBCB" mt={2}>
{selectedDayForModal?.schedules.length ?? 0} train
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
{originName} {destinationName}
</Text>
</Box>
{/* Schedule list */}
<Stack gap={12} p={24}>
{selectedDayForModal?.schedules.map((schedule) => {
const remaining = schedule.remainingWagons;
const max = schedule.maxWagons || 1;
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
const isSelected = schedule.id === selectedScheduleId;
return (
<Box
key={schedule.id}
role="button"
tabIndex={0}
onClick={() => handleSelectScheduleFromModal(schedule.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelectScheduleFromModal(schedule.id);
}
}}
style={{
cursor: "pointer",
borderRadius: 14,
padding: 16,
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
boxShadow: isSelected
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
: "0 1px 2px rgba(16,24,40,0.04)",
transition: "all 150ms ease",
}}
>
<Group gap={14} wrap="nowrap" align="center">
<Box
style={{
width: 50,
height: 50,
flexShrink: 0,
borderRadius: 13,
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][6]} />
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text fw={800} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
<Text fz={12.5} c="edr-muted">
{schedule.trainNumber
? `Train ${schedule.trainNumber}`
: `#${schedule.id.slice(0, 6)}`}
</Text>
</Group>
{/* capacity bar */}
<Box mt={8}>
<Group justify="space-between" mb={4}>
<Text fz={11} fw={600} c="edr-muted">
{remaining} / {max} wagons free
</Text>
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
{pct}%
</Text>
</Group>
<Box
style={{
height: 6,
borderRadius: 999,
background: "#EEF2F6",
overflow: "hidden",
}}
>
<Box
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 999,
background:
pct > 25
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
: "#F2A516",
}}
/>
</Box>
</Box>
</Box>
<Box
style={{
width: 26,
height: 26,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
}}
>
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</Box>
);
})}
</Stack>
</Modal>
</Group>
);
}
@@ -537,7 +332,7 @@ interface DayCellProps {
onDayClick: (day: DayData) => void;
}
function DayCell({ day: d, onDayClick, }: DayCellProps) {
function DayCell({ day: d, onDayClick }: DayCellProps) {
const theme = useMantineTheme();
if (!d.isCurrentMonth) {
@@ -561,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
const cellBg = d.isSelectedDate
? theme.colors["edr-soft"][0]
: d.hasSchedule
: d.hasDeparture
? "#FFFFFF"
: "transparent";
const cellBorder = d.isSelectedDate
? `2px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule
: d.hasDeparture
? `1px solid ${theme.colors["edr-border"][0]}`
: "none";
return (
<Box
onClick={() => d.hasSchedule && onDayClick(d)}
onClick={() => d.hasDeparture && onDayClick(d)}
style={{
height: 92,
borderRadius: theme.radius.md,
@@ -584,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
display: "flex",
flexDirection: "column",
gap: 4,
cursor: d.hasSchedule ? "pointer" : "default",
cursor: d.hasDeparture ? "pointer" : "default",
transition: "all 150ms ease",
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
}}
onMouseEnter={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
}}
onMouseLeave={(e) => {
if (d.hasSchedule && !d.isSelectedDate) {
if (d.hasDeparture && !d.isSelectedDate) {
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
}
@@ -610,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
c={
d.isToday && !d.isSelectedDate
? "edr-green.6"
: d.hasSchedule
: d.hasDeparture
? "edr-text.0"
: "edr-muted"
}
@@ -635,50 +430,24 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
justifyContent: "center",
}}
>
<Check
size={14}
color="white"
strokeWidth={3}
/>
<Check size={14} color="white" strokeWidth={3} />
</Box>
)}
</Group>
{/* Availability marker — a dot + count, never the schedule list itself. */}
{d.hasSchedule && (
{/* Availability marker — a single dot for days that have a departure.
No counts or capacity are shown: it's a day-level pool. */}
{d.hasDeparture && (
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
<Group
gap={6}
align="center"
wrap="nowrap"
px={9}
py={4}
style={{
borderRadius: 999,
backgroundColor: d.isSelectedDate
? "#fff"
: theme.colors["edr-soft"][0],
border: `1px solid ${
d.isSelectedDate
? theme.colors["edr-green"][2]
: "transparent"
}`,
}}
>
<Box
style={{
width: 7,
height: 7,
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
}}
/>
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
)}
</Box>

View File

@@ -326,10 +326,6 @@ export function Step8Review({
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
>
<DetailRow label="Shipment date" value={scheduleLabel} />
<DetailRow
label="Train schedule"
value={values.trainScheduleId ? "Selected" : "—"}
/>
</OverviewSection>
<OverviewSection
@@ -444,8 +440,8 @@ export function Step8Review({
label="Route selected"
/>
<ReadinessItem
done={Boolean(values.scheduledDate && values.trainScheduleId)}
label="Schedule selected"
done={Boolean(values.scheduledDate)}
label="Shipment day selected"
/>
<ReadinessItem
done={

View File

@@ -220,6 +220,13 @@ export const api = {
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
),
getAvailableDays: endpoint<
{ originYardId?: string; destinationYardId?: string },
string[]
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
},
payments: {

View File

@@ -195,4 +195,18 @@ export const bookingsService = {
);
return data.data;
},
/**
* Day-level pool: the days that have a departure on the route. The customer
* picks a day; the engine assigns the train. No capacity is returned.
*/
getAvailableDays: async (
query: Freight.AvailableDaysQuery = {},
): Promise<string[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
{ params: query },
);
return (data.data as Freight.AvailableDaysResponse).days;
},
};

View File

@@ -497,6 +497,20 @@ export interface BookableSchedulesQuery {
destinationYardId?: string;
}
export interface AvailableDaysQuery {
originYardId?: string;
destinationYardId?: string;
}
/**
* Day-level booking pool: the EAT calendar days that have at least one OPEN
* departure on a route. `days` are `yyyy-MM-dd` strings, e.g. `["2026-06-20"]`.
* No capacity is exposed — customers pick a day, not a train.
*/
export interface AvailableDaysResponse {
days: string[];
}
export interface BookableScheduleLocomotive {
id: string;
code: string;