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;
|
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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 { 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" })
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user