mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 14:43:26 +00:00
implement intercity booking management and booking window websocket integration
This commit is contained in:
@@ -119,12 +119,27 @@ export class ContractBookingService {
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
|
||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||
// there is no window and no date — staff accept them onto a train at
|
||||
// finalize time, so both the window gate and scheduledDate are skipped.
|
||||
const isIntercity = contract.tradeDirection === 'DOMESTIC';
|
||||
if (isIntercity && dto.scheduledDate) {
|
||||
throw new BadRequestException(
|
||||
'Intercity bookings do not pick a date — staff assign them to a passing train',
|
||||
);
|
||||
}
|
||||
// Every other direction keeps the binding shipment day (the DTO field went
|
||||
// optional only for intercity).
|
||||
if (!isIntercity && !dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
|
||||
// Booking-window gate (config-driven): an operations booking may only be
|
||||
// created while the route's booking window is open — import: the day's window
|
||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
||||
// enter clearance first and are scheduled later, so they are not gated here.
|
||||
if (!generalCustoms) {
|
||||
if (!generalCustoms && !isIntercity) {
|
||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
|
||||
@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
|
||||
import { YardCountry } from '@edr/types';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
@@ -110,6 +114,49 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every route must match the contract's declared trade direction as derived
|
||||
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
|
||||
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
|
||||
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
|
||||
* (e.g. an export lane on an import contract) are rejected for every kind.
|
||||
*/
|
||||
private async assertRoutesMatchDirection(
|
||||
tradeDirection: string,
|
||||
routes: CreateContractDto['routes'],
|
||||
): Promise<void> {
|
||||
const yardIds = [
|
||||
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
|
||||
];
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: yardIds.map((id) => ({ id })) });
|
||||
const yardById = new Map(yards.map((y) => [y.id, y]));
|
||||
|
||||
for (const route of routes) {
|
||||
const origin = yardById.get(route.originYardId);
|
||||
const destination = yardById.get(route.destinationYardId);
|
||||
if (!origin || !destination) {
|
||||
throw new BadRequestException('Route references a yard that does not exist');
|
||||
}
|
||||
const derived = deriveTradeDirection(origin, destination);
|
||||
if (derived !== tradeDirection) {
|
||||
throw new BadRequestException(
|
||||
`Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
derived === 'DOMESTIC' &&
|
||||
(origin.country !== YardCountry.ETHIOPIA ||
|
||||
destination.country !== YardCountry.ETHIOPIA)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||
async create(
|
||||
dto: CreateContractDto,
|
||||
@@ -144,6 +191,7 @@ export class ContractsService {
|
||||
|
||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||
|
||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||
let companyProfileId: string | null = null;
|
||||
@@ -175,6 +223,13 @@ export class ContractsService {
|
||||
|
||||
// Customs clearing is owned by the service type, not the customer.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||
// Intercity never crosses a border, so a customs-including service type is
|
||||
// a contradiction — the wizard hides them, the API enforces it.
|
||||
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
||||
throw new BadRequestException(
|
||||
'Intercity contracts cannot use a service type that includes customs clearing',
|
||||
);
|
||||
}
|
||||
|
||||
// An explicit reference is caller-chosen — a collision there is a real
|
||||
// conflict and should surface. Auto-generated references retry past a
|
||||
@@ -360,6 +415,12 @@ export class ContractsService {
|
||||
|
||||
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
|
||||
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
|
||||
if (dto.routes) {
|
||||
await this.assertRoutesMatchDirection(
|
||||
dto.tradeDirection ?? existing.tradeDirection,
|
||||
dto.routes,
|
||||
);
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
contractKind,
|
||||
@@ -385,6 +446,11 @@ export class ContractsService {
|
||||
const includesCustoms = await this.resolveIncludesCustoms(
|
||||
dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
);
|
||||
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
|
||||
throw new BadRequestException(
|
||||
'Intercity contracts cannot use a service type that includes customs clearing',
|
||||
);
|
||||
}
|
||||
updates.customsClearingEnabled = includesCustoms;
|
||||
updates.customsClearingAgent = includesCustoms
|
||||
? null
|
||||
|
||||
@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
|
||||
@IsUUID()
|
||||
contractRouteId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
||||
example: '2026-07-15',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
scheduledDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||
@IsOptional()
|
||||
|
||||
Reference in New Issue
Block a user