implement intercity booking management and booking window websocket integration

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

View File

@@ -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