mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user