mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
implement intercity booking management and booking window websocket integration
This commit is contained in:
@@ -1,20 +1,33 @@
|
|||||||
import type { ScheduleTradeDirection } from '@edr/types';
|
import { YardCountry, type ScheduleTradeDirection } from '@edr/types';
|
||||||
|
|
||||||
type YardLike = { country?: string | null };
|
type YardLike = { country?: string | null };
|
||||||
|
|
||||||
/** Derive booking/schedule trade direction from origin and destination yard countries. */
|
/**
|
||||||
|
* Derive trade direction from origin and destination yard countries.
|
||||||
|
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country =
|
||||||
|
* DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now).
|
||||||
|
* Comparison is strict against the YardCountry enum values the yards table is
|
||||||
|
* constrained to; the trim/case fold only shields legacy rows.
|
||||||
|
*/
|
||||||
export function deriveTradeDirection(
|
export function deriveTradeDirection(
|
||||||
originYard: YardLike,
|
originYard: YardLike,
|
||||||
destinationYard: YardLike,
|
destinationYard: YardLike,
|
||||||
): ScheduleTradeDirection {
|
): ScheduleTradeDirection {
|
||||||
const originCountry = originYard.country?.trim().toLowerCase();
|
const origin = normalizeCountry(originYard.country);
|
||||||
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
const destination = normalizeCountry(destinationYard.country);
|
||||||
|
|
||||||
if (originCountry === 'djibouti') {
|
if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) {
|
||||||
return 'IMPORT';
|
return 'IMPORT';
|
||||||
}
|
}
|
||||||
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) {
|
||||||
return 'EXPORT';
|
return 'EXPORT';
|
||||||
}
|
}
|
||||||
return 'DOMESTIC';
|
return 'DOMESTIC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCountry(country: string | null | undefined): YardCountry | null {
|
||||||
|
const folded = country?.trim().toLowerCase();
|
||||||
|
if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA;
|
||||||
|
if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
|
||||||
|
* freezes its trade direction from the yard countries:
|
||||||
|
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
|
||||||
|
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
|
||||||
|
* and contracts for now).
|
||||||
|
*
|
||||||
|
* Existing yard rows are normalized case-insensitively; anything mentioning
|
||||||
|
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
|
||||||
|
* these two countries). A CHECK constraint keeps future writes honest.
|
||||||
|
*/
|
||||||
|
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
|
||||||
|
name = 'YardCountryEnumAndRouteDirection1980000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.yards
|
||||||
|
SET country = CASE
|
||||||
|
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
|
||||||
|
ELSE 'Ethiopia'
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yards
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_yards_country,
|
||||||
|
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.routes
|
||||||
|
ADD COLUMN IF NOT EXISTS direction varchar(10)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.routes r
|
||||||
|
SET direction = CASE
|
||||||
|
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
|
||||||
|
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
|
||||||
|
ELSE 'DOMESTIC'
|
||||||
|
END
|
||||||
|
FROM freight.yards o, freight.yards d
|
||||||
|
WHERE o.id = r.origin_yard_id
|
||||||
|
AND d.id = r.destination_yard_id
|
||||||
|
`);
|
||||||
|
// Orphan origin/destination (deleted yard) — no way to classify; park as
|
||||||
|
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.routes
|
||||||
|
ALTER COLUMN direction SET NOT NULL,
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_routes_direction,
|
||||||
|
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.routes
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_routes_direction,
|
||||||
|
DROP COLUMN IF EXISTS direction
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,44 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
/** 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(
|
private async resolveTradeDirectionForBooking(
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
destinationYardId: string,
|
destinationYardId: string,
|
||||||
@@ -607,6 +645,23 @@ export class BookingsService {
|
|||||||
dto.tradeDirection,
|
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)
|
// Stamp the operational profile this booking belongs to (importer/exporter)
|
||||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
// 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.
|
// for non-government bookings with a resolved company; never blocks creation.
|
||||||
|
|||||||
@@ -119,12 +119,27 @@ export class ContractBookingService {
|
|||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
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
|
// 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
|
// created while the route's booking window is open — import: the day's window
|
||||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||||
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
||||||
// enter clearance first and are scheduled later, so they are not gated here.
|
// enter clearance first and are scheduled later, so they are not gated here.
|
||||||
if (!generalCustoms) {
|
if (!generalCustoms && !isIntercity) {
|
||||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
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 { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.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 { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
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. */
|
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||||
async create(
|
async create(
|
||||||
dto: CreateContractDto,
|
dto: CreateContractDto,
|
||||||
@@ -144,6 +191,7 @@ export class ContractsService {
|
|||||||
|
|
||||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||||
|
|
||||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
@@ -175,6 +223,13 @@ export class ContractsService {
|
|||||||
|
|
||||||
// Customs clearing is owned by the service type, not the customer.
|
// Customs clearing is owned by the service type, not the customer.
|
||||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
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
|
// An explicit reference is caller-chosen — a collision there is a real
|
||||||
// conflict and should surface. Auto-generated references retry past a
|
// 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.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
|
||||||
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
|
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> = {
|
const updates: Record<string, unknown> = {
|
||||||
contractKind,
|
contractKind,
|
||||||
@@ -385,6 +446,11 @@ export class ContractsService {
|
|||||||
const includesCustoms = await this.resolveIncludesCustoms(
|
const includesCustoms = await this.resolveIncludesCustoms(
|
||||||
dto.serviceTypeId ?? existing.serviceTypeId,
|
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.customsClearingEnabled = includesCustoms;
|
||||||
updates.customsClearingAgent = includesCustoms
|
updates.customsClearingAgent = includesCustoms
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
contractRouteId?: string;
|
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()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
scheduledDate?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import type { ScheduleTradeDirection } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
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' })
|
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
|
||||||
status!: RouteStatus;
|
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 })
|
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||||
milestones?: RouteMilestone[];
|
milestones?: RouteMilestone[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { CreateRouteDto } from './dto/create-route.dto';
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
@@ -83,6 +84,7 @@ export class RoutesService {
|
|||||||
originYardId: validated.originYardId,
|
originYardId: validated.originYardId,
|
||||||
destinationYardId: validated.destinationYardId,
|
destinationYardId: validated.destinationYardId,
|
||||||
status: dto.status ?? 'AVAILABLE',
|
status: dto.status ?? 'AVAILABLE',
|
||||||
|
direction: validated.direction,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ export class RoutesService {
|
|||||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||||
destinationYardId:
|
destinationYardId:
|
||||||
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||||
|
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,9 +190,18 @@ export class RoutesService {
|
|||||||
throw new BadRequestException('Origin and destination yards must be different');
|
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 {
|
return {
|
||||||
originYardId: normalized[0].yardId,
|
originYardId,
|
||||||
destinationYardId: normalized[normalized.length - 1].yardId,
|
destinationYardId,
|
||||||
|
direction,
|
||||||
milestones: normalized,
|
milestones: normalized,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { YardCountry } from '@edr/types';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class CreateYardDto {
|
||||||
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
||||||
@@ -7,10 +8,9 @@ export class CreateYardDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
label!: string;
|
label!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
|
@ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
|
||||||
@IsString()
|
@IsEnum(YardCountry)
|
||||||
@MaxLength(50)
|
country!: YardCountry;
|
||||||
country!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { YardCountry } from '@edr/types';
|
||||||
import { Column, Entity, Index } from 'typeorm';
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'yards' })
|
@Entity({ schema: 'freight', name: 'yards' })
|
||||||
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
|
|||||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||||
label!: string;
|
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 })
|
@Column({ name: 'country', type: 'varchar', length: 50 })
|
||||||
country!: string;
|
country!: `${YardCountry}`;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|||||||
@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
|
|||||||
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
|
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(
|
export function computeExportWindowTimes(
|
||||||
departure: Date,
|
departure: Date,
|
||||||
cfg: { exportBookingLeadHours: number },
|
cfg: {
|
||||||
|
exportBookingLeadHours: number;
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowCloseHour: number;
|
||||||
|
},
|
||||||
): InitialWindowTimes {
|
): InitialWindowTimes {
|
||||||
return {
|
const rawOpen = new Date(
|
||||||
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
|
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
|
||||||
windowClosesAt: departure,
|
);
|
||||||
};
|
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
|
* after each close, on the same booking day, until departure. This mirrors
|
||||||
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||||
* exact windows the engine runs.
|
* 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
|
* `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
|
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
|
||||||
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
|
|||||||
): BoardWindow[] {
|
): BoardWindow[] {
|
||||||
if (direction === 'EXPORT') {
|
if (direction === 'EXPORT') {
|
||||||
const start =
|
const start =
|
||||||
anchorOpensAt ??
|
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
|
||||||
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
|
|
||||||
return [boardWindowFromInterval(start, departure)];
|
return [boardWindowFromInterval(start, departure)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { BookingSplitService } from './booking-split.service';
|
|||||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
interface Capacity {
|
export interface Capacity {
|
||||||
wagons: number;
|
wagons: number;
|
||||||
weightTons: number;
|
weightTons: number;
|
||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
@@ -1444,6 +1444,48 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.fillSchedule(booking.trainScheduleId);
|
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 ------------------------------------------------------------
|
// ---- mutations ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
|
|||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
||||||
@@ -40,6 +41,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly gateway: BookingWindowGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
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> {
|
async tick(): Promise<void> {
|
||||||
if (this.ticking) return;
|
if (this.ticking) return;
|
||||||
this.ticking = true;
|
this.ticking = true;
|
||||||
@@ -89,9 +94,10 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
|
|
||||||
await this.settleOverdueReservations();
|
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;
|
this.tickCount += 1;
|
||||||
if (this.tickCount % 5 === 0) {
|
if (this.tickCount % 30 === 0) {
|
||||||
await this.bookingBatchService.runBatchFill();
|
await this.bookingBatchService.runBatchFill();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,6 +157,9 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
? await this.advanceExport(schedule, now)
|
? await this.advanceExport(schedule, now)
|
||||||
: await this.advanceImport(schedule, cfg, now);
|
: await this.advanceImport(schedule, cfg, now);
|
||||||
if (!advanced) return;
|
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');
|
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||||
schedule.bookingWindowStatus = '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}`);
|
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -216,7 +227,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
schedule.bookingWindowStatus = 'OPEN';
|
schedule.bookingWindowStatus = 'OPEN';
|
||||||
}
|
}
|
||||||
// Only announce the first opening of the day; reopen cycles don't re-notify.
|
// 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(
|
this.logger.log(
|
||||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
importWindowLeadDays?: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
TrainSchedulingManage,
|
TrainSchedulingManage,
|
||||||
TrainSchedulingView,
|
TrainSchedulingView,
|
||||||
} from "../../common/booking-guards";
|
} from "../../common/booking-guards";
|
||||||
|
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
||||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.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 { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
import { BookingWindowService } from "./booking-window.service";
|
import { BookingWindowService } from "./booking-window.service";
|
||||||
|
import { IntercityService } from "./intercity.service";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
|
||||||
@ApiTags("train-scheduling")
|
@ApiTags("train-scheduling")
|
||||||
@@ -57,6 +59,7 @@ export class TrainSchedulingController {
|
|||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly bookingWindowService: BookingWindowService,
|
private readonly bookingWindowService: BookingWindowService,
|
||||||
|
private readonly intercityService: IntercityService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -406,6 +409,54 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
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")
|
@Get("schedules/:id/import-djibouti")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
|
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||||
|
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
@@ -25,7 +26,10 @@ import { TrainSchedulingController } from './train-scheduling.controller';
|
|||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { BookingWindowService } from './booking-window.service';
|
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 { BookingSplitService } from './booking-split.service';
|
||||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
@@ -46,6 +50,8 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
TrainCheckpointEvent,
|
TrainCheckpointEvent,
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
BookingBatchOffer,
|
BookingBatchOffer,
|
||||||
|
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
||||||
|
Session,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
@@ -64,8 +70,11 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
TrainCheckpointEventsRepository,
|
TrainCheckpointEventsRepository,
|
||||||
BookingBatchService,
|
BookingBatchService,
|
||||||
BookingNotifierService,
|
BookingNotifierService,
|
||||||
|
BookingWindowGateway,
|
||||||
|
WsAuthService,
|
||||||
BookingWindowService,
|
BookingWindowService,
|
||||||
BookingSplitService,
|
BookingSplitService,
|
||||||
|
IntercityService,
|
||||||
],
|
],
|
||||||
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
|
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -408,7 +408,9 @@ export class TrainSchedulingService {
|
|||||||
schedule.ruleImportWindowLeadDays ??
|
schedule.ruleImportWindowLeadDays ??
|
||||||
liveCfg.importWindowLeadDays,
|
liveCfg.importWindowLeadDays,
|
||||||
exportBookingLeadHours:
|
exportBookingLeadHours:
|
||||||
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
|
dto.exportBookingLeadHours ??
|
||||||
|
schedule.ruleExportBookingLeadHours ??
|
||||||
|
liveCfg.exportBookingLeadHours,
|
||||||
windowOpenHour:
|
windowOpenHour:
|
||||||
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
||||||
windowCloseHour:
|
windowCloseHour:
|
||||||
@@ -432,6 +434,12 @@ export class TrainSchedulingService {
|
|||||||
schedule.direction === 'EXPORT'
|
schedule.direction === 'EXPORT'
|
||||||
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
|
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
|
||||||
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
|
: 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, {
|
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||||
windowOpensAt: times.windowOpensAt,
|
windowOpensAt: times.windowOpensAt,
|
||||||
@@ -686,10 +694,9 @@ export class TrainSchedulingService {
|
|||||||
lockedLocomotives.push(locked);
|
lockedLocomotives.push(locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
const direction = deriveScheduleDirection(
|
// Frozen on the route at create/update from the yard-country enum;
|
||||||
route.originYard ?? { country: null },
|
// getSchedulableRoute already rejected DOMESTIC (intercity).
|
||||||
route.destinationYard ?? { country: null },
|
const direction = this.resolveRouteDirection(route);
|
||||||
);
|
|
||||||
|
|
||||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||||||
// Effective capacity is capped by the weakest locomotive in the set.
|
// 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})`,
|
`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;
|
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) {
|
private mapEligibleBooking(booking: Booking) {
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
@@ -4054,6 +4079,7 @@ export class TrainSchedulingService {
|
|||||||
: null,
|
: null,
|
||||||
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
||||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||||
|
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||||
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
|
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001';
|
|||||||
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
|
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
|
||||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONTAINER_TYPES = [
|
const CONTAINER_TYPES = [
|
||||||
|
|||||||
@@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
|
|||||||
const COMPANY_TIN = "1234567890";
|
const COMPANY_TIN = "1234567890";
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
|
{
|
||||||
|
code: "DJIBOUTI",
|
||||||
|
label: "Djibouti",
|
||||||
|
country: "Djibouti" as const,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: "ADDIS_ABABA",
|
code: "ADDIS_ABABA",
|
||||||
label: "Addis Ababa",
|
label: "Addis Ababa",
|
||||||
country: "Ethiopia",
|
country: "Ethiopia" as const,
|
||||||
displayOrder: 2,
|
displayOrder: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "DIRE_DAWA",
|
code: "DIRE_DAWA",
|
||||||
label: "Dire Dawa",
|
label: "Dire Dawa",
|
||||||
country: "Ethiopia",
|
country: "Ethiopia" as const,
|
||||||
displayOrder: 3,
|
displayOrder: 3,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const COMPANY_TIN = 'PAIDMILE001';
|
|||||||
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
|
||||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONTAINER_TYPES = [
|
const CONTAINER_TYPES = [
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountdownTimer } from "@edr/ui-common";
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { StaffBookingWindow } from "@/types/trainScheduling";
|
import type { StaffBookingWindow } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -224,6 +225,9 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
|
|||||||
* Hidden when nothing is pending.
|
* Hidden when nothing is pending.
|
||||||
*/
|
*/
|
||||||
export function GlUpcomingWindowsSection() {
|
export function GlUpcomingWindowsSection() {
|
||||||
|
// Live pushes flip cards the moment the window engine transitions a phase;
|
||||||
|
// the 60s poll below stays only as a fallback.
|
||||||
|
useBookingWindowSocket();
|
||||||
const { data, isLoading } = useQuery(
|
const { data, isLoading } = useQuery(
|
||||||
api.trainScheduling.allBookingWindows.queryOptions({
|
api.trainScheduling.allBookingWindows.queryOptions({
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const DEFAULTS = {
|
|||||||
docReviewMinutes: 30,
|
docReviewMinutes: 30,
|
||||||
paymentWindowMinutes: 60,
|
paymentWindowMinutes: 60,
|
||||||
importWindowLeadDays: 3,
|
importWindowLeadDays: 3,
|
||||||
|
exportBookingLeadHours: 24,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||||
@@ -53,6 +54,7 @@ interface FormState {
|
|||||||
docReviewMinutes: number | "";
|
docReviewMinutes: number | "";
|
||||||
paymentWindowMinutes: number | "";
|
paymentWindowMinutes: number | "";
|
||||||
importWindowLeadDays: number | "";
|
importWindowLeadDays: number | "";
|
||||||
|
exportBookingLeadHours: number | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseError(error: unknown, fallback: string): string {
|
function parseError(error: unknown, fallback: string): string {
|
||||||
@@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({
|
|||||||
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
|
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
|
||||||
importWindowLeadDays:
|
importWindowLeadDays:
|
||||||
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
|
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
|
||||||
|
exportBookingLeadHours:
|
||||||
|
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
|
||||||
});
|
});
|
||||||
}, [opened, schedule]);
|
}, [opened, schedule]);
|
||||||
|
|
||||||
@@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({
|
|||||||
const doc = Number(form.docReviewMinutes);
|
const doc = Number(form.docReviewMinutes);
|
||||||
const pay = Number(form.paymentWindowMinutes);
|
const pay = Number(form.paymentWindowMinutes);
|
||||||
const lead = Number(form.importWindowLeadDays);
|
const lead = Number(form.importWindowLeadDays);
|
||||||
|
const exportLead = Number(form.exportBookingLeadHours);
|
||||||
|
const leadInvalid = isExport
|
||||||
|
? form.exportBookingLeadHours === "" ||
|
||||||
|
!Number.isFinite(exportLead) ||
|
||||||
|
exportLead < 1
|
||||||
|
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
|
||||||
if (
|
if (
|
||||||
form.windowDurationHours === "" ||
|
form.windowDurationHours === "" ||
|
||||||
form.docReviewMinutes === "" ||
|
form.docReviewMinutes === "" ||
|
||||||
form.paymentWindowMinutes === "" ||
|
form.paymentWindowMinutes === "" ||
|
||||||
form.importWindowLeadDays === "" ||
|
|
||||||
!Number.isFinite(duration) ||
|
!Number.isFinite(duration) ||
|
||||||
!Number.isFinite(doc) ||
|
!Number.isFinite(doc) ||
|
||||||
!Number.isFinite(pay) ||
|
!Number.isFinite(pay) ||
|
||||||
!Number.isFinite(lead)
|
leadInvalid
|
||||||
) {
|
) {
|
||||||
toast({
|
toast({
|
||||||
title: "Fill every field before saving",
|
title: "Fill every field before saving",
|
||||||
@@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({
|
|||||||
windowDurationHours: duration,
|
windowDurationHours: duration,
|
||||||
docReviewMinutes: doc,
|
docReviewMinutes: doc,
|
||||||
paymentWindowMinutes: pay,
|
paymentWindowMinutes: pay,
|
||||||
importWindowLeadDays: lead,
|
...(isExport
|
||||||
|
? { exportBookingLeadHours: exportLead }
|
||||||
|
: { importWindowLeadDays: lead }),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{isExport ? (
|
{isExport ? (
|
||||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
Export schedules use a single FCFS lead window — the daily desk
|
Export schedules use a single first-come-first-served window: it
|
||||||
hours below don't apply, only the lead time does.
|
opens the export lead time before departure — shifted to the next
|
||||||
|
desk opening if that lands outside desk hours — and stays open
|
||||||
|
until departure. Cycle timing below doesn't apply.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({
|
|||||||
}
|
}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
comboboxProps={{ withinPortal: true }}
|
comboboxProps={{ withinPortal: true }}
|
||||||
disabled={isExport}
|
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Closes"
|
label="Closes"
|
||||||
@@ -275,7 +287,6 @@ export default function BookingWindowSettingsModal({
|
|||||||
}
|
}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
comboboxProps={{ withinPortal: true }}
|
comboboxProps={{ withinPortal: true }}
|
||||||
disabled={isExport}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{isOvernight && !is24h ? (
|
{isOvernight && !is24h ? (
|
||||||
@@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({
|
|||||||
color="grape"
|
color="grape"
|
||||||
label="Run 24 hours a day (never pause overnight)"
|
label="Run 24 hours a day (never pause overnight)"
|
||||||
checked={is24h}
|
checked={is24h}
|
||||||
disabled={isExport}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const checked = e.currentTarget.checked;
|
const checked = e.currentTarget.checked;
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
@@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!isExport ? (
|
<Text size="xs" c="dimmed" mt={6}>
|
||||||
<Text size="xs" c="dimmed" mt={6}>
|
{isExport
|
||||||
A not-yet-full train pauses at the close hour and resumes the next
|
? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
|
||||||
morning at the open hour, every day until it fills or departs.
|
: "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* ── Lead time ────────────────────────────────────────────────── */}
|
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||||||
<NumberInput
|
{isExport ? (
|
||||||
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
|
<NumberInput
|
||||||
description={
|
label="Export booking lead (hours)"
|
||||||
isExport
|
description="How many hours before departure the export booking window opens"
|
||||||
? "How many days before departure export booking opens"
|
value={form.exportBookingLeadHours}
|
||||||
: "How many days before departure the booking window starts"
|
onChange={(v) =>
|
||||||
}
|
setForm(
|
||||||
value={form.importWindowLeadDays}
|
(f) =>
|
||||||
onChange={(v) =>
|
f && {
|
||||||
setForm(
|
...f,
|
||||||
(f) =>
|
exportBookingLeadHours: v === "" ? "" : Number(v),
|
||||||
f && {
|
},
|
||||||
...f,
|
)
|
||||||
importWindowLeadDays: v === "" ? "" : Number(v),
|
}
|
||||||
},
|
min={1}
|
||||||
)
|
clampBehavior="none"
|
||||||
}
|
allowDecimal={false}
|
||||||
min={0}
|
/>
|
||||||
clampBehavior="none"
|
) : (
|
||||||
allowDecimal={false}
|
<NumberInput
|
||||||
/>
|
label="Window lead (days)"
|
||||||
|
description="How many days before departure the booking window starts"
|
||||||
|
value={form.importWindowLeadDays}
|
||||||
|
onChange={(v) =>
|
||||||
|
setForm(
|
||||||
|
(f) =>
|
||||||
|
f && {
|
||||||
|
...f,
|
||||||
|
importWindowLeadDays: v === "" ? "" : Number(v),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" mt="xs">
|
<Group justify="flex-end" mt="xs">
|
||||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type {
|
||||||
|
IntercityBookingRow,
|
||||||
|
IntercityCapacity,
|
||||||
|
} from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
|
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join("; ");
|
||||||
|
return message || (error as Error)?.message || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
||||||
|
if (!capacity) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Capacity unknown — schedule has no locomotive/train set yet.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
|
||||||
|
{fmt(capacity.wagons)} wagons free
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
|
||||||
|
{fmt(capacity.weightTons)} t free
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
|
||||||
|
{fmt(capacity.lengthMeters)} m free
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeedCells({ need }: { need: IntercityCapacity | null }) {
|
||||||
|
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table.Td>{fmt(need.wagons)}</Table.Td>
|
||||||
|
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||||||
|
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm">{row.origin}</Text>
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
<Text size="sm">{row.destination}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercity ride-along desk for one import/export schedule: waiting intercity
|
||||||
|
* bookings whose corridor lies on this train's route, checked against the
|
||||||
|
* remaining wagon/weight/length budget. Accepting opens the customer's pay
|
||||||
|
* window; after payment the booking is allocated. Loading/unloading is
|
||||||
|
* confirmed manually when the train is physically at the booking's origin /
|
||||||
|
* destination yard (the server validates against recorded checkpoints).
|
||||||
|
*/
|
||||||
|
export function IntercityRideAlongPanel({
|
||||||
|
scheduleId,
|
||||||
|
direction,
|
||||||
|
}: {
|
||||||
|
scheduleId: string;
|
||||||
|
direction: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const candidatesQuery = useQuery(
|
||||||
|
api.trainScheduling.intercityCandidates.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const accept = useMutation(
|
||||||
|
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setSelected([]);
|
||||||
|
void invalidate();
|
||||||
|
if (result.accepted.length > 0) {
|
||||||
|
toast({
|
||||||
|
title: `${result.accepted.length} intercity booking(s) accepted`,
|
||||||
|
description: "Customers have been asked to pay.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of result.rejected) {
|
||||||
|
toast({
|
||||||
|
title: "Booking skipped",
|
||||||
|
description: r.reason,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Accept failed",
|
||||||
|
description: parseError(err, "Could not accept intercity bookings"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useMutation(
|
||||||
|
api.trainScheduling.loadIntercityBooking.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
void invalidate();
|
||||||
|
toast({ title: "Cargo loaded" });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Load failed",
|
||||||
|
description: parseError(err, "Could not confirm loading"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unload = useMutation(
|
||||||
|
api.trainScheduling.unloadIntercityBooking.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
void invalidate();
|
||||||
|
toast({ title: "Cargo unloaded — booking completed" });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Unload failed",
|
||||||
|
description: parseError(err, "Could not confirm unloading"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Intercity bookings only ride import/export trains.
|
||||||
|
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
|
||||||
|
|
||||||
|
const data = candidatesQuery.data;
|
||||||
|
const candidates = data?.candidates ?? [];
|
||||||
|
const accepted = data?.accepted ?? [];
|
||||||
|
|
||||||
|
if (candidatesQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading intercity ride-along bookings…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0 && accepted.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" wrap="wrap">
|
||||||
|
<Group gap="xs">
|
||||||
|
<TrainFront size={18} />
|
||||||
|
<Text fw={700}>Intercity ride-along</Text>
|
||||||
|
</Group>
|
||||||
|
<CapacityBadges capacity={data?.remaining ?? null} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{candidates.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Waiting intercity bookings whose corridor lies on this train's
|
||||||
|
route. Accepting opens the customer's payment window against the
|
||||||
|
free capacity above.
|
||||||
|
</Text>
|
||||||
|
<Table.ScrollContainer minWidth={720}>
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th w={36} />
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Corridor</Table.Th>
|
||||||
|
<Table.Th>Wagons</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Length</Table.Th>
|
||||||
|
<Table.Th>Fits</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{candidates.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
size="xs"
|
||||||
|
checked={selected.includes(row.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelected((prev) =>
|
||||||
|
e.currentTarget.checked
|
||||||
|
? [...prev, row.id]
|
||||||
|
: prev.filter((id) => id !== row.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.reference ?? row.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
{row.isGovernment && (
|
||||||
|
<Badge size="xs" variant="light" color="grape">
|
||||||
|
GOV
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.customer}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<CorridorCell row={row} />
|
||||||
|
</Table.Td>
|
||||||
|
<NeedCells need={row.need} />
|
||||||
|
<Table.Td>
|
||||||
|
{row.fits ? (
|
||||||
|
<Badge size="sm" variant="light" color="teal">
|
||||||
|
Fits
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
|
||||||
|
<Badge size="sm" variant="light" color="red">
|
||||||
|
No room
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="edr-green"
|
||||||
|
loading={accept.isPending}
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
|
||||||
|
>
|
||||||
|
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accepted.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
On this train
|
||||||
|
</Text>
|
||||||
|
<Table.ScrollContainer minWidth={680}>
|
||||||
|
<Table verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Corridor</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{accepted.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.reference ?? row.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.customer}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<CorridorCell row={row} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end">
|
||||||
|
{row.status === "PAID" && (
|
||||||
|
<Tooltip label="Train must be at the booking's origin yard">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<PackageCheck size={13} />}
|
||||||
|
loading={load.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
load.mutate({ scheduleId, bookingId: row.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{row.status === "IN_TRANSIT" && (
|
||||||
|
<Tooltip label="Train must be at the booking's destination yard">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<PackageOpen size={13} />}
|
||||||
|
loading={unload.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
unload.mutate({ scheduleId, bookingId: row.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Unload
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{candidatesQuery.isError && (
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||||
|
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -324,6 +324,14 @@ export const URL_CONSTANTS = {
|
|||||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||||
|
INTERCITY_CANDIDATES: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||||
|
INTERCITY_ACCEPT: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity/accept`,
|
||||||
|
INTERCITY_LOAD: (id: string, bookingId: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity/${bookingId}/load`,
|
||||||
|
INTERCITY_UNLOAD: (id: string, bookingId: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`,
|
||||||
IMPORT_LOADING_BOOKINGS: (id: string) =>
|
IMPORT_LOADING_BOOKINGS: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/import-loading-bookings`,
|
`/train-scheduling/schedules/${id}/import-loading-bookings`,
|
||||||
IMPORT_LOADING_STATUS: (id: string) =>
|
IMPORT_LOADING_STATUS: (id: string) =>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
BOOKING_WINDOW_WS_EVENTS,
|
||||||
|
BOOKING_WINDOW_WS_NAMESPACE,
|
||||||
|
type BookingWindowPhaseEvent,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
|
||||||
|
// The socket namespace lives at the server root, not under the `/api` REST
|
||||||
|
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||||
|
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to live booking-window pushes for staff. Every phase transition
|
||||||
|
* the window engine applies invalidates the GL windows carousel and the batch
|
||||||
|
* board, so both flip the moment the backend does — polling stays only as a
|
||||||
|
* fallback.
|
||||||
|
*/
|
||||||
|
export function useBookingWindowSocket(enabled: boolean = true) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||||
|
(_event: BookingWindowPhaseEvent) => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["train-scheduling", "all-booking-windows"],
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off();
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, qc]);
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
} from "@/components/trainScheduling/containerPlacement.util";
|
} from "@/components/trainScheduling/containerPlacement.util";
|
||||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
@@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
void detailQuery.refetch();
|
void detailQuery.refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{scheduleId ? (
|
||||||
|
<IntercityRideAlongPanel
|
||||||
|
scheduleId={scheduleId}
|
||||||
|
direction={schedule.direction}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||||
|
|
||||||
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||||
|
// API rejects them, so keep them out of the picker entirely.
|
||||||
|
const activeRoutes = useMemo(
|
||||||
|
() => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"),
|
||||||
|
[routesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||||
|
|
||||||
@@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const created = await create.mutateAsync({
|
const created = await create.mutateAsync({
|
||||||
payload: { routeId, scheduleDate, locomotiveIds },
|
payload: {
|
||||||
|
routeId,
|
||||||
|
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||||
|
locomotiveIds,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast({ title: "Train schedule created" });
|
toast({ title: "Train schedule created" });
|
||||||
showScheduleWarnings(created.warnings);
|
showScheduleWarnings(created.warnings);
|
||||||
@@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Departure date"
|
label="Departure date"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
|
value={scheduleDate}
|
||||||
onChange={(e) => {
|
onChange={(e) => setScheduleDate(e.currentTarget.value)}
|
||||||
const raw = e.currentTarget.value;
|
|
||||||
setScheduleDate(raw ? new Date(raw).toISOString() : "");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Locomotives"
|
label="Locomotives"
|
||||||
|
|||||||
@@ -593,6 +593,52 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
intercityCandidates: endpoint<
|
||||||
|
{ scheduleId: string },
|
||||||
|
import("@/types/trainScheduling").IntercityCandidatesResult
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-candidates",
|
||||||
|
({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId),
|
||||||
|
({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId],
|
||||||
|
),
|
||||||
|
|
||||||
|
acceptIntercityBookings: endpoint<
|
||||||
|
{ scheduleId: string; bookingIds: string[] },
|
||||||
|
import("@/types/trainScheduling").IntercityAcceptResult
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-accept",
|
||||||
|
({ scheduleId, bookingIds }) =>
|
||||||
|
trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
|
loadIntercityBooking: endpoint<
|
||||||
|
{ scheduleId: string; bookingId: string },
|
||||||
|
void
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-load",
|
||||||
|
({ scheduleId, bookingId }) =>
|
||||||
|
trainSchedulingService.loadIntercityBooking(scheduleId, bookingId),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
|
unloadIntercityBooking: endpoint<
|
||||||
|
{ scheduleId: string; bookingId: string },
|
||||||
|
void
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-unload",
|
||||||
|
({ scheduleId, bookingId }) =>
|
||||||
|
trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
cancelSchedule: endpoint<
|
cancelSchedule: endpoint<
|
||||||
{ id: string; freightType?: FreightType },
|
{ id: string; freightType?: FreightType },
|
||||||
TrainScheduleDetail
|
TrainScheduleDetail
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
|
|||||||
|
|
||||||
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||||
|
|
||||||
|
/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */
|
||||||
|
export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC';
|
||||||
|
|
||||||
export interface YardRef {
|
export interface YardRef {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -23,6 +26,7 @@ export interface RouteMilestone {
|
|||||||
export interface RouteRecord {
|
export interface RouteRecord {
|
||||||
id: string;
|
id: string;
|
||||||
status: RouteStatus;
|
status: RouteStatus;
|
||||||
|
direction?: RouteDirection;
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
destinationYardId: string;
|
destinationYardId: string;
|
||||||
originYard?: YardRef | null;
|
originYard?: YardRef | null;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
ImportDjiboutiLoadList,
|
ImportDjiboutiLoadList,
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
ImportLoadingBookingsResponse,
|
ImportLoadingBookingsResponse,
|
||||||
|
IntercityAcceptResult,
|
||||||
|
IntercityCandidatesResult,
|
||||||
LoadingStatus,
|
LoadingStatus,
|
||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
@@ -328,6 +330,46 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getIntercityCandidates: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<IntercityCandidatesResult> => {
|
||||||
|
const response = await client.get<IntercityCandidatesResult>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_CANDIDATES(scheduleId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptIntercityBookings: async (
|
||||||
|
scheduleId: string,
|
||||||
|
bookingIds: string[],
|
||||||
|
): Promise<IntercityAcceptResult> => {
|
||||||
|
const response = await client.post<IntercityAcceptResult>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_ACCEPT(scheduleId),
|
||||||
|
{ bookingIds },
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadIntercityBooking: async (
|
||||||
|
scheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await client.post(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_LOAD(scheduleId, bookingId),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
unloadIntercityBooking: async (
|
||||||
|
scheduleId: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await client.post(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_UNLOAD(scheduleId, bookingId),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
dispatchSchedule: async (
|
dispatchSchedule: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
): Promise<TrainScheduleDetail> => {
|
): Promise<TrainScheduleDetail> => {
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ export interface ScheduleWindowRule {
|
|||||||
windowDurationHours: number | null;
|
windowDurationHours: number | null;
|
||||||
reopenDelayMinutes: number | null;
|
reopenDelayMinutes: number | null;
|
||||||
importWindowLeadDays: number | null;
|
importWindowLeadDays: number | null;
|
||||||
|
exportBookingLeadHours: number | null;
|
||||||
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
||||||
docReviewMinutes: number;
|
docReviewMinutes: number;
|
||||||
paymentWindowMinutes: number;
|
paymentWindowMinutes: number;
|
||||||
@@ -420,6 +421,7 @@ export interface UpdateScheduleWindowRulePayload {
|
|||||||
docReviewMinutes?: number;
|
docReviewMinutes?: number;
|
||||||
paymentWindowMinutes?: number;
|
paymentWindowMinutes?: number;
|
||||||
importWindowLeadDays?: number;
|
importWindowLeadDays?: number;
|
||||||
|
exportBookingLeadHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrainScheduleDetail {
|
export interface TrainScheduleDetail {
|
||||||
@@ -746,3 +748,49 @@ export interface CompositionRemovalEntry {
|
|||||||
removedAt: string;
|
removedAt: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Intercity ride-along ─────────────────────────────────────────────────────
|
||||||
|
// Intercity (DOMESTIC) bookings have no train of their own — they ride a
|
||||||
|
// passing import/export schedule whose route milestones contain the booking's
|
||||||
|
// origin before its destination. Staff accept them at finalize time against
|
||||||
|
// the train's remaining wagon/weight/length capacity.
|
||||||
|
|
||||||
|
export interface IntercityCapacity {
|
||||||
|
wagons: number;
|
||||||
|
weightTons: number;
|
||||||
|
lengthMeters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntercityBookingRow {
|
||||||
|
id: string;
|
||||||
|
reference: string | null;
|
||||||
|
status: string;
|
||||||
|
freightType: FreightType | null;
|
||||||
|
isGovernment: boolean;
|
||||||
|
customer: string;
|
||||||
|
originYardId: string;
|
||||||
|
destinationYardId: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
weightTons: number;
|
||||||
|
paymentDeadline: string | null;
|
||||||
|
need: IntercityCapacity | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntercityCandidateRow extends IntercityBookingRow {
|
||||||
|
fits: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntercityCandidatesResult {
|
||||||
|
scheduleId: string;
|
||||||
|
routeId: string | null;
|
||||||
|
remaining: IntercityCapacity | null;
|
||||||
|
candidates: IntercityCandidateRow[];
|
||||||
|
accepted: IntercityBookingRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntercityAcceptResult {
|
||||||
|
accepted: string[];
|
||||||
|
rejected: Array<{ bookingId: string; reason: string }>;
|
||||||
|
remaining: IntercityCapacity;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
BOOKING_WINDOW_WS_EVENTS,
|
||||||
|
BOOKING_WINDOW_WS_NAMESPACE,
|
||||||
|
type BookingWindowPhaseEvent,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
|
||||||
|
function getAuthToken(): string | undefined {
|
||||||
|
return document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith("auth-token="))
|
||||||
|
?.split("=")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The socket namespace lives at the server root, not under the `/api` REST
|
||||||
|
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||||
|
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to live booking-window pushes. Every phase transition the window
|
||||||
|
* engine applies (open, doc review, payment, reopen, done) invalidates the
|
||||||
|
* cached window lists, so the home-page "Booking Windows" card flips the
|
||||||
|
* moment the backend does — the 60s poll remains only as a fallback.
|
||||||
|
*/
|
||||||
|
export function useBookingWindowSocket(enabled: boolean) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||||
|
(_event: BookingWindowPhaseEvent) => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["train-scheduling", "myBookingWindows"],
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["train-scheduling", "contractBookingWindows"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off();
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, qc]);
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ACTIVE_STATUSES } from "./constants";
|
import { ACTIVE_STATUSES } from "./constants";
|
||||||
|
|
||||||
export function useMyPortalData(selectedProfileId?: string) {
|
export function useMyPortalData(selectedProfileId?: string) {
|
||||||
const { user, customer, company } = useAuth();
|
const { user, customer, company } = useAuth();
|
||||||
|
|
||||||
|
// Live booking-window pushes: a phase transition on any lane invalidates the
|
||||||
|
// windows queries below the moment it happens (poll below is only a fallback).
|
||||||
|
useBookingWindowSocket(Boolean(user));
|
||||||
|
|
||||||
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
|
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
|
||||||
const myInvoices = invoicesQuery.data ?? [];
|
const myInvoices = invoicesQuery.data ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,10 @@ export default function ContractDetailPage() {
|
|||||||
}),
|
}),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
const bookingWindowOpen = hasOpenWindow(bookingWindows);
|
// Intercity contracts are never window-gated: the shipment rides a passing
|
||||||
|
// import/export train that staff assign later, so booking is always open.
|
||||||
|
const bookingWindowOpen =
|
||||||
|
contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows);
|
||||||
|
|
||||||
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||||
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
||||||
|
|||||||
@@ -128,8 +128,10 @@ export default function NewShipmentPage() {
|
|||||||
|
|
||||||
// Coarse gate: if the customer deep-links here while no booking window is
|
// Coarse gate: if the customer deep-links here while no booking window is
|
||||||
// open, show the same closed-state notice as the contract page instead of the
|
// open, show the same closed-state notice as the contract page instead of the
|
||||||
// form. Still allowed the moment any window isOpenNow.
|
// form. Still allowed the moment any window isOpenNow. Intercity contracts
|
||||||
if (!hasOpenWindow(bookingWindows)) {
|
// are never window-gated — the shipment rides a passing train that staff
|
||||||
|
// pick at finalize time, so booking is always open.
|
||||||
|
if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) {
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 0 0" }}>
|
<Box style={{ padding: "28px 0 0" }}>
|
||||||
<Group
|
<Group
|
||||||
@@ -213,6 +215,8 @@ function NewShipmentBookingForm({
|
|||||||
isHazardous: contract.isHazardous ?? false,
|
isHazardous: contract.isHazardous ?? false,
|
||||||
isReefer: contract.isReefer ?? false,
|
isReefer: contract.isReefer ?? false,
|
||||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||||
|
// Intercity rides a passing train staff pick later — no date to choose.
|
||||||
|
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
@@ -246,7 +250,10 @@ function NewShipmentBookingForm({
|
|||||||
...(values.contractRouteId
|
...(values.contractRouteId
|
||||||
? { contractRouteId: values.contractRouteId }
|
? { contractRouteId: values.contractRouteId }
|
||||||
: {}),
|
: {}),
|
||||||
scheduledDate: new Date(values.scheduledDate).toISOString(),
|
// Intercity bookings carry no date — staff assign a passing train later.
|
||||||
|
...(values.scheduledDate
|
||||||
|
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||||
|
: {}),
|
||||||
...(isContainer
|
...(isContainer
|
||||||
? {
|
? {
|
||||||
containers: values.containers
|
containers: values.containers
|
||||||
@@ -793,13 +800,31 @@ function ScheduleStep({
|
|||||||
contract.pricingBreakdown,
|
contract.pricingBreakdown,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
||||||
const { data: availableDays, isLoading } = useQuery({
|
const { data: availableDays, isLoading } = useQuery({
|
||||||
...api.bookings.getAvailableDaysForCargo.queryOptions({
|
...api.bookings.getAvailableDaysForCargo.queryOptions({
|
||||||
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
|
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
|
||||||
}),
|
}),
|
||||||
enabled: cargoQuery !== null,
|
enabled: cargoQuery !== null && !isIntercity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isIntercity) {
|
||||||
|
return (
|
||||||
|
<StepCard>
|
||||||
|
<StepHeader
|
||||||
|
icon={<CalendarDays size={22} />}
|
||||||
|
title="Schedule"
|
||||||
|
description="Intercity shipments have no fixed day."
|
||||||
|
/>
|
||||||
|
<Alert color="blue" variant="light" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
|
Your shipment rides the next import/export train passing through your
|
||||||
|
corridor. Operations assign it to a train with free capacity — you
|
||||||
|
will be notified when it is accepted and payment is due.
|
||||||
|
</Alert>
|
||||||
|
</StepCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const OPERATION_TYPE_OPTIONS: Array<{
|
|||||||
value: OperationType;
|
value: OperationType;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Shown but not selectable (service not offered yet). */
|
||||||
|
disabled?: boolean;
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
value: "import",
|
value: "import",
|
||||||
@@ -46,7 +48,8 @@ export const OPERATION_TYPE_OPTIONS: Array<{
|
|||||||
{
|
{
|
||||||
value: "intercity",
|
value: "intercity",
|
||||||
label: "Intercity",
|
label: "Intercity",
|
||||||
description: "Domestic movement between Ethiopian yards.",
|
description:
|
||||||
|
"Domestic movement between Ethiopian yards — rides on passing import/export trains, no customs.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "import_ff",
|
value: "import_ff",
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export function Step0OperationType({
|
|||||||
}) {
|
}) {
|
||||||
const data = OPERATION_TYPE_OPTIONS.filter((opt) =>
|
const data = OPERATION_TYPE_OPTIONS.filter((opt) =>
|
||||||
allowedOperations.includes(opt.value),
|
allowedOperations.includes(opt.value),
|
||||||
).map((opt) => ({ value: opt.value, label: opt.label }));
|
).map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: opt.label,
|
||||||
|
disabled: opt.disabled ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ export function Step1ContractType({
|
|||||||
|
|
||||||
const operationData = OPERATION_TYPE_OPTIONS.filter((opt) =>
|
const operationData = OPERATION_TYPE_OPTIONS.filter((opt) =>
|
||||||
allowedOperations.includes(opt.value),
|
allowedOperations.includes(opt.value),
|
||||||
).map((opt) => ({ value: opt.value, label: opt.label }));
|
).map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: opt.label,
|
||||||
|
disabled: opt.disabled ?? false,
|
||||||
|
}));
|
||||||
const previousContractRef = form.watch("previousContractRef");
|
const previousContractRef = form.watch("previousContractRef");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export interface ShipmentValidationContext {
|
|||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
isReefer: boolean;
|
isReefer: boolean;
|
||||||
unitOfMeasure?: "PER_TON" | "PER_ITEM";
|
unitOfMeasure?: "PER_TON" | "PER_ITEM";
|
||||||
|
/**
|
||||||
|
* Intercity (DOMESTIC) shipments ride a passing import/export train that
|
||||||
|
* staff pick later, so no shipment day is chosen. Defaults to true.
|
||||||
|
*/
|
||||||
|
requiresDate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerUnitSchema = z.object({
|
const containerUnitSchema = z.object({
|
||||||
@@ -54,7 +59,7 @@ const shipmentFormBase = z.object({
|
|||||||
|
|
||||||
export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||||
return shipmentFormBase.superRefine((data, refineCtx) => {
|
return shipmentFormBase.superRefine((data, refineCtx) => {
|
||||||
if (!data.scheduledDate.trim()) {
|
if ((ctx.requiresDate ?? true) && !data.scheduledDate.trim()) {
|
||||||
refineCtx.addIssue({
|
refineCtx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
path: ["scheduledDate"],
|
path: ["scheduledDate"],
|
||||||
|
|||||||
41
packages/types/src/freight/booking-window-ws.ts
Normal file
41
packages/types/src/freight/booking-window-ws.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Shared contracts for live booking-window pushes.
|
||||||
|
*
|
||||||
|
* The freight API emits one event whenever a schedule's booking-window state
|
||||||
|
* changes (phase transition, open/close, cycle reopen). Portal home and the
|
||||||
|
* backoffice GL windows section subscribe and refresh instantly instead of
|
||||||
|
* waiting for their poll interval.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Booking-window lifecycle phase persisted on a train schedule. */
|
||||||
|
export type BookingWindowPhase =
|
||||||
|
| "PRE_WINDOW"
|
||||||
|
| "OPEN"
|
||||||
|
| "DOC_REVIEW"
|
||||||
|
| "PAYMENT"
|
||||||
|
| "CLOSED_FOR_DAY"
|
||||||
|
| "DONE";
|
||||||
|
|
||||||
|
/** Payload pushed on every booking-window state change. */
|
||||||
|
export interface BookingWindowPhaseEvent {
|
||||||
|
scheduleId: string;
|
||||||
|
originYardId: string;
|
||||||
|
destinationYardId: string;
|
||||||
|
direction: string | null;
|
||||||
|
phase: BookingWindowPhase;
|
||||||
|
bookingWindowStatus: string | null;
|
||||||
|
bookingCycleNo: number;
|
||||||
|
windowOpensAt: string | null;
|
||||||
|
windowClosesAt: string | null;
|
||||||
|
docReviewEndsAt: string | null;
|
||||||
|
paymentPhaseEndsAt: string | null;
|
||||||
|
scheduledDepartureDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Socket.io event names pushed server → client on the booking-windows namespace. */
|
||||||
|
export const BOOKING_WINDOW_WS_EVENTS = {
|
||||||
|
PHASE: "booking-window:phase",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Socket.io namespace the booking-window gateway listens on. */
|
||||||
|
export const BOOKING_WINDOW_WS_NAMESPACE = "booking-windows";
|
||||||
@@ -695,8 +695,8 @@ export interface CreateBulkLineDto {
|
|||||||
export interface CreateBookingUnderContractDto {
|
export interface CreateBookingUnderContractDto {
|
||||||
/** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */
|
/** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */
|
||||||
contractRouteId?: string;
|
contractRouteId?: string;
|
||||||
/** Binding shipment day. */
|
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
|
||||||
scheduledDate: string;
|
scheduledDate?: string;
|
||||||
containers?: CreateBookingContainerLineDto[];
|
containers?: CreateBookingContainerLineDto[];
|
||||||
bulkLines?: CreateBulkLineDto[];
|
bulkLines?: CreateBulkLineDto[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * from "./etrade";
|
|||||||
export * from "./contracts";
|
export * from "./contracts";
|
||||||
export * from "./clearance-files.catalog";
|
export * from "./clearance-files.catalog";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
|
export * from "./booking-window-ws";
|
||||||
|
|
||||||
export enum TradeDirection {
|
export enum TradeDirection {
|
||||||
IMPORT = "IMPORT",
|
IMPORT = "IMPORT",
|
||||||
@@ -220,6 +221,24 @@ export enum WagonReadiness {
|
|||||||
|
|
||||||
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only two countries on the EDR line. Yard `country` is constrained to
|
||||||
|
* these values; route trade direction is derived from the origin/destination
|
||||||
|
* yard countries (ET→DJ = EXPORT, DJ→ET = IMPORT, same-country = DOMESTIC,
|
||||||
|
* shown as "Intercity" in UIs and currently disabled for scheduling/contracts).
|
||||||
|
*/
|
||||||
|
export enum YardCountry {
|
||||||
|
ETHIOPIA = "Ethiopia",
|
||||||
|
DJIBOUTI = "Djibouti",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI label for a schedule/route trade direction (DOMESTIC displays as Intercity). */
|
||||||
|
export const TRADE_DIRECTION_LABELS: Record<ScheduleTradeDirection, string> = {
|
||||||
|
IMPORT: "Import",
|
||||||
|
EXPORT: "Export",
|
||||||
|
DOMESTIC: "Intercity",
|
||||||
|
};
|
||||||
|
|
||||||
export enum TrainCheckpointKind {
|
export enum TrainCheckpointKind {
|
||||||
Departed = "DEPARTED",
|
Departed = "DEPARTED",
|
||||||
Passed = "PASSED",
|
Passed = "PASSED",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -15461,9 +15461,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||||
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||||
@@ -15584,9 +15584,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||||
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user