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; destinationStationId?: string;
schedulingStatus?: string; schedulingStatus?: string;
trainScheduleId?: 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[]> { }): Promise<Booking[]> {
const qb = this.repository const qb = this.repository
.createQueryBuilder('booking') .createQueryBuilder('booking')
@@ -706,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.where('booking.status = :paidStatus', { paidStatus: 'PAID' }) .where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL'); .andWhere('scheduleBooking.id IS NULL');
// Mirror the automatic batch pool: a schedule only ever considers bookings that // Day-level pooling: customers no longer set train_schedule_id, so the wizard
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter). // surfaces the whole (route, EAT day) pool. Fall back to the legacy
if (options.trainScheduleId) { // 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', { qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
trainScheduleId: options.trainScheduleId, trainScheduleId: options.trainScheduleId,
}); });
@@ -765,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany(); .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. */ /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> { findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository return this.repository

View File

@@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -273,8 +274,8 @@ export class BookingsService {
companyId = company.id; companyId = company.id;
} }
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
if (dto.trainScheduleId) { if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource const schedule = await this.dataSource
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } }); .findOne({ where: { id: dto.trainScheduleId } });
@@ -290,6 +291,22 @@ export class BookingsService {
) { ) {
throw new BadRequestException('Selected schedule is not on the booking route'); 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()); const reference = dto.reference || (await this.generateReference());

View File

@@ -91,12 +91,21 @@ export class CreateBookingDto {
@IsUUID() @IsUUID()
trainId?: string; 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() @IsOptional()
@IsUUID() @IsUUID()
trainScheduleId?: string; trainScheduleId?: string;
/** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) @ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString() @IsDateString()
scheduledDate!: string; scheduledDate!: string;

View File

@@ -277,7 +277,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null; 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 }) @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null; 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. */ /** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
function eatToUtc( function eatToUtc(
year: number, year: number,

View File

@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
let bookingsRepository: { let bookingsRepository: {
findPaidUnlinkedForSchedule: jest.Mock; findPaidUnlinkedForSchedule: jest.Mock;
findBatchPool: jest.Mock; findBatchPool: jest.Mock;
findBatchPoolByRouteDay: jest.Mock;
findReservedForSchedule: jest.Mock; findReservedForSchedule: jest.Mock;
update: jest.Mock; update: jest.Mock;
}; };
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
}; };
let trainSchedulingService: { let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock; tryAutoWagonAllocation: jest.Mock;
getBookableSchedules: jest.Mock;
}; };
let dataSource: { let dataSource: {
getRepository: jest.Mock; getRepository: jest.Mock;
transaction: jest.Mock; transaction: jest.Mock;
}; };
let notifier: {
payNow: jest.Mock;
secured: jest.Mock;
expired: jest.Mock;
unplaced: jest.Mock;
};
beforeEach(() => { beforeEach(() => {
bookingsRepository = { bookingsRepository = {
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
findBatchPool: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]),
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
findReservedForSchedule: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
}; };
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [], issues: [],
violations: [], violations: [],
}), }),
getBookableSchedules: jest.fn().mockResolvedValue([]),
}; };
const bookingRepo = { const bookingRepo = {
findOne: jest.fn().mockResolvedValue(paidBooking), findOne: jest.fn().mockResolvedValue(paidBooking),
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
// WagonType.find() / global-rules find() fall back to defaults when empty.
find: jest.fn().mockResolvedValue([]),
}; };
dataSource = { dataSource = {
getRepository: jest.fn().mockReturnValue(bookingRepo), 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( service = new BookingBatchService(
dataSource as never, dataSource as never,
bookingsRepository as never, bookingsRepository as never,
trainSchedulesRepository as never, trainSchedulesRepository as never,
trainScheduleBookingsRepository 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, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never, trainSchedulingService as never,
); );
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(fillOrder).toBeLessThan(reconcileOrder); expect(fillOrder).toBeLessThan(reconcileOrder);
expect(reconcileOrder).toBeLessThan(wagonOrder); 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 { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from './train-scheduling.service';
import { groupBookingsIntoBoardWindows } from './batch-window.util'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { import {
BATCH_CRON, BATCH_CRON,
BATCH_TIMEZONE, BATCH_TIMEZONE,
@@ -42,6 +42,14 @@ interface Capacity {
lengthMeters: number; 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 }; type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState = export type BatchBoardBookingState =
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainSchedulingService: TrainSchedulingService, 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> { async onModuleInit(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({ const groups = await this.openRouteDayGroups();
where: { bookingWindowStatus: 'OPEN' }, for (const group of groups) {
});
for (const s of open) {
try { try {
await this.processSchedule(s.id); await this.processRouteDay(group);
} catch (err) { } 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 const reserved = await this.dataSource
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
for (const { scheduleId } of reserved) this.armSettle(scheduleId); 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 { enqueueScheduleProcessing(scheduleId: string): void {
void this.processSchedule(scheduleId).catch((err) => void this.processRouteDayForSchedule(scheduleId).catch((err) =>
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`), 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. */ /** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise<void> { async processSchedule(scheduleId: string): Promise<void> {
await this.fillSchedule(scheduleId); await this.fillSchedule(scheduleId);
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); 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. * Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases. * 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 }) @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
async runBatchFill(): Promise<void> { async runBatchFill(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({ const groups = await this.openRouteDayGroups();
where: { bookingWindowStatus: 'OPEN' }, this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
}); for (const group of groups) {
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
for (const s of open) {
try { try {
await this.processSchedule(s.id); await this.processRouteDay(group);
} catch (err) { } 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) { if (booking.isGovernment) {
await this.allocate(scheduleId, booking, 'gov'); await this.allocate(scheduleId, booking, 'gov');
} else { } else {
await this.reserve(booking); await this.reserve(booking, scheduleId);
armed = true; armed = true;
} }
budget = this.subtract(budget, need); budget = this.subtract(budget, need);
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId); 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. */ /** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> { async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
// ---- mutations ------------------------------------------------------------ // ---- 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 now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: 'SELECTED_FOR_BATCH', status: 'SELECTED_FOR_BATCH',
selectedForBatchAt: now, selectedForBatchAt: now,
paymentDeadline: deadline, paymentDeadline: deadline,
} as never); } as never);
booking.trainScheduleId = scheduleId;
await this.notifier.payNow(booking, deadline); await this.notifier.payNow(booking, deadline);
} }
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(scheduleId); 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> { private async expire(booking: Booking): Promise<void> {
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: 'EXPIRED', status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE', schedulingStatus: 'ELIGIBLE',
paymentDeadline: null, paymentDeadline: null,
selectedForBatchAt: null, selectedForBatchAt: null,
} as never); } as never);
booking.trainScheduleId = null;
this.notifier.expired(booking); 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 { displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; 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'); 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 { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-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 { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service"; import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.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") @Get("container/eligible-bookings")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ summary: "List eligible container bookings" }) @ApiOperation({ summary: "List eligible container bookings" })

View File

@@ -87,6 +87,7 @@ import {
DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants'; } from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
) {} ) {}
async getEligibleBookings(query: GetEligibleBookingsDto) { 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({ const bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType, freightType: query.freightType,
originStationId: query.originStationId, originStationId,
destinationStationId: query.destinationStationId, destinationStationId,
schedulingStatus: query.schedulingStatus, schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId, trainScheduleId: query.trainScheduleId,
day,
}); });
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
} }
@@ -1989,6 +2006,33 @@ export class TrainSchedulingService {
return filteredSchedules; 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( private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, 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 allLinesValid = lines.length > 0 && lines.every(lineValid);
const sameYard = Boolean(originYardId && originYardId === destinationYardId); const sameYard = Boolean(originYardId && originYardId === destinationYardId);
const scheduleSatisfied = // Day-level pool: a shipment DAY is enough to proceed. Pinning a specific train
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate); // (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 departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
const canSubmit = const canSubmit =
@@ -311,7 +312,6 @@ export default function NewBookingPage() {
Boolean(destinationYardId) && Boolean(destinationYardId) &&
!sameYard && !sameYard &&
Boolean(tradeDirection) && Boolean(tradeDirection) &&
scheduleSatisfied &&
Boolean(serviceTypeId) && Boolean(serviceTypeId) &&
departureSatisfied && departureSatisfied &&
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) && (isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
@@ -473,25 +473,25 @@ export default function NewBookingPage() {
</Group> </Group>
{hasBookableSchedules ? ( {hasBookableSchedules ? (
<Select <Select
label="Train schedule" label="Train schedule (optional)"
placeholder={ placeholder={
originYardId && destinationYardId originYardId && destinationYardId
? "Select an open schedule on this route" ? "Leave blank to let the batch engine assign a train"
: "Pick origin & destination first" : "Pick origin & destination first"
} }
data={scheduleOptions} data={scheduleOptions}
value={trainScheduleId} value={trainScheduleId}
onChange={setTrainScheduleId} onChange={setTrainScheduleId}
searchable searchable
required clearable
disabled={!originYardId || !destinationYardId || schedulesLoading} disabled={!originYardId || !destinationYardId || schedulesLoading}
nothingFoundMessage="No open schedules on this route" 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 ? ( ) : originYardId && destinationYardId ? (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No open train schedule on this route set a preferred departure below. Staff can No open train schedule on this route set a preferred departure below. The batch
link a schedule later. engine assigns a train on that day, or staff can pin one later.
</Text> </Text>
) : null} ) : null}
<Group grow align="flex-end"> <Group grow align="flex-end">

View File

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

View File

@@ -143,7 +143,6 @@ function mapBookingToFormValues(
scheduledDate: booking.scheduledDate scheduledDate: booking.scheduledDate
? new Date(booking.scheduledDate).toISOString().slice(0, 10) ? new Date(booking.scheduledDate).toISOString().slice(0, 10)
: "", : "",
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
notes: "", notes: "",
containers: [], containers: [],
} as BookingFormInputValues; } as BookingFormInputValues;
@@ -410,7 +409,8 @@ export default function EditBookingPage() {
scheduledDate: data.scheduledDate scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString() ? new Date(data.scheduledDate).toISOString()
: undefined, : 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: contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"], data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId, serviceTypeId: data.serviceTypeId,

View File

@@ -279,7 +279,8 @@ export default function NewBookingPage() {
destinationYardId: data.destinationYard, destinationYardId: data.destinationYard,
tradeDirection: direction!, tradeDirection: direction!,
cargoTypeId, 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, cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous, isHazardous: data.isHazardous,
allowConsolidation: data.consolidationEnabled, allowConsolidation: data.consolidationEnabled,

View File

@@ -113,8 +113,9 @@ export const bookingFormSchema = z
originYard: z.string().min(1, "Select an origin yard."), originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."), destinationYard: z.string().min(1, "Select a destination yard."),
shippingLine: z.string(), 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."), 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."), cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(), cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]), cargoTypePath: z.array(z.string()).default([]),
@@ -236,7 +237,6 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "", destinationYard: "",
shippingLine: "", shippingLine: "",
scheduledDate: "", scheduledDate: "",
trainScheduleId: "",
cargoWeight: "", cargoWeight: "",
cargoTypePath: [], cargoTypePath: [],
cargoFreeText: "", cargoFreeText: "",
@@ -272,7 +272,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"containers", "containers",
"consolidationEnabled", "consolidationEnabled",
], ],
5: ["scheduledDate", "trainScheduleId"], 5: ["scheduledDate"],
6: ["documents"], 6: ["documents"],
7: ["notes"], 7: ["notes"],
}; };

View File

@@ -5,10 +5,9 @@ import {
Button, Button,
Card, Card,
Group, Group,
Modal,
Stack, Stack,
Text, Text,
useMantineTheme useMantineTheme,
} from "@mantine/core"; } from "@mantine/core";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
@@ -46,17 +45,15 @@ interface DayData {
isToday: boolean; isToday: boolean;
isCurrentMonth: boolean; isCurrentMonth: boolean;
isSelectedDate: boolean; isSelectedDate: boolean;
schedules: Freight.BookableScheduleItem[]; /** True when the route has at least one departure on this day. */
hasSchedule: boolean; hasDeparture: boolean;
} }
export function StepScheduling({ form, referenceData }: StepSchedulingProps) { export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const theme = useMantineTheme(); const theme = useMantineTheme();
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
const selectedDate = form.watch("scheduledDate"); const selectedDate = form.watch("scheduledDate");
const selectedScheduleId = form.watch("trainScheduleId");
const originYardId = form.watch("originYard"); const originYardId = form.watch("originYard");
const destinationYardId = form.watch("destinationYard"); const destinationYardId = form.watch("destinationYard");
const cargoType = form.watch("cargoType"); const cargoType = form.watch("cargoType");
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
[referenceData, destinationYardId], [referenceData, destinationYardId],
); );
const { data: bookableSchedules } = useQuery( // Day-level pool: the customer picks a DAY, not a train. We only fetch which
api.bookings.getBookableSchedules.queryOptions({ // 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 }, input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId, enabled: !!originYardId && !!destinationYardId,
}), }),
); );
// Group all schedules per date — multiple departures per day are allowed. const departureDays = useMemo(
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to () => new Set(availableDays ?? []),
// match the format used by the calendar day keys. [availableDays],
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 availableCount = useMemo(() => {
let count = 0;
schedulesByDate.forEach((schedules) => {
if (schedules.some((s) => s.remainingWagons > 0)) count++;
});
return count;
}, [schedulesByDate]);
const days = useMemo((): DayData[] => { const days = useMemo((): DayData[] => {
const monthStart = startOfMonth(currentDate); const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate); const monthEnd = endOfMonth(currentDate);
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => { return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
const dateString = format(date, "yyyy-MM-dd"); const dateString = format(date, "yyyy-MM-dd");
const schedules = schedulesByDate.get(dateString) ?? [];
return { return {
day: date.getDate(), day: date.getDate(),
dateString, dateString,
isToday: isToday(date), isToday: isToday(date),
isCurrentMonth: isSameMonth(date, currentDate), isCurrentMonth: isSameMonth(date, currentDate),
isSelectedDate: selectedDate === dateString, isSelectedDate: selectedDate === dateString,
schedules, hasDeparture: departureDays.has(dateString),
hasSchedule: schedules.length > 0,
}; };
}); });
}, [currentDate, schedulesByDate, selectedDate]); }, [currentDate, departureDays, selectedDate]);
const availableCount = useMemo(
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
[days],
);
const cargoSummary = useMemo(() => { const cargoSummary = useMemo(() => {
if (!cargoType) return "Not selected"; if (!cargoType) return "Not selected";
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
const weeksCount = Math.ceil(days.length / 7); const weeksCount = Math.ceil(days.length / 7);
const handleDayClick = (day: DayData) => { const handleDayClick = (day: DayData) => {
if (day.schedules.length > 1) { if (!day.hasDeparture) return;
setSelectedDayForModal(day); // Record only the day — no specific train is chosen.
} else if (day.schedules.length === 1) { form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
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);
}
}; };
return ( return (
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Stack gap={14} px={24} py={18}> <Stack gap={14} px={24} py={18}>
<Text fz={13} fw={600} c="edr-text.0"> <Text fz={13} fw={600} c="edr-text.0">
{originYardId && destinationYardId {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"} : "Select origin and destination to see available departures"}
</Text> </Text>
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
}} }}
> >
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => ( {days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
<DayCell <DayCell key={di} day={d} onDayClick={handleDayClick} />
key={di}
day={d}
onDayClick={handleDayClick}
/>
))} ))}
</Box> </Box>
))} ))}
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
value={cargoSummary} value={cargoSummary}
/> />
{selectedSchedule && selectedDate && ( {selectedDate && (
<Box <Box
p={14} p={14}
style={{ style={{
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
c="edr-green.7" c="edr-green.7"
style={{ letterSpacing: "0.08em" }} style={{ letterSpacing: "0.08em" }}
> >
SELECTED DEPARTURE SELECTED DAY
</Text> </Text>
</Group> </Group>
<Text fw={800} fz={16} c="edr-text.0"> <Text fw={800} fz={16} c="edr-text.0">
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")} {format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
</Text> </Text>
<Group justify="space-between"> <Text fz={12.5} c="edr-muted">
<Text fz={12.5} c="edr-muted"> Your train is confirmed by our freight desk after booking.
Train </Text>
</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> </Stack>
</Box> </Box>
)} )}
@@ -380,154 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
</Stack> </Stack>
</Box> </Box>
</Stack> </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> </Group>
); );
} }
@@ -537,7 +332,7 @@ interface DayCellProps {
onDayClick: (day: DayData) => void; onDayClick: (day: DayData) => void;
} }
function DayCell({ day: d, onDayClick, }: DayCellProps) { function DayCell({ day: d, onDayClick }: DayCellProps) {
const theme = useMantineTheme(); const theme = useMantineTheme();
if (!d.isCurrentMonth) { if (!d.isCurrentMonth) {
@@ -561,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
const cellBg = d.isSelectedDate const cellBg = d.isSelectedDate
? theme.colors["edr-soft"][0] ? theme.colors["edr-soft"][0]
: d.hasSchedule : d.hasDeparture
? "#FFFFFF" ? "#FFFFFF"
: "transparent"; : "transparent";
const cellBorder = d.isSelectedDate const cellBorder = d.isSelectedDate
? `2px solid ${theme.colors["edr-green"][5]}` ? `2px solid ${theme.colors["edr-green"][5]}`
: d.hasSchedule : d.hasDeparture
? `1px solid ${theme.colors["edr-border"][0]}` ? `1px solid ${theme.colors["edr-border"][0]}`
: "none"; : "none";
return ( return (
<Box <Box
onClick={() => d.hasSchedule && onDayClick(d)} onClick={() => d.hasDeparture && onDayClick(d)}
style={{ style={{
height: 92, height: 92,
borderRadius: theme.radius.md, borderRadius: theme.radius.md,
@@ -584,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 4, gap: 4,
cursor: d.hasSchedule ? "pointer" : "default", cursor: d.hasDeparture ? "pointer" : "default",
transition: "all 150ms ease", 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) => { 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.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0]; e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
} }
}} }}
onMouseLeave={(e) => { 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.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = theme.colors["edr-border"][0]; e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
} }
@@ -610,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
c={ c={
d.isToday && !d.isSelectedDate d.isToday && !d.isSelectedDate
? "edr-green.6" ? "edr-green.6"
: d.hasSchedule : d.hasDeparture
? "edr-text.0" ? "edr-text.0"
: "edr-muted" : "edr-muted"
} }
@@ -635,50 +430,24 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
justifyContent: "center", justifyContent: "center",
}} }}
> >
<Check <Check size={14} color="white" strokeWidth={3} />
size={14}
color="white"
strokeWidth={3}
/>
</Box> </Box>
)} )}
</Group> </Group>
{/* Availability marker — a dot + count, never the schedule list itself. */} {/* Availability marker — a single dot for days that have a departure.
{d.hasSchedule && ( No counts or capacity are shown: it's a day-level pool. */}
{d.hasDeparture && (
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}> <Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
<Group <Box
gap={6}
align="center"
wrap="nowrap"
px={9}
py={4}
style={{ style={{
borderRadius: 999, width: 8,
backgroundColor: d.isSelectedDate height: 8,
? "#fff" borderRadius: "50%",
: theme.colors["edr-soft"][0], backgroundColor: theme.colors["edr-green"][5],
border: `1px solid ${ boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
d.isSelectedDate
? theme.colors["edr-green"][2]
: "transparent"
}`,
}} }}
> />
<Box
style={{
width: 7,
height: 7,
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>
)} )}
</Box> </Box>

View File

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

View File

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

View File

@@ -195,4 +195,18 @@ export const bookingsService = {
); );
return data.data; 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; 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 { export interface BookableScheduleLocomotive {
id: string; id: string;
code: string; code: string;