implement intercity booking management and booking window websocket integration

This commit is contained in:
Marshal
2026-07-06 13:28:21 +00:00
parent 907f4edc0a
commit fed5f2f43f
46 changed files with 1772 additions and 101 deletions

View File

@@ -176,6 +176,44 @@ export class BookingsService {
}
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
* one non-retired route passes the origin strictly before the destination in
* its milestone order — that is the corridor an import/export train can
* serve the booking on.
*/
private async assertIntercityCorridorExists(
originYardId: string,
destinationYardId: string,
): Promise<void> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
if (yards.some((y) => y.country !== 'Ethiopia')) {
throw new BadRequestException(
'Intercity bookings only run between Ethiopian yards',
);
}
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT r.id
FROM freight.routes r
JOIN freight.route_milestones mo
ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
JOIN freight.route_milestones md
ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
WHERE mo.sequence_no < md.sequence_no
AND r.status = 'AVAILABLE'
AND r.deleted_at IS NULL
LIMIT 1`,
[originYardId, destinationYardId],
);
if (rows.length === 0) {
throw new BadRequestException(
'No route passes through this origin and destination in order — intercity service is not available on this corridor',
);
}
}
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
@@ -607,6 +645,23 @@ export class BookingsService {
dto.tradeDirection,
);
// Intercity (DOMESTIC) bookings never get their own train — they ride on a
// passing import/export train, so there is no booking window and no date to
// pin. All we require at creation is that the corridor actually lies on a
// route (origin before destination in some route's milestone order); staff
// accept the booking onto a concrete train at finalize time.
if (tradeDirection === 'DOMESTIC') {
if (dto.scheduledDate || dto.trainScheduleId) {
throw new BadRequestException(
'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
);
}
await this.assertIntercityCorridorExists(
dto.originYardId,
dto.destinationYardId,
);
}
// Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.

View File

@@ -119,12 +119,27 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
const isIntercity = contract.tradeDirection === 'DOMESTIC';
if (isIntercity && dto.scheduledDate) {
throw new BadRequestException(
'Intercity bookings do not pick a date — staff assign them to a passing train',
);
}
// Every other direction keeps the binding shipment day (the DTO field went
// optional only for intercity).
if (!isIntercity && !dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms) {
if (!generalCustoms && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,

View File

@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository';
@@ -110,6 +114,49 @@ export class ContractsService {
}
}
/**
* Every route must match the contract's declared trade direction as derived
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
* (e.g. an export lane on an import contract) are rejected for every kind.
*/
private async assertRoutesMatchDirection(
tradeDirection: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const yardIds = [
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: yardIds.map((id) => ({ id })) });
const yardById = new Map(yards.map((y) => [y.id, y]));
for (const route of routes) {
const origin = yardById.get(route.originYardId);
const destination = yardById.get(route.destinationYardId);
if (!origin || !destination) {
throw new BadRequestException('Route references a yard that does not exist');
}
const derived = deriveTradeDirection(origin, destination);
if (derived !== tradeDirection) {
throw new BadRequestException(
`Route ${origin.label}${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
);
}
if (
derived === 'DOMESTIC' &&
(origin.country !== YardCountry.ETHIOPIA ||
destination.country !== YardCountry.ETHIOPIA)
) {
throw new BadRequestException(
`Route ${origin.label}${destination.label}: intercity service only runs between Ethiopian yards`,
);
}
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create(
dto: CreateContractDto,
@@ -144,6 +191,7 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
@@ -175,6 +223,13 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
@@ -360,6 +415,12 @@ export class ContractsService {
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
if (dto.routes) {
await this.assertRoutesMatchDirection(
dto.tradeDirection ?? existing.tradeDirection,
dto.routes,
);
}
const updates: Record<string, unknown> = {
contractKind,
@@ -385,6 +446,11 @@ export class ContractsService {
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null

View File

@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
@IsUUID()
contractRouteId?: string;
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
@ApiPropertyOptional({
description:
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
example: '2026-07-15',
})
@IsOptional()
@IsDateString()
scheduledDate!: string;
scheduledDate?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import type { ScheduleTradeDirection } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -26,6 +27,14 @@ export class Route extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
status!: RouteStatus;
/**
* Trade direction frozen from the yard countries at create/update
* (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity").
* Consumers (scheduling, booking windows) read this instead of re-deriving.
*/
@Column({ name: 'direction', type: 'varchar', length: 10 })
direction!: ScheduleTradeDirection;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -83,6 +84,7 @@ export class RoutesService {
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
status: dto.status ?? 'AVAILABLE',
direction: validated.direction,
}),
);
@@ -115,6 +117,7 @@ export class RoutesService {
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
});
@@ -187,9 +190,18 @@ export class RoutesService {
throw new BadRequestException('Origin and destination yards must be different');
}
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
);
return {
originYardId: normalized[0].yardId,
destinationYardId: normalized[normalized.length - 1].yardId,
originYardId,
destinationYardId,
direction,
milestones: normalized,
};
}

View File

@@ -1,5 +1,6 @@
import { YardCountry } from '@edr/types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -7,10 +8,9 @@ export class CreateYardDto {
@MaxLength(100)
label!: string;
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
@IsString()
@MaxLength(50)
country!: string;
@ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
@IsEnum(YardCountry)
country!: YardCountry;
@ApiPropertyOptional({ default: true })
@IsOptional()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'yards' })
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
@Column({ name: 'label', type: 'varchar', length: 100 })
label!: string;
// Constrained to YardCountry by DTO validation + a DB CHECK constraint;
// route/schedule trade direction is derived from this value. Typed as the
// enum's literal values so plain strings from seeds/queries still fit.
@Column({ name: 'country', type: 'varchar', length: 50 })
country!: string;
country!: `${YardCountry}`;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
/**
* Export booking window: a single FCFS window from `exportBookingLeadHours`
* before departure until departure. The open honours the daily desk hours —
* when the raw lead instant lands while the desk is shut, the window opens at
* the next desk opening instead (capped at departure, so a config whose desk
* never opens before the train leaves yields a zero-length window rather than
* one that outlives the train).
*/
export function computeExportWindowTimes(
departure: Date,
cfg: { exportBookingLeadHours: number },
cfg: {
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
},
): InitialWindowTimes {
return {
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
windowClosesAt: departure,
};
const rawOpen = new Date(
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
);
let opensAt = officeHoursOpen(rawOpen, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
}
/**
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
*
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
): BoardWindow[] {
if (direction === 'EXPORT') {
const start =
anchorOpensAt ??
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
return [boardWindowFromInterval(start, departure)];
}

View File

@@ -43,7 +43,7 @@ import { BookingSplitService } from './booking-split.service';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
export interface Capacity {
wagons: number;
weightTons: number;
lengthMeters: number;
@@ -1444,6 +1444,48 @@ export class BookingBatchService implements OnModuleInit {
await this.fillSchedule(booking.trainScheduleId);
}
// ---- intercity ride-along API ---------------------------------------------
/**
* Remaining capacity budget (wagons / weight / length) for a schedule, and
* the per-booking need calculator — exposed for the intercity accept flow,
* which reserves ride-along bookings onto import/export trains outside the
* batch engine.
*/
async intercityCapacity(scheduleId: string): Promise<{
budget: Capacity;
needFor: (booking: Booking) => Capacity;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
}
/**
* Accept an intercity booking onto the given train. Commercial bookings get
* the same pay-window lifecycle as a batch reservation (deadline, invoice
* due-date sync, pay-now notify, settle on the window tick), so payment →
* allocation needs no special path. Government bookings allocate directly.
*/
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
if (booking.isGovernment) {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'gov');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
}
// ---- mutations ------------------------------------------------------------
/**

View File

@@ -0,0 +1,77 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from '@edr/types';
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* Server → client push for booking-window state changes. Same handshake model
* as the notifications gateway: clients only listen, the token is verified on
* connect. Events are broadcast namespace-wide — window state is route-scoped
* public information for signed-in users, and clients filter/invalidate their
* own queries.
*/
@WebSocketGateway({
namespace: BOOKING_WINDOW_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class BookingWindowGateway implements OnGatewayConnection {
private readonly logger = new Logger(BookingWindowGateway.name);
@WebSocketServer()
private readonly server!: Server;
constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
if (!userId) {
this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
socket.disconnect(true);
return;
}
socket.data.userId = userId;
}
/** Push a schedule's current window state to every connected client. */
emitPhase(schedule: TrainSchedule): void {
const payload: BookingWindowPhaseEvent = {
scheduleId: schedule.id,
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
direction: schedule.direction ?? null,
phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
bookingCycleNo: schedule.bookingCycleNo,
windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === 'string') return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith('Bearer ')) return header.slice(7);
return undefined;
}
}

View File

@@ -9,6 +9,7 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
@@ -40,6 +41,7 @@ export class BookingWindowService implements OnModuleInit {
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService,
private readonly gateway: BookingWindowGateway,
) {}
async onModuleInit(): Promise<void> {
@@ -48,7 +50,10 @@ export class BookingWindowService implements OnModuleInit {
);
}
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
// 10-second cadence: every transition is derived from persisted timestamps
// and applied idempotently, so a finer tick only shrinks the lag between a
// deadline passing and the phase actually moving (was a full minute).
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
async tick(): Promise<void> {
if (this.ticking) return;
this.ticking = true;
@@ -89,9 +94,10 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations();
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
// (30 ticks at the 10-second cadence).
this.tickCount += 1;
if (this.tickCount % 5 === 0) {
if (this.tickCount % 30 === 0) {
await this.bookingBatchService.runBatchFill();
}
} finally {
@@ -151,6 +157,9 @@ export class BookingWindowService implements OnModuleInit {
? await this.advanceExport(schedule, now)
: await this.advanceImport(schedule, cfg, now);
if (!advanced) return;
// Push the new window state to portal home / backoffice GL sections so
// they refresh instantly instead of waiting out their poll interval.
this.gateway.emitPhase(schedule);
}
}
@@ -169,7 +178,9 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
await this.notifyWindowOpened(schedule);
// Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
// (the `ticking` guard would otherwise delay every schedule's transition).
void this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
@@ -216,7 +227,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'OPEN';
}
// Only announce the first opening of the day; reopen cycles don't re-notify.
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
);

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class AcceptIntercityBookingsDto {
@ApiProperty({
type: [String],
description:
'Waiting intercity booking ids to accept onto this train, in priority order',
})
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
bookingIds!: string[];
}

View File

@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
@IsInt()
@Min(0)
importWindowLeadDays?: number;
@ApiPropertyOptional({
example: 24,
description:
'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportBookingLeadHours?: number;
}

View File

@@ -0,0 +1,367 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingBatchService, type Capacity } from './booking-batch.service';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
/**
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
* train — they ride a passing import/export schedule whose route milestones
* contain the booking's origin strictly before its destination.
*
* Flow: the customer books a corridor with no date; at finalize time staff see
* every waiting intercity booking whose corridor lies on the schedule's route,
* with its wagon/weight/length need against the train's remaining capacity;
* accepting reserves it (pay window → payment → allocation, same lifecycle as
* a batch reservation). Cargo is loaded manually when the train reaches the
* booking's origin yard and unloaded at its destination yard.
*/
@Injectable()
export class IntercityService {
private readonly logger = new Logger(IntercityService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingBatchService: BookingBatchService,
) {}
/**
* Waiting intercity bookings this schedule could carry, with the train's
* remaining capacity along all three axes (wagons, weight, length) and each
* booking's need, so staff can pick what fits.
*/
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq
? await this.findWaitingIntercityBookings(milestoneSeq)
: [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
return {
scheduleId,
routeId: schedule.routeId ?? null,
remaining: capacity?.budget ?? null,
candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return {
...this.mapBooking(booking),
need,
fits: need && capacity ? fits(need, capacity.budget) : false,
};
}),
accepted: accepted.map((booking) => ({
...this.mapBooking(booking),
need: capacity?.needFor(booking) ?? null,
})),
};
}
/**
* Accept selected waiting intercity bookings onto this train, in the given
* order, each re-checked against the shrinking capacity budget. Commercial
* bookings open a pay window (payment → allocation runs on the existing
* settle lifecycle); government bookings allocate immediately.
*/
async acceptBookings(scheduleId: string, bookingIds: string[]) {
if (bookingIds.length === 0) {
throw new BadRequestException('Select at least one intercity booking');
}
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
if (!milestoneSeq) {
throw new BadRequestException(
'Schedule has no route milestones — cannot serve intercity corridors',
);
}
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
if (!capacity) {
throw new BadRequestException(
'Schedule has no locomotive/train set — capacity unknown',
);
}
const accepted: string[] = [];
const rejected: Array<{ bookingId: string; reason: string }> = [];
let budget = capacity.budget;
for (const bookingId of bookingIds) {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
if (!booking) {
rejected.push({ bookingId, reason: 'Booking not found' });
continue;
}
const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
if (notWaiting) {
rejected.push({ bookingId, reason: notWaiting });
continue;
}
const need = capacity.needFor(booking);
if (!fits(need, budget)) {
rejected.push({
bookingId,
reason: 'Does not fit the remaining wagon/weight/length capacity',
});
continue;
}
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
budget = subtract(budget, need);
accepted.push(bookingId);
this.logger.log(
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
);
}
return { accepted, rejected, remaining: budget };
}
/**
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
* the train is physically at the booking's origin yard: either it has not
* departed yet and the booking boards at the train's own origin, or the
* latest recorded checkpoint is at the booking's origin yard.
*/
async loadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'PAID') {
throw new BadRequestException(
`Booking must be paid before loading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'IN_TRANSIT' });
return { bookingId, status: 'IN_TRANSIT' as const };
}
/**
* Mark an intercity booking's cargo as unloaded at its destination yard —
* requires the latest checkpoint to be at that yard. Completes the booking.
*/
async unloadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'IN_TRANSIT') {
throw new BadRequestException(
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'COMPLETED' });
return { bookingId, status: 'COMPLETED' as const };
}
// ---- helpers ---------------------------------------------------------------
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return schedule;
}
/**
* yardId → sequenceNo for the schedule's route. Falls back to a two-stop
* origin/destination pseudo-route for legacy schedules without a routeId,
* so an intercity booking exactly matching the train's own corridor still
* qualifies.
*/
private async routeMilestoneSequence(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
}
if (schedule.originStationId && schedule.destinationStationId) {
return new Map([
[schedule.originStationId, 1],
[schedule.destinationStationId, 2],
]);
}
return null;
}
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>,
): Promise<Booking[]> {
const pool = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
}
/** Intercity bookings already reserved/allocated on this schedule. */
private async findAcceptedIntercityBookings(
scheduleId: string,
): Promise<Booking[]> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.created_at', 'ASC')
.getMany();
}
private corridorOnRoute(
booking: Booking,
milestoneSeq: Map<string, number>,
): boolean {
const originSeq = milestoneSeq.get(booking.originYardId);
const destinationSeq = milestoneSeq.get(booking.destinationYardId);
return (
originSeq != null && destinationSeq != null && originSeq < destinationSeq
);
}
private whyNotWaiting(
booking: Booking,
milestoneSeq: Map<string, number>,
): string | null {
if (booking.tradeDirection !== 'DOMESTIC') {
return 'Not an intercity booking';
}
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {
return "Corridor is not on this schedule's route";
}
return null;
}
private async getAcceptedBooking(scheduleId: string, bookingId: string) {
const schedule = await this.getSchedule(scheduleId);
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not assigned to this schedule');
}
if (booking.tradeDirection !== 'DOMESTIC') {
throw new BadRequestException('Not an intercity booking');
}
return { schedule, booking };
}
/**
* The train is "at" a yard when the latest recorded checkpoint is that yard,
* or — for a booking boarding at the train's own origin — when the train has
* not recorded any checkpoint yet (still sitting at its origin).
*/
private async assertTrainAtYard(
schedule: TrainSchedule,
yardId: string,
side: 'origin' | 'destination',
): Promise<void> {
const latest = await this.dataSource
.getRepository(TrainCheckpointEvent)
.findOne({
where: { trainScheduleId: schedule.id },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
if (!latest) {
if (side === 'origin' && schedule.originStationId === yardId) return;
throw new BadRequestException(
'Train has not reached this yard yet — record its checkpoint first',
);
}
if (latest.yardId !== yardId) {
throw new BadRequestException(
`Train's last recorded position is not at the booking's ${side} yard`,
);
}
}
private mapBooking(booking: Booking) {
return {
id: booking.id,
reference: booking.reference,
status: booking.status,
freightType: booking.freightType,
isGovernment: booking.isGovernment,
customer: booking.company?.name ?? 'Unknown customer',
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
origin:
booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}
}
function fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&
need.weightTons <= budget.weightTons &&
need.lengthMeters <= budget.lengthMeters
);
}
function subtract(budget: Capacity, need: Capacity): Capacity {
return {
wagons: budget.wagons - need.wagons,
weightTons: budget.weightTons - need.weightTons,
lengthMeters: budget.lengthMeters - need.lengthMeters,
};
}

View File

@@ -20,6 +20,7 @@ import {
TrainSchedulingManage,
TrainSchedulingView,
} from "../../common/booking-guards";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingWindowService } from "./booking-window.service";
import { IntercityService } from "./intercity.service";
import { BillingService } from "../billing/billing.service";
@ApiTags("train-scheduling")
@@ -57,6 +59,7 @@ export class TrainSchedulingController {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly bookingWindowService: BookingWindowService,
private readonly intercityService: IntercityService,
private readonly billingService: BillingService,
) { }
@@ -406,6 +409,54 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id);
}
@Get("schedules/:id/intercity-candidates")
@TrainSchedulingView()
@ApiOperation({
summary:
"Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity",
})
getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) {
return this.intercityService.listCandidates(id);
}
@Post("schedules/:id/intercity/accept")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
})
acceptIntercityBookings(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntercityBookingsDto,
) {
return this.intercityService.acceptBookings(id, dto.bookingIds);
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingManage()
@ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
})
loadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.loadBooking(id, bookingId);
}
@Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
})
unloadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.unloadBooking(id, bookingId);
}
@Get("schedules/:id/import-djibouti")
@TrainSchedulingView()
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })

View File

@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
@@ -25,7 +26,10 @@ import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingWindowService } from './booking-window.service';
import { IntercityService } from './intercity.service';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingSplitService } from './booking-split.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -46,6 +50,8 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEvent,
ImportDjiboutiOperation,
BookingBatchOffer,
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
Session,
]),
forwardRef(() => BookingsModule),
BillingModule,
@@ -64,8 +70,11 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEventsRepository,
BookingBatchService,
BookingNotifierService,
BookingWindowGateway,
WsAuthService,
BookingWindowService,
BookingSplitService,
IntercityService,
],
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
})

View File

@@ -408,7 +408,9 @@ export class TrainSchedulingService {
schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
dto.exportBookingLeadHours ??
schedule.ruleExportBookingLeadHours ??
liveCfg.exportBookingLeadHours,
windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour:
@@ -432,6 +434,12 @@ export class TrainSchedulingService {
schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
throw new BadRequestException(
'These settings leave no booking window before departure — with the ' +
'desk hours applied, the window would only open once the train has left.',
);
}
await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt,
@@ -686,10 +694,9 @@ export class TrainSchedulingService {
lockedLocomotives.push(locked);
}
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
// Frozen on the route at create/update from the yard-country enum;
// getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set.
@@ -3447,9 +3454,27 @@ export class TrainSchedulingService {
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
);
}
// Intercity (same-country) service is not offered yet — only import/export
// trains can be scheduled.
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
);
}
return route;
}
/** Stored route direction, deriving from yard countries for pre-migration rows. */
private resolveRouteDirection(route: Route) {
return (
route.direction ??
deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
)
);
}
private mapEligibleBooking(booking: Booking) {
return {
id: booking.id,
@@ -4054,6 +4079,7 @@ export class TrainSchedulingService {
: null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
},