Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-04 05:54:26 +00:00
231 changed files with 13389 additions and 3301 deletions

View File

@@ -61,4 +61,25 @@ REDIS_PORT=6379
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
SMS_QUEUE=sms_queue
# ── VeriFayda 2.0 (eSignet OIDC) identity verification ──────────────────────
# Disabled by default; /fayda/verification/start returns 503 until enabled.
FAYDA_ENABLED=false
FAYDA_CLIENT_ID=
FAYDA_AUTHORIZATION_ENDPOINT=
FAYDA_TOKEN_ENDPOINT=
FAYDA_USERINFO_ENDPOINT=
# Base64-encoded RSA private JWK used for the private_key_jwt client assertion
FAYDA_PRIVATE_KEY_BASE64=
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
EXPIRATION_TIME=15
ALGORITHM=RS256
EMAIL_QUEUE=email_queue

View File

@@ -63,6 +63,7 @@
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"handlebars": "^4.7.9",
"jose": "^5.10.0",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3",
"pg": "^8.13.0",

View File

@@ -1,4 +1,8 @@
import { Module, OnApplicationBootstrap } from "@nestjs/common";
import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { ScheduleModule } from "@nestjs/schedule";
@@ -12,6 +16,7 @@ import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.module";
@@ -61,26 +66,29 @@ import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { FuelModule } from './modules/fuel/fuel.module';
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module';
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
import { RoutesModule } from "./modules/routes/routes.module";
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
import { OverviewModule } from "./modules/overview/overview.module";
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
import { FuelModule } from "./modules/fuel/fuel.module";
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { LoggerMiddleware } from "./logger.middleware";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -141,6 +149,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
LastMileModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
FleetHistoryModule,
],
providers: [
EdrOrgSeeder,
@@ -211,4 +221,8 @@ export class AppModule implements OnApplicationBootstrap {
// bookings bill to. Idempotent — keyed by fixed IDs.
await this.govCompaniesSeeder.run();
}
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
}
}

View File

@@ -0,0 +1,126 @@
import { registerAs } from '@nestjs/config';
export interface FaydaJwk {
kty: 'RSA';
use?: string;
kid?: string;
alg?: string;
n: string;
e: string;
d: string;
p?: string;
q?: string;
dp?: string;
dq?: string;
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export interface FaydaConfig {
enabled: boolean;
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
webRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
claimsLocales: string;
sessionTtlMinutes: number;
}
const REQUIRED_VARS = [
'FAYDA_CLIENT_ID',
'FAYDA_AUTHORIZATION_ENDPOINT',
'FAYDA_TOKEN_ENDPOINT',
'FAYDA_USERINFO_ENDPOINT',
'FAYDA_PRIVATE_KEY_BASE64',
] as const;
function decodePrivateJwk(base64: string): FaydaJwk {
let jwk: unknown;
try {
const json = Buffer.from(base64, 'base64').toString('utf8');
jwk = JSON.parse(json);
} catch (err) {
throw new Error(
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
);
}
if (!jwk || typeof jwk !== 'object') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
}
const candidate = jwk as Partial<FaydaJwk>;
if (candidate.kty !== 'RSA') {
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
}
if (!candidate.n || !candidate.e || !candidate.d) {
throw new Error(
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
);
}
return candidate as FaydaJwk;
}
export default registerAs('fayda', (): FaydaConfig => {
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
// `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
// are needed so the matching essential claims aren't rejected as out-of-scope.
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
if (!enabled) {
return {
enabled: false,
clientId: process.env.FAYDA_CLIENT_ID ?? '',
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
};
}
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
);
}
if (!redirectUri) {
throw new Error(
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
);
}
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
}
return {
enabled: true,
clientId: process.env.FAYDA_CLIENT_ID!,
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,
claimsLocales,
sessionTtlMinutes: sessionTtl,
};
});

View File

@@ -0,0 +1,21 @@
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger("HTTP");
use(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
this.logger.log(
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
);
});
next();
}
}

View File

@@ -38,7 +38,8 @@ async function bootstrap() {
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
});
app.setGlobalPrefix("api");
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
app.setGlobalPrefix("api", { exclude: ["callback"] });
// enableImplicitConversion is OFF: class-transformer's implicit boolean
// coercion turns any non-empty multipart/form-data string (including the
// literal "false") into `true`, silently corrupting flags like isHazardous

View File

@@ -0,0 +1,269 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Repairs schema drift on databases that were originally built by TypeORM
* `synchronize` (at an older entity snapshot) and never had their migration
* history recorded. Such databases have `freight.migrations` empty while most
* of the schema already exists, so a from-scratch migration run aborts on the
* first non-idempotent statement and never reaches the columns/tables added
* after synchronize was last used.
*
* The deployment procedure for those databases is:
* 1. Baseline every pre-existing migration into `freight.migrations`.
* 2. Run migrations — this file is the only pending one and back-fills the
* objects the drift scan found missing.
*
* Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is
* also safe on a clean database where the earlier migrations already created
* these objects — it simply no-ops.
*/
export class RepairSynchronizeDrift1870000000000
implements MigrationInterface
{
name = 'RepairSynchronizeDrift1870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// --- enum types (derived from entities that never had a source migration) ---
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.consignments_cargo_type_enum AS ENUM (
'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.consignments_status_enum AS ENUM (
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
await queryRunner.query(`DO $$ BEGIN
CREATE TYPE freight.tracking_events_status_enum AS ENUM (
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
);
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
// --- missing tables ---
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments (
id uuid NOT NULL DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL,
tracking_number varchar(64) NOT NULL,
cargo_type freight.consignments_cargo_type_enum NOT NULL,
weight_kg numeric(12, 2) NOT NULL,
status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING',
origin_station varchar(128) NOT NULL,
destination_station varchar(128) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_consignments PRIMARY KEY (id),
CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events (
id uuid NOT NULL DEFAULT gen_random_uuid(),
consignment_id uuid NOT NULL,
location varchar(256) NOT NULL,
status freight.tracking_events_status_enum NOT NULL,
occurred_at timestamptz NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_tracking_events PRIMARY KEY (id)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
purchase_date timestamptz NOT NULL,
liters numeric(10, 2) NOT NULL,
cost_per_liter numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
fuel_station varchar(255) NULL,
payment_method varchar(50) DEFAULT 'CASH',
odometer_reading numeric(10, 2) NULL,
driver_id uuid NULL,
receipt_number varchar(255) NULL,
notes text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
month date NOT NULL,
total_liters numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
total_distance_km numeric(10, 2) NOT NULL,
fuel_efficiency_km_per_l numeric(10, 2) NULL,
number_of_purchases integer DEFAULT 0,
average_cost_per_liter numeric(10, 2) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
maintenance_type varchar NOT NULL,
description varchar NOT NULL,
scheduled_date timestamptz NOT NULL,
completed_date timestamptz,
estimated_cost numeric(14,2),
actual_cost numeric(14,2),
status varchar NOT NULL DEFAULT 'SCHEDULED',
odometer_reading numeric,
service_provider varchar,
notes text,
next_due_km numeric,
next_due_date timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
PRIMARY KEY (id)
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
maintenance_schedule_id uuid,
incurred_date timestamptz NOT NULL,
cost_amount numeric(14,2) NOT NULL,
cost_type varchar NOT NULL,
description varchar NOT NULL,
service_provider varchar,
invoice_number varchar,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
PRIMARY KEY (id),
CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id)
REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications (
id uuid NOT NULL DEFAULT gen_random_uuid(),
phone varchar NOT NULL,
otp varchar NOT NULL,
verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_otp_verifications PRIMARY KEY (id),
CONSTRAINT uq_otp_verifications_phone UNIQUE (phone)
);`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
offered_wagons integer NOT NULL,
total_wagons integer NOT NULL,
offered_lines jsonb NULL,
offered_weight_tons numeric(12, 3) NOT NULL,
offered_amount numeric(14, 2) NOT NULL,
offered_pricing_breakdown jsonb NULL,
invoice_id uuid NULL,
payment_deadline timestamptz NOT NULL,
status varchar(10) NOT NULL DEFAULT 'OFFERED',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`);
// --- missing columns on existing tables ---
await queryRunner.query(`ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz;`);
await queryRunner.query(`ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME',
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40),
ADD COLUMN IF NOT EXISTS duty_required boolean,
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`);
await queryRunner.query(`ALTER TABLE freight.cargoes
ADD COLUMN IF NOT EXISTS receiver_name varchar,
ADD COLUMN IF NOT EXISTS delivered_at timestamp,
ADD COLUMN IF NOT EXISTS delivery_remarks text;`);
await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles
ADD COLUMN IF NOT EXISTS duty_required boolean,
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
ADD COLUMN IF NOT EXISTS current_phase varchar(40),
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
await queryRunner.query(`ALTER TABLE freight.first_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.last_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.route_milestones
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`);
await queryRunner.query(`ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`);
await queryRunner.query(`ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL,
ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase
ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`);
await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24,
ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8,
ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30,
ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60,
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`);
}
public async down(): Promise<void> {
// No-op: this migration only repairs drift by additively creating objects
// that other migrations own. Rolling it back would drop objects those
// migrations legitimately created. Revert individual feature migrations
// instead if needed.
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Session store for the VeriFayda 2.0 OIDC verification flow (ported from
* passenger-api). One row per started verification; `state` is the
* single-use CSRF token linking the eSignet redirect back to the session.
*/
export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface {
name = "AddFaydaVerificationSessions1890000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
state varchar NOT NULL UNIQUE,
code_verifier varchar NOT NULL,
purpose varchar NOT NULL DEFAULT 'VERIFY',
platform varchar NOT NULL DEFAULT 'WEB',
save_to_account boolean NOT NULL DEFAULT false,
status varchar NOT NULL DEFAULT 'PENDING',
error_code varchar,
error_description text,
iam_user_id uuid,
expires_at timestamptz NOT NULL,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_EXPIRES_AT"
ON freight.fayda_verification_sessions (expires_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID"
ON freight.fayda_verification_sessions (iam_user_id)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Track Fayda identity verification on drivers: whether the driver's
* identity was verified through VeriFayda and the OIDC subject it was
* verified against.
*/
export class AddDriverFaydaVerification1890000000003 implements MigrationInterface {
name = "AddDriverFaydaVerification1890000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS fayda_sub varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS fayda_verified,
DROP COLUMN IF EXISTS fayda_sub
`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Allow more than one vehicle per last-mile delivery. Junction table joins
* last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as
* the first assignment so nothing is lost.
*/
export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface {
name = "AddLastMileVehicleAssignments1890000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE,
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE"
ON freight.last_mile_vehicle_assignments (vehicle_id)
`);
// Backfill: existing single-vehicle assignments become the first row
await queryRunner.query(`
INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id)
SELECT id, vehicle_id FROM freight.last_mile
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Store the driver's gender. Prefilled from the Fayda VERIFY response
* (Male/Female) but editable; nullable so existing rows and manual,
* non-Fayda driver records stay valid.
*/
export class AddDriverGender1890000000005 implements MigrationInterface {
name = "AddDriverGender1890000000005";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD COLUMN IF NOT EXISTS gender varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP COLUMN IF EXISTS gender
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Enforce one driver record per verified Fayda identity. A unique index on
* fayda_sub blocks a second driver from being created against the same Fayda
* OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected.
*/
export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface {
name = "AddDriverFaydaSubUnique1890000000006";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
ON freight.drivers (fayda_sub)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"
`);
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Make driver uniqueness soft-delete aware. The original table used plain
* column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted
* rows, so deleting a driver then re-adding the same email/phone/license/Fayda
* identity failed at the DB with a raw 500 — even though the service's own
* (deleted_at-excluding) duplicate check saw nothing. Replace them with partial
* unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches
* what the service enforces and freed values become reusable after deletion.
*/
export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface {
name = "DriverUniquePartialSoftDelete1890000000007";
public async up(queryRunner: QueryRunner): Promise<void> {
// Drop the full-table unique constraints from CreateDriversTable...
await queryRunner.query(`
ALTER TABLE freight.drivers
DROP CONSTRAINT IF EXISTS drivers_email_key,
DROP CONSTRAINT IF EXISTS drivers_phone_number_key,
DROP CONSTRAINT IF EXISTS drivers_license_number_key
`);
// ...and the plain fayda_sub unique index from 1890000000006.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`);
// Re-add each as a partial unique index scoped to non-deleted rows.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE"
ON freight.drivers (email) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE"
ON freight.drivers (phone_number) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE"
ON freight.drivers (license_number) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE"
ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
ON freight.drivers (fayda_sub)
`);
await queryRunner.query(`
ALTER TABLE freight.drivers
ADD CONSTRAINT drivers_email_key UNIQUE (email),
ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number),
ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number)
`);
}
}

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle
* status/availability transitions, first/last-mile vehicle assignments + mile
* status changes). Queried by vehicle_id or driver_id to build a per-record
* timeline. Populated going forward — existing records have no back-history.
*/
export class AddFleetEvents1890000000008 implements MigrationInterface {
name = "AddFleetEvents1890000000008";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.fleet_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_type varchar NOT NULL,
vehicle_id uuid,
driver_id uuid,
first_mile_id uuid,
last_mile_id uuid,
from_value varchar,
to_value varchar,
label varchar,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE"
ON freight.fleet_events (vehicle_id, created_at)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER"
ON freight.fleet_events (driver_id, created_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Container number carried by each vehicle on a last-mile delivery. Auto-filled
* from the booking's container number when present, else entered by the operator
* at assignment time.
*/
export class AddLastMileAssignmentContainerNumber1890000000009
implements MigrationInterface
{
name = "AddLastMileAssignmentContainerNumber1890000000009";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS container_number varchar
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS container_number
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Per-vehicle actual distance on a last-mile delivery. A booking served by
* several trucks records each truck's km; the record's total (last_mile.exact_km)
* is their sum and drives the invoice.
*/
export class AddLastMileAssignmentDistance1890000000010
implements MigrationInterface
{
name = "AddLastMileAssignmentDistance1890000000010";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS distance_km
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings.
* Tracking only — does not gate dispatch.
*/
export class AddLoadingStatusToTrainScheduleBookings1900000000000
implements MigrationInterface
{
name = "AddLoadingStatusToTrainScheduleBookings1900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
DROP COLUMN IF EXISTS loading_status
`);
}
}

View File

@@ -0,0 +1,126 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Simplify the rate + weight-limit configuration model:
*
* 1. Drop the effective_from / effective_to validity window from both
* `rates` and `weight_limit_rules`. Rates are now activated purely by
* the approval workflow (status = LIVE) and weight limits are always
* active for their container + direction. No time-travel scheduling.
*
* 2. Enforce "one rate per pattern" with partial unique indexes so the same
* configuration (e.g. FIRST_MILE for a given container type) cannot be
* duplicated. NULL scope columns are COALESCE-normalised because Postgres
* treats NULLs as distinct in a plain unique index.
*
* This migration is destructive on the date columns — existing effective_*
* values are dropped.
*/
export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface {
name = 'SimplifyRatesAndWeightLimitRules1900000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. De-duplicate existing data so the unique indexes can be created ──
// Keep the most recently-created row per pattern, soft-delete the rest.
await queryRunner.query(`
WITH ranked AS (
SELECT id,
row_number() OVER (
PARTITION BY rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
rate_unit
ORDER BY created_at DESC, id DESC
) AS rn
FROM freight.rates
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'
)
UPDATE freight.rates r
SET deleted_at = now()
FROM ranked
WHERE r.id = ranked.id AND ranked.rn > 1;
`);
await queryRunner.query(`
WITH ranked AS (
SELECT id,
row_number() OVER (
PARTITION BY container_type_id, trade_direction
ORDER BY created_at DESC, id DESC
) AS rn
FROM freight.weight_limit_rules
WHERE deleted_at IS NULL
)
UPDATE freight.weight_limit_rules w
SET deleted_at = now()
FROM ranked
WHERE w.id = ranked.id AND ranked.rn > 1;
`);
// ── 2. Drop the effective-date indexes + columns ───────────────────────
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`);
// Indexes created by TypeORM's @Index carry generated hashed names — drop
// any index that references the effective_from column defensively.
await queryRunner.query(`
DO $$
DECLARE idx record;
BEGIN
FOR idx IN
SELECT indexname FROM pg_indexes
WHERE schemaname = 'freight'
AND tablename IN ('rates', 'weight_limit_rules')
AND indexdef ILIKE '%effective_from%'
LOOP
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname);
END LOOP;
END $$;
`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`);
// ── 3. One-rate-per-pattern partial unique indexes ─────────────────────
// The unit is part of the identity so a surcharge can legitimately carry two
// rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON),
// while still blocking a true duplicate (same rateType + scope + unit).
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern"
ON freight.weight_limit_rules (container_type_id, trade_direction)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`);
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`);
await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`);
await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`);
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`);
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`,
);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2)
* to numeric(6,4). The UI now lets staff enter the booking-window duration in
* minutes / hours / days and converts to the column's native hours unit; a
* 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min).
* Four decimals store sub-minute durations exactly (0.0667h → 4.00 min).
*/
export class WidenWindowDurationHoursPrecision1910000000000
implements MigrationInterface
{
name = "WidenWindowDurationHoursPrecision1910000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_duration_hours TYPE numeric(6, 4);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ALTER COLUMN window_duration_hours TYPE numeric(4, 2);
`);
}
}

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Snapshot the booking-window rule onto each train schedule.
*
* A schedule's window (open time + reopen cycles) must be frozen to the rule it
* was created with: a later global-rules edit applies only to FUTURE schedules,
* while an already-open schedule keeps its base rule. Previously the batch board
* recomputed windows from the LIVE global config, so editing the rule redrew the
* board for open schedules (a synthetic grid that no longer matched the window
* the customer was shown). These columns give the board a per-schedule rule to
* derive its display windows from.
*
* Existing rows are backfilled from the current global-rules singleton — the best
* available base, since they never stored one. Their stamped windowOpensAt/
* windowClosesAt are still real, so only projected reopen cycles rely on the
* backfill.
*/
export class AddScheduleWindowRuleSnapshot1920000000000
implements MigrationInterface
{
name = "AddScheduleWindowRuleSnapshot1920000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_window_open_hour integer,
ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4),
ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer,
ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer,
ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer;
`);
// Backfill from the global-rules singleton so pre-existing schedules render.
await queryRunner.query(`
UPDATE freight.train_schedules ts
SET
rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour),
rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours),
rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes),
rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days),
rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours)
FROM freight.train_scheduling_global_rules r
WHERE ts.rule_window_open_hour IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_window_open_hour,
DROP COLUMN IF EXISTS rule_window_duration_hours,
DROP COLUMN IF EXISTS rule_reopen_delay_minutes,
DROP COLUMN IF EXISTS rule_import_window_lead_days,
DROP COLUMN IF EXISTS rule_export_booking_lead_hours;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a hard per-unit weight ceiling to weight limit rules.
*
* maxVgmTons stays the soft "overweight" threshold (surcharge + warning);
* max_capacity_tons is the absolute ceiling above which a booking cannot be
* created at all. Null means no ceiling (existing behavior).
*/
export class AddMaxCapacityToWeightLimitRules1930000000000
implements MigrationInterface
{
name = "AddMaxCapacityToWeightLimitRules1930000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS max_capacity_tons;
`);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Allow more than one vehicle per first-mile pickup. Junction table joins
* first_mile ⇄ vehicles, with each truck's container number + actual distance;
* existing single vehicle_id values are backfilled as the first assignment so
* nothing is lost. Mirrors the last-mile vehicle-assignment schema.
*/
export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface {
name = "AddFirstMileVehicleAssignments1940000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE,
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
container_number varchar,
distance_km numeric(10,2),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE"
ON freight.first_mile_vehicle_assignments (vehicle_id)
`);
// Backfill: existing single-vehicle assignments become the first row
await queryRunner.query(`
INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id)
SELECT id, vehicle_id FROM freight.first_mile
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`);
}
}

View File

@@ -0,0 +1,133 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Replace load-type string matching with a real wagon-type foreign key.
*
* Before this migration, train scheduling picked a wagon type by matching
* strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
* and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
* `cargo_types` and `container_types` so scheduling resolves the wagon type
* through the relation instead.
*
* The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
* never ship in bulk have no wagon type, and forcing one onto them is
* meaningless. Scheduling enforces the requirement at run time (it throws when a
* scheduled bulk cargo type or a container type in the batch has no wagon type).
*
* Backfill reproduces the old hardcoded resolution one final time so existing
* bulk cargo + container rows are not left unset. After this, the runtime map is
* removed — the FK is the single source of truth.
*/
export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
implements MigrationInterface
{
name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── Columns + FKs ────────────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD CONSTRAINT fk_cargo_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD CONSTRAINT fk_container_types_wagon_type
FOREIGN KEY (wagon_type_id)
REFERENCES freight.wagon_types(id)
ON DELETE RESTRICT;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
ON freight.cargo_types (wagon_type_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
ON freight.container_types (wagon_type_id);
`);
// ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
// COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
// COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
const cargoCodeToWagon: Record<string, string> = {
COFFEE: "KW2",
GRAIN: "KW2",
WHEAT: "KW2",
SORGHUM: "KW2",
CORN: "KW2",
FERTILIZER: "PW2",
SUGAR: "PW2",
COAL: "KW3",
STEEL: "CW3",
ORE: "CW3",
};
for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
await queryRunner.query(
`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = $1
AND UPPER(TRIM(ct.code)) = $2
AND ct.wagon_type_id IS NULL;
`,
[wagonCode, cargoCode],
);
}
// Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
await queryRunner.query(`
UPDATE freight.cargo_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'CW3'
AND ct.wagon_type_id IS NULL
AND ct.unit_of_measure = 'PER_TON';
`);
// All container types → the old container default wagon NW5.
await queryRunner.query(`
UPDATE freight.container_types ct
SET wagon_type_id = wt.id
FROM freight.wagon_types wt
WHERE wt.code = 'NW5'
AND ct.wagon_type_id IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types
DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
`);
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
`);
}
}

View File

@@ -307,6 +307,16 @@ export class BillingService {
});
}
/** Invoices for a batch of source records (e.g. many last-mile legs), so a
* list can show which records already have an invoice without N+1 queries. */
findBySourceIds(source: string, sourceIds: string[]): Promise<Invoice[]> {
if (!sourceIds.length) return Promise.resolve([]);
return this.invoices.findAll({
where: { source, sourceId: In(sourceIds) },
order: { createdAt: "DESC" },
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
async findForUser(
userId: string,

View File

@@ -431,7 +431,7 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
@@ -571,6 +571,23 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
* an unsaved preview booking (no id) sums the wagonsRequired already computed
* on its in-memory container lines — same math, no DB row needed.
*/
private async resolveWagonCount(booking: Booking): Promise<number> {
if (!booking.id) {
return Math.ceil(
(booking.bookingContainers ?? []).reduce(
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
0,
),
);
}
return this.bookingsRepository.calculateWagonCount(booking.id);
}
/** Friendly container-type label for the per-unit card; degrades to "Container". */
private async containerTypeLabel(containerTypeId: string): Promise<string> {
try {

View File

@@ -1051,17 +1051,27 @@ export class BookingTransitionService {
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
// only reserve once both partners are FULLY_EXECUTED (handled inside).
const fresh = await this.bookingsService.findById(booking.id);
await this.bookingBatchService.acceptExportBooking(fresh);
} else if (booking.tradeDirection === "IMPORT") {
// Import bookings wait for their booking-day window cycle — the batch runs
// after staff document review, never at accept time.
} else if (booking.scheduledDate) {
this.bookingBatchService.enqueueRouteDayProcessing(
booking.originYardId,
booking.destinationYardId,
eatDay(new Date(booking.scheduledDate)),
);
try {
await this.bookingBatchService.acceptExportBooking(fresh);
} catch (err) {
// The status update above already committed. Without compensation the
// client gets an error for a booking that reads as accepted after a
// refresh — half-applied state. Put the request back so staff can retry.
await this.bookingsRepository.update(booking.id, {
status: "OPERATION_REQUEST_PENDING",
fullyExecutedAt: null,
lockedAt: booking.lockedAt ?? null,
} as never);
this.logger.warn(
`Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`,
);
throw err;
}
}
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
return this.bookingsService.findById(booking.id);
}
@@ -1078,23 +1088,59 @@ export class BookingTransitionService {
} | null;
}
> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
"CHANGES_REQUESTED",
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
const activeBatchOffer =
booking.status === "SELECTED_FOR_BATCH"
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
: null;
// This enrichment runs AFTER the transition has committed. A failure here
// must never 500 the response — the client would report "failed" for a
// transition that actually succeeded (visible only after a refresh).
// Degrade each fragile field to null instead.
let note: Awaited<
ReturnType<typeof this.bookingsRepository.findLatestReviewNote>
> = null;
try {
note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
"CHANGES_REQUESTED",
);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
let summary: string | null = booking.contractSummary ?? null;
try {
summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`,
);
}
let nextStep: BookingNextStep | null = null;
try {
const nextPending =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
nextStep = computeNextStep(booking, nextPending);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
let activeBatchOffer: Awaited<
ReturnType<typeof this.bookingBatchService.getOpenOfferSummary>
> = null;
try {
activeBatchOffer =
booking.status === "SELECTED_FOR_BATCH"
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
: null;
} catch (err) {
this.logger.warn(
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
return {
...booking,
latestChangeRequestNote: note?.note ?? null,

View File

@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -35,6 +36,7 @@ export interface BookingListFilterOptions {
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
@@ -89,6 +91,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('bc.units', 'bcu')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.train', 'train')
@@ -103,6 +106,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.id = :id', { id })
.addOrderBy('bcu.sort_order', 'ASC')
.leftJoinAndMapMany(
'booking.files',
FileRecord,
@@ -584,6 +588,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
// Booking → contract, so join the entity by id and select just the
// reference — a schema-qualified table string is parsed as alias.relation
// by TypeORM and crashes).
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
@@ -602,10 +612,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
}
const [items, total] = await qb
const total = await qb.getCount();
const { entities: items, raw } = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
.getRawAndEntities();
// The joined contract.reference comes back on the raw rows only (entity has no
// contract relation) — map it onto each booking by position.
const contractRefByBooking = new Map<string, string | null>();
for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) {
if (row.booking_id && !contractRefByBooking.has(row.booking_id)) {
contractRefByBooking.set(row.booking_id, row.contract_reference ?? null);
}
}
for (const item of items) {
(item as Booking & { contractReference?: string | null }).contractReference =
contractRefByBooking.get(item.id) ?? null;
}
if (items.length) {
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
@@ -737,6 +761,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
freightType: options.freightType,
});
}
if (options.bookingType) {
qb.andWhere('booking.bookingType = :bookingType', {
bookingType: options.bookingType,
});
}
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {
createdFrom: options.createdFrom,
@@ -1051,8 +1080,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
cargoType: true,
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
// containerType.wagonType + cargoType.wagonType drive wagon-type
// resolution during scheduling (FK, not the old load-type string map).
bookingContainers: { containerType: { wagonType: true }, units: true },
cargoType: { wagonType: true },
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});

View File

@@ -1001,6 +1001,7 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { Booking } from './booking.entity';
import { BookingContainerUnit } from './booking-container-unit.entity';
@Entity({ schema: 'freight', name: 'booking_container' })
@Index(['bookingId'])
@@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity {
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
overweightExcessTons?: number | null;
/** The physical containers under this line — each with its own number + VGM. */
@OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer)
units?: BookingContainerUnit[];
}

View File

@@ -157,6 +157,10 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
contractRouteId?: string | null;
/** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
bookingType!: string;
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
contractKind?: string | null;

View File

@@ -334,15 +334,19 @@ export class CompaniesController {
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
const files = await this.filesService.findByResource(companyId, "companies");
return files.map((f) => ({
id: f.id,
name: f.name,
code: f.code,
mimeType: f.mimeType,
size: f.size,
uploadedAt: f.createdAt,
url: f.url,
}));
return Promise.all(
files.map(async (f) => ({
id: f.id,
name: f.name,
code: f.code,
mimeType: f.mimeType,
size: f.size,
uploadedAt: f.createdAt,
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
// it so the file previews/downloads in the client.
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
})),
);
}
@Post(":companyId/documents")

View File

@@ -1,9 +1,11 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
@@ -12,9 +14,11 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -62,6 +66,8 @@ export class ContractBookingService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
async createUnderContract(
@@ -113,6 +119,32 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// 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) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
}
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
// 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
// ≤ the cap, and drawdown bookings never pass through submit — so this is
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
reference,
@@ -566,11 +598,14 @@ export class ContractBookingService {
}
/**
* Pre-create validation for the shipment form: run the overweight rule + the
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
* booking. The portal calls this from the price-confirm modal so the customer
* sees the overweight warning (+ surcharge basis) and is blocked on an
* un-pairable 20ft set before the booking is created.
* Pre-create validation + authoritative price preview for the shipment form:
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
* would persist it and run the same BookingPricingService compute over it —
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
* (overweight, hazard, reefer, consolidation, …). The portal and the GL
* backoffice form call this from the price-confirm modal, so the breakdown the
* user confirms is line-for-line what the booking will be charged. Also runs
* the 20ft weight-pairing rule, which hard-blocks creation.
*/
async validateShipment(
contractId: string,
@@ -582,16 +617,31 @@ export class ContractBookingService {
maxAllowedTons: number;
excessTons: number;
}>;
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
capacityErrors: string[];
lineItems: PriceLineItemDto[];
totalAmount: number;
}> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const lines = dto.containers ?? [];
if (!lines.length) return { overweightLines: [], pairingErrors: [] };
if (contract.freightType === 'CONTAINER' && !lines.length) {
return {
overweightLines: [],
overweightSurchargeAmount: 0,
currency: null,
pairingErrors: [],
capacityErrors: [],
lineItems: [],
totalAmount: 0,
};
}
// Resolve each line's container type + total VGM (sum of unit weights) so the
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
// Resolve each container line's type + total VGM (sum of unit weights)
// mirrors persistContainers so the preview lines match the persisted ones.
const resolved = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
@@ -606,46 +656,44 @@ export class ContractBookingService {
}),
);
const ruleResult = await this.ruleEngineService.evaluate({
freightType: 'CONTAINER',
cargoTypeId: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
// The unsaved twin of the booking createUnderContract would write: same
// denormalized contract fields, same container-line math. No id → the
// pricing service derives wagon counts from the in-memory lines.
const route = await this.resolveRoute(contract, dto.contractRouteId);
const previewBooking = Object.assign(new Booking(), {
freightType: contract.freightType,
tradeDirection: contract.tradeDirection,
isHazardous: false,
isReefer: contract.isReefer ?? false,
isGovernment: false,
allowConsolidation: false,
paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isGovernment: contract.isGovernment,
shippingLineId: null,
totalWagons: 0,
bulkTons: 0,
containers: resolved.map((r) => ({
containerTypeId: r.ct.id,
quantity: r.line.quantity,
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
totalVgmTons: r.totalVgmTons,
isReefer: r.ct.isReefer,
})),
} as never);
contractRouteId: route?.id ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
Object.assign(new BookingContainer(), {
containerTypeId: ct.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
}),
),
}) as Booking;
const overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}> = [];
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue;
const r = resolved[i];
const excessTons = Number(wr.overweightExcessTons ?? 0);
overweightLines.push({
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
totalVgmTons: r?.totalVgmTons ?? 0,
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
excessTons,
});
}
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
// The overweight surcharge line is already currency-converted; surface its
// amount separately so the warning alert can reference the exact charge.
const overweightSurchargeAmount =
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
const twentyFtUnits = resolved
@@ -661,7 +709,90 @@ export class ContractBookingService {
(v) => v.message,
);
return { overweightLines, pairingErrors };
// Hard capacity ceiling — a non-empty result means the create call will be
// rejected, so the form can block submit up front.
const capacityErrors = await this.ruleEngineService.capacityViolations(
resolved.map(({ line, ct, totalVgmTons }) => ({
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
})),
contract.tradeDirection,
);
return {
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
}
/**
* Throws when any container line's total weight exceeds the hard capacity
* ceiling of its weight limit rule. Mirrors validateShipment's line
* resolution so the gate matches what the form preview reported.
*/
private async assertWithinMaxCapacity(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) return;
const containers = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
}),
);
const violations = await this.ruleEngineService.capacityViolations(
containers,
contract.tradeDirection,
);
if (violations.length) {
throw new BadRequestException(violations.join('; '));
}
}
/**
* Hard-block booking creation when the 20ft container weights cannot be
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>
(line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
if (twentyFtUnits.length < 2) return;
const maxDiff = await this.max20ftPairDiffTons();
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
if (violations.length) {
throw new BadRequestException(
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
private async max20ftPairDiffTons(): Promise<number> {

View File

@@ -77,6 +77,9 @@ export interface ContractClearanceView {
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
exportClearanceFinalized?: boolean;
linkedBookingId?: string | null;
/** Reference + status of the GL-created shipment booking, once it exists. */
linkedBookingReference?: string | null;
linkedBookingStatus?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -284,13 +287,22 @@ export class ContractClearanceService {
);
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
// Once GL creates the shipment booking, surface its reference + status so the
// customer sees the concrete booking instead of a stale "will be created
// shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null;
if (cycle?.bookingId) {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
}
}
@@ -326,6 +338,8 @@ export class ContractClearanceService {
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId: cycle?.bookingId ?? null,
linkedBookingReference,
linkedBookingStatus,
dutyAdvice,
workflowFiles,
t1,

View File

@@ -791,7 +791,7 @@ export class ContractsController {
@Post(':id/validate-shipment')
@ApiOperation({
summary:
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
})
validateShipment(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -13,6 +13,7 @@ import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.m
import { SignaturesModule } from '../signatures/signatures.module';
import { OtpModule } from '../otp/otp.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -78,6 +79,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),
// TrainSchedulingModule provides the config-driven booking-window gate used
// by ContractBookingService.createUnderContract. forwardRef because
// TrainSchedulingModule already imports ContractsModule.
forwardRef(() => TrainSchedulingModule),
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
// Attach the generated contract PDF to each row so list/home can offer a
// direct download. Loaded separately to keep pagination counts correct.
await this.attachContractFiles(items);
await this.attachClearancePhases(items);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
@@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
}
/**
* Attach each contract's persisted clearance phase (latest cycle's
* current_phase) so list consumers can show step-accurate customer actions
* ("Pay duty & upload slip" vs generic "Update clearance") without a
* per-contract clearance-view request. One query per page, like
* `attachContractFiles`.
*/
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
if (contracts.length === 0) return;
const ids = contracts.map((c) => c.id);
const rows: Array<{ contract_id: string; current_phase: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
FROM freight.contract_clearance_cycles
WHERE contract_id = ANY($1)
ORDER BY contract_id, cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
for (const contract of contracts) {
contract.clearancePhase = byContract.get(contract.id) ?? null;
}
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('contract')

View File

@@ -254,4 +254,10 @@ export class Contract extends BaseEntity {
createForeignKeyConstraints: false,
})
files?: FileRecord[];
/**
* Latest clearance cycle's current_phase, attached by
* ContractsRepository.attachClearancePhases for list responses. Not a column.
*/
clearancePhase?: string | null;
}

View File

@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
import { DriversService } from './drivers.service';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('drivers')
@ApiBearerAuth()
@Controller('drivers')
@FleetView()
export class DriversController {
constructor(private readonly driversService: DriversService) {}
constructor(
private readonly driversService: DriversService,
private readonly fleetHistory: FleetHistoryService,
) {}
@Post()
@FleetManage()
@@ -55,6 +59,12 @@ export class DriversController {
return this.driversService.findById(id);
}
@Get(':id/history')
@ApiOperation({ summary: 'Get driver assignment & activity history' })
history(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetHistory.getDriverHistory(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a driver' })

View File

@@ -1,18 +1,27 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateDriverDto } from './dto/create-driver.dto';
import { UpdateDriverDto } from './dto/update-driver.dto';
import { Driver, DriverStatus } from './entities/driver.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
@Injectable()
export class DriversService {
constructor(
@InjectRepository(Driver)
private readonly driverRepo: Repository<Driver>,
private readonly history: FleetHistoryService,
) {}
async create(dto: CreateDriverDto): Promise<Driver> {
if (dto.faydaVerified !== true) {
throw new BadRequestException(
'Driver identity must be verified with Fayda before saving',
);
}
const existing = await this.driverRepo.findOne({
where: [
{ licenseNumber: dto.licenseNumber },
@@ -33,8 +42,28 @@ export class DriversService {
}
}
if (dto.faydaSub) {
const dupe = await this.driverRepo.findOne({
where: { faydaSub: dto.faydaSub },
});
if (dupe) {
throw new ConflictException(
'A driver is already registered for this Fayda identity',
);
}
}
const driver = this.driverRepo.create(dto);
return this.driverRepo.save(driver);
const saved = await this.driverRepo.save(driver);
await this.history.record({
eventType: FleetEventType.DRIVER_REGISTERED,
driverId: saved.id,
label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null,
toValue: saved.status ?? null,
});
return saved;
}
async findAll(query: {
@@ -106,7 +135,25 @@ export class DriversService {
}
}
if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) {
const dupe = await this.driverRepo.findOne({
where: { faydaSub: dto.faydaSub },
});
if (dupe) {
throw new ConflictException(
'A driver is already registered for this Fayda identity',
);
}
}
Object.assign(driver, dto);
if (driver.faydaVerified !== true) {
throw new BadRequestException(
'Driver identity must be verified with Fayda before saving',
);
}
return this.driverRepo.save(driver);
}

View File

@@ -1,5 +1,5 @@
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
import { DriverStatus } from '../entities/driver.entity';
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { DriverStatus, DriverGender } from '../entities/driver.entity';
export class CreateDriverDto {
@IsString()
@@ -20,6 +20,10 @@ export class CreateDriverDto {
@IsDateString()
dateOfBirth!: string;
@IsOptional()
@IsEnum(DriverGender)
gender?: DriverGender;
@IsDateString()
licenseExpiryDate!: string;
@@ -42,4 +46,12 @@ export class CreateDriverDto {
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsBoolean()
faydaVerified?: boolean;
@IsOptional()
@IsString()
faydaSub?: string;
}

View File

@@ -8,6 +8,12 @@ export enum DriverStatus {
ON_LEAVE = 'ON_LEAVE',
}
export enum DriverGender {
MALE = 'MALE',
FEMALE = 'FEMALE',
OTHER = 'OTHER',
}
@Entity({ name: 'drivers', schema: 'freight' })
export class Driver extends BaseEntity {
@Column({ name: 'license_number', unique: true, nullable: true })
@@ -28,6 +34,9 @@ export class Driver extends BaseEntity {
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
dateOfBirth?: Date;
@Column({ type: 'varchar', nullable: true })
gender?: DriverGender | null;
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
licenseExpiryDate?: Date;
@@ -51,4 +60,12 @@ export class Driver extends BaseEntity {
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
rating?: number | null;
@Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true })
faydaVerified?: boolean;
/** Fayda OIDC subject the identity was verified against. Unique — one driver
* record per verified Fayda identity (NULLs allowed for legacy/unverified). */
@Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true })
faydaSub?: string | null;
}

View File

@@ -123,6 +123,16 @@ export class FilesService {
return this.filesRepository.findByResource(resourceId, resource);
}
/**
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
* `url` is an un-signed object path that a browser cannot fetch directly;
* callers that expose files for preview/download must sign them first.
*/
async signUrl(rawUrl: string, expirySeconds = 300): Promise<string> {
const objectName = this.minioService.getObjectNameFromUrl(rawUrl);
return this.minioService.getSignedUrl(objectName, expirySeconds);
}
async findByCode(
resourceId: string,
resource: string,

View File

@@ -1,8 +0,0 @@
export class FirstMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateFirstMileContainersDto {
allocations!: FirstMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,24 @@
import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class VehicleDistanceInput {
@IsUUID()
vehicleId!: string;
@IsNumber()
@Min(0)
distanceKm!: number;
}
/** Per-vehicle actual distances for a first-mile pickup (multi-truck). */
export class SetDistancesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => VehicleDistanceInput)
distances!: VehicleDistanceInput[];
/** Recomputed remaining payment (total km × rate), from the client. */
@IsOptional()
@IsNumber()
remainingPayment?: number;
}

View File

@@ -0,0 +1,19 @@
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FirstMileVehicleInput {
@IsUUID()
vehicleId!: string;
@IsOptional()
@IsString()
containerNumber?: string;
}
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
export class SetVehiclesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => FirstMileVehicleInput)
vehicles!: FirstMileVehicleInput[];
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMile } from './first-mile.entity';
/**
* One row per vehicle assigned to a first-mile pickup. A pickup can be served
* by several vehicles at once (multi-truck bookings); the legacy
* `first_mile.vehicle_id` column keeps pointing at the first assignment for
* backward compatibility.
*/
@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' })
@Unique(['firstMileId', 'vehicleId'])
@Index(['vehicleId'])
export class FirstMileVehicleAssignment extends BaseEntity {
@Column({ name: 'first_mile_id', type: 'uuid' })
firstMileId!: string;
@ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'first_mile_id' })
firstMile?: FirstMile;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
/** Container this truck carries — auto-filled from the booking's container
* number when known, else entered manually at assignment time. */
@Column({ name: 'container_number', type: 'varchar', nullable: true })
containerNumber?: string | null;
/** Actual distance driven by this truck (km), entered per vehicle. */
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
distanceKm?: number | null;
}

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity {
{ eager: false },
)
containerAllocations!: FirstMileContainerAllocation[];
@OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile)
vehicleAssignments?: FirstMileVehicleAssignment[];
}

View File

@@ -57,7 +57,8 @@ export class FirstMileInvoiceService {
return null;
}
const totalAmount = record.remainingPayment || 0;
// numeric columns come back as strings — coerce before the finite/>0 check.
const totalAmount = Number(record.remainingPayment) || 0;
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
this.logger.warn(
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
@@ -71,7 +72,7 @@ export class FirstMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: fm.booking!.companyId,
companyProfileId: fm.booking!.companyProfileId || '',
currency: 'ETB',
currency: fm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -17,13 +18,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { BillingService } from '../billing/billing.service';
import { BookingsService } from '../bookings/bookings.service';
import { Freight } from '@edr/types';
@ApiTags('first-mile')
@ApiBearerAuth()
@@ -33,8 +32,6 @@ export class FirstMileController {
constructor(
private readonly firstMileService: FirstMileService,
private readonly firstMileInvoiceService: FirstMileInvoiceService,
private readonly billingService: BillingService,
private readonly bookingsService: BookingsService
) { }
@Get()
@@ -89,39 +86,43 @@ export class FirstMileController {
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a first-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
const record = await this.firstMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
const booking = await this.bookingsService.findById(record.bookingId);
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
await this.billingService.generateInvoice({
source: Freight.InvoiceSource.FirstMile,
sourceId: record.id,
type: "FIRST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
// No invoice side-effects — invoices are generated only via the explicit
// POST :id/invoice endpoint (the "Generate Invoice" action).
return this.firstMileService.update(id, dto);
}
lines: [
{
chargeType: "FIRST_MILE",
description: "First Mile Transportation Service",
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
},
],
subtotalAmount: record.remainingPayment,
taxAmount: 0, // Replace if VAT/tax applies
totalAmount: record.remainingPayment,
dueInDays: 7,
status: Freight.InvoiceStatus.Pending,
});
await this.firstMileInvoiceService.ensureInvoiceFor(record);
@Post(':id/invoice')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.firstMileService.findById(id);
const invoice = await this.firstMileInvoiceService.ensureInvoiceFor(record);
if (!invoice) {
throw new BadRequestException(
'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a FIRST_MILE rate is configured, and the booking has a company.',
);
}
return record;
return invoice;
}
@Post(':id/vehicles')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetVehiclesDto,
) {
return this.firstMileService.setVehicles(id, dto.vehicles);
}
@Post(':id/distances')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDistancesDto,
) {
return this.firstMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Delete(':id')
@@ -131,14 +132,4 @@ export class FirstMileController {
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.remove(id);
}
@Post(':firstMileId/allocate-containers')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
allocateContainers(
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
@Body() dto: AllocateFirstMileContainersDto,
) {
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
}
}

View File

@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { FirstMileRepository } from './first-mile.repository';
@@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]),
forwardRef(() => BillingModule),
forwardRef(() => BookingsModule),
VehiclesModule,

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, In } from 'typeorm';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
@@ -11,9 +11,12 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto";
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity";
import { FirstMileRepository } from "./first-mile.repository";
import { OnEvent } from "@nestjs/event-emitter";
import { InvoiceEventPayload } from "../billing/billing.service";
import { BillingService, InvoiceEventPayload } from "../billing/billing.service";
import { FleetHistoryService } from "../fleet-history/fleet-history.service";
import { FleetEventType } from "../fleet-history/entities/fleet-event.entity";
type FirstMileListFilter = {
status?: FirstMileStatus;
@@ -43,8 +46,78 @@ export class FirstMileService {
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly history: FleetHistoryService,
private readonly billing: BillingService,
) { }
/** Attach real invoice info so the UI shows an invoice link only when one
* exists — not merely because distance was entered. Batched (no N+1). */
private async attachInvoices(records: FirstMile[]): Promise<void> {
const invoices = await this.billing.findBySourceIds(
'first_mile',
records.map((r) => r.id),
);
const byId = new Map<string, { id: string; number: string; status: string }>();
for (const inv of invoices) {
if (!byId.has(inv.sourceId)) {
byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) });
}
}
for (const r of records) {
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
}
}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
private async vehicleInfo(
vehicleId?: string | null,
): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> {
if (!vehicleId) return { driverId: null, plate: null, driverName: null };
try {
const v = await this.vehiclesService.findById(vehicleId);
return {
driverId: v.assignedDriverId ?? null,
plate: v.plateNumber ?? v.code ?? null,
driverName: v.assignedDriverName ?? null,
};
} catch {
return { driverId: null, plate: null, driverName: null };
}
}
/** A leg counts as having a vehicle if it has a direct assignment or at least
* one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */
private async hasAssignedVehicle(
recordId: string,
directVehicleId?: string | null,
): Promise<boolean> {
if (directVehicleId) return true;
const [junction, allocations] = await Promise.all([
this.dataSource.manager.count(FirstMileVehicleAssignment, {
where: { firstMileId: recordId },
}),
this.dataSource.manager.count(FirstMileContainerAllocation, {
where: { firstMileId: recordId, vehicleId: Not(IsNull()) },
}),
]);
return junction > 0 || allocations > 0;
}
/** Human booking reference for a first-mile record, for the history timeline. */
private async resolveBookingRef(record: FirstMile): Promise<string | null> {
const loaded = (record as FirstMile & { booking?: { reference?: string } })
.booking?.reference;
if (loaded) return loaded;
if (!record.bookingId) return null;
try {
const b = await this.bookingsRepository.findById(record.bookingId);
return (b as { reference?: string } | null)?.reference ?? null;
} catch {
return null;
}
}
/**
* Look up a booking by its human-readable reference and confirm it has been
* paid before any first-mile work proceeds. Throws if the reference is
@@ -135,14 +208,18 @@ export class FirstMileService {
originYard: true,
destinationYard: true,
cargoType: true,
bookingContainers: { containerType: true, units: true },
},
vehicle: true,
vehicleAssignments: { vehicle: true },
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
});
await this.attachInvoices(data);
return {
data,
meta: {
@@ -179,8 +256,10 @@ export class FirstMileService {
originYard: true,
destinationYard: true,
cargoType: true,
bookingContainers: { containerType: true, units: true },
},
vehicle: true,
vehicleAssignments: { vehicle: true },
},
});
@@ -188,6 +267,8 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${id} not found`);
}
await this.attachInvoices([record]);
return record;
}
@@ -210,6 +291,20 @@ export class FirstMileService {
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
firstMileId: record.id,
driverId: info.driverId,
label: record.status,
metadata: {
mile: 'FIRST',
bookingRef: await this.resolveBookingRef(record),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
return record;
@@ -248,6 +343,18 @@ export class FirstMileService {
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id);
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
// assigned in this same request).
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
const vehicleId =
dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId;
if (!(await this.hasAssignedVehicle(id, vehicleId))) {
throw new BadRequestException(
'Assign a vehicle before marking this first-mile leg in transit',
);
}
}
const dtoAny = dto as any;
const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -278,6 +385,39 @@ export class FirstMileService {
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
const bookingRef = await this.resolveBookingRef(existing);
if (existing.vehicleId) {
const info = await this.vehicleInfo(existing.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId: existing.vehicleId,
firstMileId: id,
driverId: info.driverId,
metadata: {
mile: 'FIRST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
if (dto.vehicleId) {
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
firstMileId: id,
driverId: info.driverId,
label: updated.status,
metadata: {
mile: 'FIRST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
@@ -285,6 +425,25 @@ export class FirstMileService {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
if (dto.status !== undefined && dto.status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
firstMileId: id,
vehicleId,
driverId: info.driverId,
fromValue: existing.status,
toValue: dto.status,
metadata: {
mile: 'FIRST',
bookingRef: await this.resolveBookingRef(existing),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
// Trip finished — release the vehicles it was holding
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
@@ -295,12 +454,40 @@ export class FirstMileService {
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
const existing = await this.findById(id);
if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) {
throw new BadRequestException(
'Assign a vehicle before marking this first-mile leg in transit',
);
}
}
const updated = await this.firstMileRepository.update(id, { status });
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
if (status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
firstMileId: id,
vehicleId,
driverId: info.driverId,
fromValue: existing.status,
toValue: status,
metadata: {
mile: 'FIRST',
bookingRef: await this.resolveBookingRef(existing),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
@@ -313,18 +500,154 @@ export class FirstMileService {
* allocations), unless still in use by another active trip.
*/
private async releaseVehicles(record: FirstMile): Promise<void> {
const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: record.id },
});
const vehicleIds = recordAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
if (record.vehicleId) {
vehicleIds.push(record.vehicleId);
}
const [assignments, recordAllocations] = await Promise.all([
this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: record.id },
}),
this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: record.id },
}),
]);
const vehicleIds = [
...new Set(
[
...assignments.map((a) => a.vehicleId),
...recordAllocations.map((a) => a.vehicleId),
record.vehicleId ?? null,
].filter((id): id is string => Boolean(id)),
),
];
await this.vehiclesService.releaseIfUnused(vehicleIds);
}
/**
* Replace the full set of vehicles serving a first-mile pickup (multi-truck).
* Diffs against the current junction rows, syncing availability + audit history
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
* `vehicleId` column for back-compat with single-vehicle readers.
*/
async setVehicles(
id: string,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
): Promise<FirstMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
for (const inp of inputs) {
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
}
const desired = [...desiredMap.keys()];
const desiredSet = new Set(desired);
const manager = this.dataSource.manager;
const current = await manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
});
const junctionSet = new Set(current.map((a) => a.vehicleId));
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
// old single-vehicle path has no junction row but must still be freed.
const releaseIds = [...new Set(
current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
)];
const added = desired.filter((v) => !junctionSet.has(v));
const removed = releaseIds.filter((v) => !desiredSet.has(v));
// Vehicles that stay but whose container number changed.
const changed = current.filter(
(a) =>
desiredMap.has(a.vehicleId) &&
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
);
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
await tx.delete(FirstMileVehicleAssignment, {
firstMileId: id,
vehicleId: In(removed),
});
}
for (const vehicleId of added) {
await tx.insert(FirstMileVehicleAssignment, {
firstMileId: id,
vehicleId,
containerNumber: desiredMap.get(vehicleId) ?? null,
});
}
for (const row of changed) {
await tx.update(
FirstMileVehicleAssignment,
{ firstMileId: id, vehicleId: row.vehicleId },
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
);
}
});
// Legacy primary vehicle = first of the set (null when cleared).
await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);
const bookingRef = await this.resolveBookingRef(existing);
for (const vehicleId of added) {
await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
void this.notifyDriverAssignment(vehicleId, existing);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId,
firstMileId: id,
driverId: info.driverId,
label: existing.status,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
for (const vehicleId of removed) {
await this.vehiclesService.releaseIfUnused([vehicleId]);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
firstMileId: id,
driverId: info.driverId,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return this.findById(id);
}
/**
* Record each truck's actual distance. The pickup total (exact_km) is their
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
* client-side. Does NOT generate an invoice — that's a separate explicit step.
*/
async setDistances(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,
remainingPayment?: number,
): Promise<FirstMile> {
await this.findById(id);
// Distances are locked once the invoice exists.
const invoices = await this.billing.findBySourceIds('first_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Distances cannot be changed after the invoice is generated',
);
}
for (const d of distances) {
await this.dataSource.manager.update(
FirstMileVehicleAssignment,
{ firstMileId: id, vehicleId: d.vehicleId },
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
await this.firstMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
} as any);
return this.findById(id);
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
@@ -383,56 +706,54 @@ export class FirstMileService {
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.firstMileRepository.softDelete(id);
}
const existing = await this.findById(id);
async allocateContainers(
firstMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const firstMile = await this.findById(firstMileId);
if (!firstMile) {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
// Can't delete once billed.
const invoices = await this.billing.findBySourceIds('first_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Cannot delete a first-mile leg after its invoice is generated',
);
}
const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: {
firstMileId,
containerId: In(allocations.map((a) => a.containerId)),
},
});
const previousVehicleIds = previousAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
// Every vehicle this pickup holds — junction + legacy + container rows.
const [assignments, allocations] = await Promise.all([
this.dataSource.manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
}),
this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: id },
}),
]);
const vehicleIds = [
...new Set(
[
...assignments.map((a) => a.vehicleId),
...allocations.map((a) => a.vehicleId),
existing.vehicleId ?? null,
].filter((v): v is string => Boolean(v)),
),
];
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
});
await manager.insert(FirstMileContainerAllocation, {
firstMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: "CONTAINER",
quantity: 1,
await this.firstMileRepository.softDelete(id);
if (assignments.length) {
await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id });
}
// Free every vehicle no longer held by another active trip and audit release.
if (vehicleIds.length) {
await this.vehiclesService.releaseIfUnused(vehicleIds);
const bookingRef = await this.resolveBookingRef(existing);
for (const vehicleId of vehicleIds) {
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
firstMileId: id,
driverId: info.driverId,
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
});
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
return {
success: true,
allocated: allocations.length,
};
}
}
}

View File

@@ -0,0 +1,55 @@
import { Entity, Column, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* Append-only audit log for fleet activity. One row per transition. Queried by
* `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may
* carry both so a driver↔vehicle assignment or a mile assignment shows on both.
* `createdAt` (from BaseEntity) is the event time.
*/
export enum FleetEventType {
DRIVER_REGISTERED = 'DRIVER_REGISTERED',
VEHICLE_REGISTERED = 'VEHICLE_REGISTERED',
DRIVER_ASSIGNED = 'DRIVER_ASSIGNED',
DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED',
VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED',
VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED',
MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED',
MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED',
MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED',
}
@Entity({ name: 'fleet_events', schema: 'freight' })
export class FleetEvent extends BaseEntity {
@Column({ name: 'event_type', type: 'varchar' })
eventType!: FleetEventType;
@Index()
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@Index()
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string | null;
@Column({ name: 'first_mile_id', type: 'uuid', nullable: true })
firstMileId?: string | null;
@Column({ name: 'last_mile_id', type: 'uuid', nullable: true })
lastMileId?: string | null;
/** Previous value for a transition (e.g. old status/availability). */
@Column({ name: 'from_value', type: 'varchar', nullable: true })
fromValue?: string | null;
/** New value for a transition (e.g. new status/availability). */
@Column({ name: 'to_value', type: 'varchar', nullable: true })
toValue?: string | null;
/** Human-readable summary token (driver name, plate, booking ref, mile). */
@Column({ name: 'label', type: 'varchar', nullable: true })
label?: string | null;
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: Record<string, unknown> | null;
}

View File

@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FleetEvent } from './entities/fleet-event.entity';
import { FleetHistoryService } from './fleet-history.service';
/**
* Global so any fleet-touching service (vehicles, drivers, first/last-mile) can
* inject FleetHistoryService to append audit events without each module having
* to import this one.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([FleetEvent])],
providers: [FleetHistoryService],
exports: [FleetHistoryService],
})
export class FleetHistoryModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FleetEvent, FleetEventType } from './entities/fleet-event.entity';
export interface FleetEventInput {
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
}
@Injectable()
export class FleetHistoryService {
private readonly logger = new Logger(FleetHistoryService.name);
constructor(
@InjectRepository(FleetEvent)
private readonly eventRepo: Repository<FleetEvent>,
) {}
/**
* Append an audit event. Best-effort: recording history must never break the
* business operation that triggered it, so failures are logged and swallowed.
*/
async record(input: FleetEventInput): Promise<void> {
try {
await this.eventRepo.save(this.eventRepo.create(input));
} catch (err) {
this.logger.error(
`Failed to record fleet event ${input.eventType}: ${String(err)}`,
);
}
}
getVehicleHistory(vehicleId: string): Promise<FleetEvent[]> {
return this.eventRepo.find({
where: { vehicleId },
order: { createdAt: 'DESC' },
});
}
getDriverHistory(driverId: string): Promise<FleetEvent[]> {
return this.eventRepo.find({
where: { driverId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -1,8 +0,0 @@
export class LastMileContainerAllocationDto {
containerId!: string;
vehicleId!: string;
}
export class AllocateLastMileContainersDto {
allocations!: LastMileContainerAllocationDto[];
}

View File

@@ -0,0 +1,24 @@
import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class VehicleDistanceInput {
@IsUUID()
vehicleId!: string;
@IsNumber()
@Min(0)
distanceKm!: number;
}
/** Per-vehicle actual distances for a last-mile delivery (multi-truck). */
export class SetDistancesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => VehicleDistanceInput)
distances!: VehicleDistanceInput[];
/** Recomputed remaining payment (total km × rate), from the client. */
@IsOptional()
@IsNumber()
remainingPayment?: number;
}

View File

@@ -0,0 +1,19 @@
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class LastMileVehicleInput {
@IsUUID()
vehicleId!: string;
@IsOptional()
@IsString()
containerNumber?: string;
}
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
export class SetVehiclesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LastMileVehicleInput)
vehicles!: LastMileVehicleInput[];
}

View File

@@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity {
@Column('uuid', { name: 'vehicle_id', nullable: true })
vehicleId?: string | null;
@Column('text')
@Column('text', { name: 'container_type' })
containerType!: string;
@Column('integer', { default: 1 })

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMile } from './last-mile.entity';
/**
* One row per vehicle assigned to a last-mile delivery. A delivery can be
* served by several vehicles at once (multi-truck bookings); the legacy
* `last_mile.vehicle_id` column keeps pointing at the first assignment for
* backward compatibility.
*/
@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' })
@Unique(['lastMileId', 'vehicleId'])
@Index(['vehicleId'])
export class LastMileVehicleAssignment extends BaseEntity {
@Column({ name: 'last_mile_id', type: 'uuid' })
lastMileId!: string;
@ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'last_mile_id' })
lastMile?: LastMile;
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
/** Container this truck carries — auto-filled from the booking's container
* number when known, else entered manually at assignment time. */
@Column({ name: 'container_number', type: 'varchar', nullable: true })
containerNumber?: string | null;
/** Actual distance driven by this truck (km), entered per vehicle. */
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
distanceKm?: number | null;
}

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
@@ -57,4 +58,7 @@ export class LastMile extends BaseEntity {
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
containerAllocations?: LastMileContainerAllocation[];
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
vehicleAssignments?: LastMileVehicleAssignment[];
}

View File

@@ -54,6 +54,15 @@ export class LastMileInvoiceService {
return null;
}
// numeric columns come back as strings — coerce before billing.
const totalAmount = Number(record.remainingPayment) || 0;
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
this.logger.warn(
`Skipping invoice for last-mile record ${record.id}: no remaining payment.`,
);
return null;
}
// Generate invoice with remainingPayment as totalAmount
const input: GenerateInvoiceInput = {
source: 'last_mile' as Freight.InvoiceSource,
@@ -61,17 +70,17 @@ export class LastMileInvoiceService {
type: 'DELIVERY_FEE',
companyId: lm.booking!.companyId,
companyProfileId: lm.booking!.companyProfileId || '',
currency: 'ETB',
currency: lm.booking!.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'DELIVERY',
description: 'Last-mile delivery',
quantity: 1,
unitRate: record.remainingPayment || 0,
amount: record.remainingPayment || 0,
unitRate: totalAmount,
amount: totalAmount,
},
],
totalAmount: record.remainingPayment || 0,
totalAmount,
};
return this.billing.generateInvoice(input);

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -17,13 +18,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { Freight } from '@edr/types';
import { BillingService } from '../billing/billing.service';
import { BookingsService } from '../bookings/bookings.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@@ -33,8 +32,6 @@ export class LastMileController {
constructor(
private readonly lastMileService: LastMileService,
private readonly lastMileInvoiceService: LastMileInvoiceService,
private readonly billingService: BillingService,
private readonly bookingsService: BookingsService
) {}
@Get()
@@ -83,39 +80,9 @@ export class LastMileController {
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a last-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
const record = await this.lastMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated
const booking = await this.bookingsService.findById(record.bookingId);
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
await this.billingService.generateInvoice({
source: Freight.InvoiceSource.LastMile,
sourceId: record.id,
type: "LAST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
lines: [
{
chargeType: "LAST_MILE",
description: "Last Mile Transportation Service",
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
},
],
subtotalAmount: record.remainingPayment,
taxAmount: 0, // Replace if VAT/tax applies
totalAmount: record.remainingPayment,
dueInDays: 7,
status: Freight.InvoiceStatus.Pending,
});
await this.lastMileInvoiceService.ensureInvoiceFor(record);
}
return record;
// No invoice side-effects here — invoices are generated only via the
// explicit POST :id/invoice endpoint (the "Generate Invoice" action).
return this.lastMileService.update(id, dto);
}
@Delete(':id')
@@ -126,13 +93,38 @@ export class LastMileController {
return this.lastMileService.remove(id);
}
@Post(':id/allocate-containers')
@Post(':id/vehicles')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AllocateLastMileContainersDto,
@Body() dto: SetVehiclesDto,
) {
return this.lastMileService.allocateContainers(id, dto.allocations);
return this.lastMileService.setVehicles(id, dto.vehicles);
}
@Post(':id/distances')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDistancesDto,
) {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/invoice')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.lastMileService.findById(id);
const invoice = await this.lastMileInvoiceService.ensureInvoiceFor(record);
if (!invoice) {
throw new BadRequestException(
'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a LAST_MILE rate is configured, and the booking has a company.',
);
}
return invoice;
}
}

View File

@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository';
@@ -15,7 +16,7 @@ import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,

View File

@@ -1,17 +1,21 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
type LastMileListFilter = {
status?: LastMileStatus;
@@ -41,8 +45,77 @@ export class LastMileService {
private readonly driversService: DriversService,
private readonly smsClient: SmsClientService,
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
private readonly billing: BillingService,
) {}
/** Attach real invoice info (number/status) to records so the UI can show an
* invoice link only when one actually exists — NOT merely because distance
* was entered. Batched to avoid N+1. */
private async attachInvoices(records: LastMile[]): Promise<void> {
const invoices = await this.billing.findBySourceIds(
'last_mile',
records.map((r) => r.id),
);
const byId = new Map<string, { id: string; number: string; status: string }>();
for (const inv of invoices) {
if (!byId.has(inv.sourceId)) {
byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) });
}
}
for (const r of records) {
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
}
}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
private async vehicleInfo(
vehicleId?: string | null,
): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> {
if (!vehicleId) return { driverId: null, plate: null, driverName: null };
try {
const v = await this.vehiclesService.findById(vehicleId);
return {
driverId: v.assignedDriverId ?? null,
plate: v.plateNumber ?? v.code ?? null,
driverName: v.assignedDriverName ?? null,
};
} catch {
return { driverId: null, plate: null, driverName: null };
}
}
/** A leg counts as having a vehicle if it has a direct assignment or at least
* one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */
private async hasAssignedVehicle(
recordId: string,
directVehicleId?: string | null,
): Promise<boolean> {
if (directVehicleId) return true;
const count = await this.dataSource.manager.count(LastMileContainerAllocation, {
where: { lastMileId: recordId, vehicleId: Not(IsNull()) },
});
return count > 0;
}
/** Human booking reference for a last-mile record, for the history timeline.
* Uses the already-loaded relation when present, else looks it up. */
private async resolveBookingRef(
record: LastMile,
): Promise<string | null> {
const loaded = (record as LastMile & { booking?: { reference?: string } })
.booking?.reference;
if (loaded) return loaded;
if (!record.bookingId) return null;
try {
const booking = await this.bookingsRepository.findById(record.bookingId);
return (booking as { reference?: string } | null)?.reference ?? null;
} catch {
return null;
}
}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
@@ -97,14 +170,17 @@ export class LastMileService {
const [data, total] = await this.lastMileRepository.findAndCount({
where,
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
vehicle: true,
vehicleAssignments: { vehicle: true },
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
});
await this.attachInvoices(data);
return {
data,
meta: {
@@ -119,8 +195,9 @@ export class LastMileService {
async findById(id: string): Promise<LastMile> {
const record = await this.lastMileRepository.findById(id, {
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
vehicle: true,
vehicleAssignments: { vehicle: true },
},
});
@@ -128,11 +205,13 @@ export class LastMileService {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
await this.attachInvoices([record]);
return record;
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
return this.lastMileRepository.create({
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
advancedPayment: dto.advancedPayment ?? 0,
@@ -142,16 +221,38 @@ export class LastMileService {
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
lastMileId: record.id,
driverId: info.driverId,
label: record.status,
metadata: {
mile: 'LAST',
bookingRef: await this.resolveBookingRef(record),
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
return record;
}
@OnEvent("lastmile.invoice.paid")
@OnEvent("last_mile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
await this.lastMileRepository.update(payload.sourceId, { paid: true } as any);
this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
// Invoice paid → the delivery is complete. Route through update() so it
// also frees the trucks + records history (same as "Mark Delivered").
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
} catch (err) {
this.logger.error(
`Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`,
`Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`,
);
}
}
@@ -159,6 +260,18 @@ export class LastMileService {
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
const existing = await this.findById(id);
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
// assigned in this same request).
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
const vehicleId =
dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId;
if (!(await this.hasAssignedVehicle(id, vehicleId))) {
throw new BadRequestException(
'Assign a vehicle before marking this last-mile leg in transit',
);
}
}
const dtoAny = dto as any;
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -180,9 +293,231 @@ export class LastMileService {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
const bookingRef = await this.resolveBookingRef(existing);
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
// Keep vehicle availability in sync: new vehicle goes BUSY, replaced one
// is freed if no other active trip still holds it.
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
if (existing.vehicleId) {
const info = await this.vehicleInfo(existing.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId: existing.vehicleId,
lastMileId: id,
driverId: info.driverId,
metadata: {
mile: 'LAST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
if (dto.vehicleId) {
const info = await this.vehicleInfo(dto.vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId: dto.vehicleId,
lastMileId: id,
driverId: info.driverId,
label: updated.status,
metadata: {
mile: 'LAST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
}
if (dto.status !== undefined && dto.status !== existing.status) {
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_STATUS_CHANGED,
lastMileId: id,
vehicleId,
driverId: info.driverId,
fromValue: existing.status,
toValue: dto.status,
metadata: {
mile: 'LAST',
bookingRef,
vehiclePlate: info.plate,
driverName: info.driverName,
},
});
}
// Delivery finished — free the vehicles this trip was holding.
if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') {
await this.releaseVehicles(updated);
}
return updated;
}
/**
* Free every vehicle held by this record — junction assignments, the legacy
* direct vehicle, and container allocations — unless still used by another
* active trip.
*/
private async releaseVehicles(record: LastMile): Promise<void> {
const [assignments, recordAllocations] = await Promise.all([
this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: record.id },
}),
this.dataSource.manager.find(LastMileContainerAllocation, {
where: { lastMileId: record.id },
}),
]);
const vehicleIds = [
...new Set(
[
...assignments.map((a) => a.vehicleId),
...recordAllocations.map((a) => a.vehicleId),
record.vehicleId ?? null,
].filter((id): id is string => Boolean(id)),
),
];
await this.vehiclesService.releaseIfUnused(vehicleIds);
}
/**
* Replace the full set of vehicles serving a last-mile delivery (multi-truck).
* Diffs against the current junction rows, syncing availability + audit history
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
* `vehicleId` column for back-compat with single-vehicle readers.
*/
async setVehicles(
id: string,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
): Promise<LastMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
for (const inp of inputs) {
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
}
const desired = [...desiredMap.keys()];
const desiredSet = new Set(desired);
const manager = this.dataSource.manager;
const current = await manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
});
const junctionSet = new Set(current.map((a) => a.vehicleId));
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
// old single-vehicle path has no junction row but must still be freed.
const releaseIds = [...new Set(
current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
)];
const added = desired.filter((v) => !junctionSet.has(v));
const removed = releaseIds.filter((v) => !desiredSet.has(v));
// Vehicles that stay but whose container number changed.
const changed = current.filter(
(a) =>
desiredMap.has(a.vehicleId) &&
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
);
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
await tx.delete(LastMileVehicleAssignment, {
lastMileId: id,
vehicleId: In(removed),
});
}
for (const vehicleId of added) {
await tx.insert(LastMileVehicleAssignment, {
lastMileId: id,
vehicleId,
containerNumber: desiredMap.get(vehicleId) ?? null,
});
}
for (const row of changed) {
await tx.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: row.vehicleId },
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
);
}
});
// Legacy primary vehicle = first of the set (null when cleared).
await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);
const bookingRef = await this.resolveBookingRef(existing);
for (const vehicleId of added) {
await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
void this.notifyDriverAssignment(vehicleId, existing);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
vehicleId,
lastMileId: id,
driverId: info.driverId,
label: existing.status,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
for (const vehicleId of removed) {
await this.vehiclesService.releaseIfUnused([vehicleId]);
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
lastMileId: id,
driverId: info.driverId,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
return this.findById(id);
}
/**
* Record each truck's actual distance. The delivery total (exact_km) is their
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
* client-side. Does NOT generate an invoice — that's a separate explicit step.
*/
async setDistances(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,
remainingPayment?: number,
): Promise<LastMile> {
await this.findById(id);
// Distances are locked once the invoice exists.
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Distances cannot be changed after the invoice is generated',
);
}
for (const d of distances) {
await this.dataSource.manager.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: d.vehicleId },
{ distanceKm: d.distanceKm },
);
}
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
await this.lastMileRepository.update(id, {
exactKm: total,
...(remainingPayment != null ? { remainingPayment } : {}),
} as any);
return this.findById(id);
}
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
@@ -223,38 +558,54 @@ export class LastMileService {
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.lastMileRepository.softDelete(id);
}
const existing = await this.findById(id);
async allocateContainers(
lastMileId: string,
allocations: Array<{ containerId: string; vehicleId: string }>,
) {
const lastMile = await this.findById(lastMileId);
if (!lastMile) {
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
// Can't delete once billed.
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Cannot delete a last-mile delivery after its invoice is generated',
);
}
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
});
await manager.insert(LastMileContainerAllocation, {
lastMileId,
containerId: allocation.containerId,
vehicleId: allocation.vehicleId,
containerType: 'CONTAINER',
quantity: 1,
// Every vehicle this delivery holds — junction + legacy + container rows.
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
});
const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, {
where: { lastMileId: id },
});
const vehicleIds = [
...new Set(
[
...assignments.map((a) => a.vehicleId),
...allocations.map((a) => a.vehicleId),
existing.vehicleId ?? null,
].filter((v): v is string => Boolean(v)),
),
];
await this.lastMileRepository.softDelete(id);
if (assignments.length) {
await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id });
}
// Free every vehicle no longer held by another active trip (releaseIfUnused
// ignores this now soft-deleted record) and audit the release.
if (vehicleIds.length) {
await this.vehiclesService.releaseIfUnused(vehicleIds);
const bookingRef = await this.resolveBookingRef(existing);
for (const vehicleId of vehicleIds) {
const info = await this.vehicleInfo(vehicleId);
await this.history.record({
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
vehicleId,
lastMileId: id,
driverId: info.driverId,
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
});
}
});
return {
success: true,
allocated: allocations.length,
};
}
}
}

View File

@@ -8,6 +8,11 @@ import { EmailClientService } from "./email-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
// Fall back to a sane broker URL so an unset RABBITMQ_URL can't produce
// `urls: [undefined]` (which crashes amqp-connection-manager on 'heartbeat').
const RABBITMQ_URL =
process.env.RABBITMQ_URL ?? process.env.PAYMENT_RABBITMQ_URL ?? "amqp://localhost:5672";
@Module({
imports: [
ConfigModule,
@@ -16,7 +21,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
name: "SMS_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
urls: [RABBITMQ_URL],
queue: process.env.SMS_QUEUE ?? "sms_queue",
queueOptions: { durable: true },
},
@@ -25,7 +30,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
name: "EMAIL_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
urls: [RABBITMQ_URL],
queue: process.env.EMAIL_QUEUE ?? "email_queue",
queueOptions: { durable: true },
},

View File

@@ -21,6 +21,14 @@ export class CreateCargoTypeDto {
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({
description:
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -30,6 +30,14 @@ export class CreateContainerTypeDto {
@IsBoolean()
isOpenTop?: boolean;
@ApiPropertyOptional({
description:
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import {
RATE_APPLIES_TO,
RATE_TRIGGERS,
@@ -51,15 +51,6 @@ export class CreateRateDto {
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
@IsIn([...RATE_UNITS])
rateUnit!: string;
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
@IsDateString()
effectiveFrom!: string;
@ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' })
@IsOptional()
@IsDateString()
effectiveTo?: string;
}
export class SubmitRateForApprovalDto {

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
@@ -22,12 +22,14 @@ export class CreateWeightLimitRuleDto {
@Transform(({ value }) => Number(value))
maxVgmTons!: number;
@ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' })
@IsDateString()
effectiveFrom!: string;
@ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' })
@ApiPropertyOptional({
description:
'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.',
minimum: 0,
})
@IsOptional()
@IsDateString()
effectiveTo?: string;
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value)))
maxCapacityTons?: number | null;
}

View File

@@ -1,11 +1,13 @@
import { BaseEntity } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'cargo_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['parentGroupId'])
@Index(['wagonTypeId'])
@Index(['code'])
export class CargoType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
@@ -25,6 +27,19 @@ export class CargoType extends BaseEntity {
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
unitOfMeasure?: CargoUnitOfMeasure | null;
/**
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
* through this FK. Nullable — grouping rows and container/legacy cargo never
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -1,10 +1,12 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['code'])
@Index(['isActive'])
@Index(['wagonTypeId'])
export class ContainerType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity {
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
isOpenTop!: boolean;
/**
* Wagon type that carries this container. Replaces the former hardcoded
* container wagon-code default (NW5): train scheduling resolves the container
* wagon type through this FK. Nullable; scheduling throws if a scheduled
* container type leaves it unset.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -0,0 +1,71 @@
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
/**
* Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per
* container, bulk freight per ton, an intercity move can be per-km, a
* cancellation is a flat/per-invoice fee, and overweight is always per excess
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply.
*
* Returned lists are ordered with the most natural/default unit first.
*/
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
}): RateUnit[] {
const { appliesTo, trigger } = input;
// Surcharges (Applies to = Other) are governed by their trigger.
if (appliesTo === 'OTHER') {
switch (trigger) {
case 'OVERWEIGHT':
// Overweight always bills the excess tonnage — per ton, nothing else.
return ['PER_TON'];
case 'REEFER':
case 'HAZARDOUS':
// Scale with the freight shape: per container for boxes, per ton for bulk.
return ['PER_CONTAINER', 'PER_TON'];
case 'DEMURRAGE':
return ['PER_CONTAINER', 'PER_TON'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':
case 'PIL_EXTRA_FEE':
return ['PER_CONTAINER', 'FLAT'];
default:
return ['FLAT', 'PER_TON', 'PER_CONTAINER'];
}
}
// Base freight + first/last mile scale with the cargo type.
switch (appliesTo) {
case 'CONTAINER':
return ['PER_CONTAINER', 'PER_WAGON'];
case 'BULK':
return ['PER_TON', 'PER_WAGON'];
case 'INTERCITY':
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
case 'FIRST_MILE':
case 'LAST_MILE':
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
default:
return ['FLAT'];
}
}
/** The default (first / most natural) unit for a rate shape. */
export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit {
return allowedRateUnits(input)[0];
}
/** True when `unit` is a valid weighting basis for the given rate shape. */
export function isRateUnitAllowed(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
unit: RateUnit;
}): boolean {
return allowedRateUnits(input).includes(input.unit);
}

View File

@@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@Index(['effectiveFrom'])
@Index(['containerTypeId'])
@Index(['trigger'])
export class Rate extends BaseEntity {
@@ -131,10 +130,4 @@ export class Rate extends BaseEntity {
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt?: Date | null;
@Column({ name: 'effective_from', type: 'date' })
effectiveFrom!: Date;
@Column({ name: 'effective_to', type: 'date', nullable: true })
effectiveTo?: Date | null;
}

View File

@@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity';
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
@Index(['containerTypeId'])
@Index(['tradeDirection'])
@Index(['effectiveFrom'])
export class WeightLimitRule extends BaseEntity {
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@@ -20,9 +19,11 @@ export class WeightLimitRule extends BaseEntity {
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxVgmTons!: number;
@Column({ name: 'effective_from', type: 'date', nullable: true })
effectiveFrom!: Date;
@Column({ name: 'effective_to', type: 'date', nullable: true })
effectiveTo?: Date | null;
/**
* Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or
* below this is "overweight" (surcharge + warning); weight above this hard-
* blocks booking creation entirely. Null = no ceiling (overweight only).
*/
@Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxCapacityTons!: number | null;
}

View File

@@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
findById(id: string): Promise<Rate | null>;
findLiveRates(): Promise<Rate[]>;
findByPattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
}): Promise<Rate | null>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
create(data: Partial<Rate>): Promise<Rate>;

View File

@@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]>;
findByPattern(
containerTypeId: string,
tradeDirection: string,
excludeId?: string,
): Promise<WeightLimitRule | null>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;

View File

@@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
}
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
}
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository {
}
findLiveRates(): Promise<Rate[]> {
const now = new Date();
return this.repo
.createQueryBuilder('rate')
.where('rate.status = :status', { status: 'LIVE' })
.andWhere('rate.effective_from <= :now', { now })
.andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now })
.getMany();
}
/**
* Find a non-superseded rate matching an identity pattern — the same tuple the
* `UQ_rates_pattern` unique index enforces. Used to reject duplicates before
* insert so the admin gets a friendly error instead of a raw constraint fault.
* NULL scope columns are matched with IS NULL, mirroring the COALESCE index.
*/
findByPattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
}): Promise<Rate | null> {
const qb = this.repo
.createQueryBuilder('rate')
.where('rate.rate_type = :rateType', { rateType: pattern.rateType })
.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit })
.andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' });
if (pattern.containerTypeId) {
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
} else {
qb.andWhere('rate.container_type_id IS NULL');
}
if (pattern.cargoTypeId) {
qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId });
} else {
qb.andWhere('rate.cargo_type_id IS NULL');
}
if (pattern.tradeDirection) {
qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection });
} else {
qb.andWhere('rate.trade_direction IS NULL');
}
return qb.getOne();
}
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]> {
return this.repo.find(options);
}
@@ -39,7 +74,7 @@ export class RatesRepository implements IRatesRepository {
}
async update(id: string, data: Partial<Rate>): Promise<Rate | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]> {
const now = new Date();
return this.repo
.createQueryBuilder('rule')
.innerJoinAndSelect('rule.containerType', 'ct')
@@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
dir: tradeDirection,
both: 'BOTH',
})
.andWhere('rule.effective_from <= :now', { now })
.andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now })
.getMany();
}
/**
* Find a rule matching the (containerType, tradeDirection) identity — the
* tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates
* before insert. Optionally excludes a row by id so updates don't self-collide.
*/
findByPattern(
containerTypeId: string,
tradeDirection: string,
excludeId?: string,
): Promise<WeightLimitRule | null> {
const qb = this.repo
.createQueryBuilder('rule')
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
.andWhere('rule.trade_direction = :tradeDirection', { tradeDirection });
if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId });
return qb.getOne();
}
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]> {
return this.repo.find(options);
}
@@ -50,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
}
async update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -136,6 +136,10 @@ export class RuleEngineService {
}
}
hardBlocked.push(
...(await this.capacityViolations(input.containers, input.tradeDirection)),
);
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
@@ -296,6 +300,40 @@ export class RuleEngineService {
};
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
* must not be created at all. Overweight (above maxVgmTons but within
* capacity) is NOT reported here — that is a surcharge, not a block.
*/
async capacityViolations(
containers: Array<{
containerTypeId: string;
quantity: number;
totalVgmTons: number;
}>,
tradeDirection: string,
): Promise<string[]> {
const violations: string[] = [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
tradeDirection,
);
const rule = rules[0];
if (!rule || rule.maxCapacityTons == null) continue;
const perUnit = Number(rule.maxCapacityTons);
const maxTotal = perUnit * container.quantity;
if (container.totalVgmTons > maxTotal) {
const label = rule.containerType?.code ?? container.containerTypeId;
violations.push(
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
);
}
}
return violations;
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/

View File

@@ -82,6 +82,7 @@ export class CargoTypesService {
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}

View File

@@ -64,6 +64,7 @@ export class ContainerTypesService {
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}

View File

@@ -1,8 +1,15 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
@Injectable()
@@ -27,7 +34,7 @@ export class RatesService {
const [data, total] = await this.repository.findAndCount({
where,
order: { effectiveFrom: 'DESC' },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -46,6 +53,50 @@ export class RatesService {
return entity;
}
/**
* Normalise + validate the weighting unit for a rate shape. Overweight is
* always billed per excess ton, so its unit is forced to PER_TON regardless
* of what the client sent. Every other shape must pick a unit the pricing
* engine can actually apply (see `allowedRateUnits`).
*/
private resolveRateUnit(
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'],
): Rate['rateUnit'] {
// Overweight is per-ton, full stop.
if (trigger === 'OVERWEIGHT') return 'PER_TON';
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
);
}
return requestedUnit;
}
/**
* Reject a second rate with the same identity pattern (rateType + scope). With
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
* make pricing ambiguous — so we allow exactly one per pattern.
*/
private async assertNoDuplicatePattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
ignoreId?: string;
}): Promise<void> {
const existing = await this.repository.findByPattern(pattern);
if (existing && existing.id !== pattern.ignoreId) {
throw new ConflictException(
'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.',
);
}
}
/** Create a rate in DRAFT status. */
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
const appliesTo = dto.appliesTo as Rate['appliesTo'];
@@ -57,25 +108,28 @@ export class RatesService {
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
const rateType = deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
});
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
return this.repository.create({
appliesTo,
trigger,
rateType: deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
}),
rateType,
containerTypeId,
cargoTypeId,
tradeDirection,
currency: dto.currency ?? 'USD',
rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'],
rateUnit,
status: 'DRAFT',
proposedByStaffId,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
});
}
@@ -110,22 +164,35 @@ export class RatesService {
? dto.tradeDirection
: existing.tradeDirection;
updates.containerTypeId = containerTypeId;
updates.cargoTypeId = cargoTypeId;
updates.tradeDirection = tradeDirection;
updates.containerTypeId = containerTypeId ?? null;
updates.cargoTypeId = cargoTypeId ?? null;
updates.tradeDirection = tradeDirection ?? null;
// Keep the derived rateType in sync with whatever changed.
updates.rateType = deriveRateType({
const rateType = deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
});
updates.rateType = rateType;
// Re-validate the unit against the (possibly changed) shape; overweight is
// forced to PER_TON.
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({
rateType,
rateUnit: updates.rateUnit,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,
ignoreId: id,
});
updates.currency = dto.currency ?? existing.currency ?? 'USD';
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
const updated = await this.repository.update(id, updates);
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
return updated;

View File

@@ -1,4 +1,10 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
@@ -30,7 +36,7 @@ export class WeightLimitRulesService {
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true },
order: { effectiveFrom: 'DESC' },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -44,26 +50,75 @@ export class WeightLimitRulesService {
return entity;
}
/**
* Reject a second rule for the same container + direction. One VGM limit per
* (container, direction) — otherwise the booking engine can't tell which
* applies.
*/
private async assertNoDuplicate(
containerTypeId: string,
tradeDirection: string,
ignoreId?: string,
): Promise<void> {
const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId);
if (existing) {
throw new ConflictException(
'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.',
);
}
}
/**
* Capacity is the hard ceiling; the VGM limit is the soft overweight
* threshold. A ceiling below the threshold would make every overweight
* booking impossible to create, which is never what the operator means.
*/
private assertCapacityAboveVgmLimit(
maxVgmTons: number,
maxCapacityTons: number | null | undefined,
): void {
if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) {
throw new BadRequestException(
'Max capacity must be greater than or equal to the max VGM limit.',
);
}
}
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons);
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxVgmTons: dto.maxVgmTons,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null,
maxCapacityTons: dto.maxCapacityTons ?? null,
});
}
/** Update an existing weight limit rule. */
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.findById(id);
const existing = await this.findById(id);
const patch: Partial<WeightLimitRule> = {};
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo);
if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons;
this.assertCapacityAboveVgmLimit(
patch.maxVgmTons ?? Number(existing.maxVgmTons),
patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons,
);
// Re-check uniqueness when the identity (container/direction) changes.
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {
await this.assertNoDuplicate(
patch.containerTypeId ?? existing.containerTypeId,
patch.tradeDirection ?? existing.tradeDirection,
id,
);
}
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
return updated;

View File

@@ -1,9 +1,15 @@
import { BaseEntity } from '@edr/api-common';
import { LoadingStatus } from '@edr/types';
import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from './train-schedule.entity';
export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [
LoadingStatus.Unloaded,
LoadingStatus.Loaded,
] as const;
@Entity({ schema: 'freight', name: 'train_schedule_bookings' })
@Index(['trainScheduleId', 'bookingId'], { unique: true })
@Index(['bookingId'], { unique: true })
@@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity {
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' })
loadingStatus!: string;
}

View File

@@ -111,6 +111,27 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'booking_cycle_no', type: 'int', default: 0 })
bookingCycleNo!: number;
// ── Booking-window rule snapshot ──────────────────────────────────────────
// The scheduling rule this train was created with, frozen at creation. A later
// global-rules edit applies only to FUTURE schedules — an already-open schedule
// keeps its base rule. The batch board derives its display windows (open time +
// reopen cycles) from THIS snapshot, never from the live global config. NULL on
// legacy rows created before the snapshot existed (board falls back to live cfg).
@Column({ name: 'rule_window_open_hour', type: 'int', nullable: true })
ruleWindowOpenHour?: number | null;
@Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true })
ruleWindowDurationHours?: number | null;
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
ruleReopenDelayMinutes?: number | null;
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
ruleImportWindowLeadDays?: number | null;
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
ruleExportBookingLeadHours?: number | null;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
select: { id: true, bookingId: true, trainScheduleId: true },
});
}
findByScheduleId(
trainScheduleId: string,
manager?: EntityManager,
): Promise<TrainScheduleBooking[]> {
return this.repo(manager).find({
where: { trainScheduleId },
select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true },
});
}
async updateLoadingStatusMany(
trainScheduleId: string,
bookingIds: string[],
loadingStatus: string,
manager?: EntityManager,
): Promise<void> {
if (!bookingIds.length) return;
await this.repo(manager).update(
{ trainScheduleId, bookingId: In(bookingIds) },
{ loadingStatus },
);
}
}

View File

@@ -3,9 +3,9 @@ import {
listBatchWindowsForDate,
listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS,
boardWindowForTimestamp,
listBoardWindowsForRange,
listConfigBookingWindows,
groupBookingsIntoBoardWindows,
type BoardWindowConfig,
} from './batch-window.util';
describe('batch-window.util', () => {
@@ -54,83 +54,87 @@ describe('batch-window.util', () => {
});
});
describe('batch-window board windows (midnight-based 3h slots)', () => {
it('maps 04:00 EAT to the 03:0006:00 slot', () => {
// 01:00 UTC = 04:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
expect(w.label).toContain('03:00');
expect(w.label).toContain('06:00');
expect(w.date).toBe('2026-06-11');
expect(w.dateLabel).toContain('11 Jun');
});
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowDurationHours: 3,
reopenDelayMinutes: 90,
exportBookingLeadHours: 24,
};
it('maps 00:30 EAT to the 00:0003:00 slot of that EAT day', () => {
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
expect(w.label).toContain('00:00');
expect(w.label).toContain('03:00');
expect(w.date).toBe('2026-06-11');
});
it('maps 23:00 EAT to the final 21:0024:00 slot', () => {
// 20:00 UTC = 23:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
expect(w.label).toContain('21:00');
expect(w.label).toContain('24:00');
expect(w.date).toBe('2026-06-11');
});
it('lists a continuous range open→departure clamped at both ends', () => {
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
const open = new Date('2026-06-05T05:00:00.000Z');
it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => {
// departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC)
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listBoardWindowsForRange(open, departure);
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
expect(windows).toHaveLength(6 + 8 + 8 + 5);
expect(windows[0].date).toBe('2026-06-05');
expect(windows[0].label).toContain('06:00');
expect(windows[0].label).toContain('09:00');
const last = windows[windows.length - 1];
expect(last.date).toBe('2026-06-08');
expect(last.label).toContain('12:00');
expect(last.label).toContain('15:00');
// chronological + unique keys
const keys = windows.map((w) => w.key);
expect(new Set(keys).size).toBe(keys.length);
expect(windows[0].label).toContain('08:00');
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
// end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC)
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
it('handles a same-day open→departure range', () => {
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (0609 slot)
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (1215 slot)
const windows = listBoardWindowsForRange(open, departure);
// 06,09,12 = 3 slots
expect(windows).toHaveLength(3);
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT
expect(windows.length).toBeGreaterThanOrEqual(2);
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
// all cycles stay on the same EAT booking day
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
});
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
const open = new Date('2026-06-05T05:00:00.000Z');
const departure = new Date('2026-06-06T11:00:00.000Z');
it('export: single FCFS window exportBookingLeadHours before departure', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
expect(windows).toHaveLength(1);
// 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun
expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z');
expect(windows[0].end.toISOString()).toBe(departure.toISOString());
});
it('buckets bookings into config cycles and keeps empty + pending windows', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 0609 on 5th
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1
{ id: 'b', ts: null }, // pending
];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
open,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const pending = map.get('pending-contract');
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
expect(withA?.window?.date).toBe('2026-06-05');
// empty slots are retained for the UI
// empty cycles are retained for the UI
const emptyCount = [...map.values()].filter(
(b) => b.window && b.items.length === 0,
).length;
expect(emptyCount).toBeGreaterThan(0);
});
it('attaches a booking made before the window opened to the first cycle', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const withEarly = [...map.values()].find((b) =>
b.items.some((i) => i.id === 'early'),
);
expect(withEarly?.window?.date).toBe('2026-06-05');
expect(withEarly?.window?.label).toContain('08:00');
});
});

View File

@@ -181,6 +181,26 @@ export function computeExportWindowTimes(
};
}
/**
* Earliest departure a train may be scheduled for — staff cannot schedule inside
* the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and
* today the 11th, the 12th and 13th are blocked and the 14th is the first
* allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure
* is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window
* math so a schedulable date always has a real booking window before it.
*/
export function earliestSchedulableDeparture(
direction: string | null | undefined,
cfg: { importWindowLeadDays: number; exportBookingLeadHours: number },
now: Date,
): Date {
if (direction === 'EXPORT') {
return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000);
}
const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays);
return eatDayToUtc(earliestDay, 0);
}
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
const { year, month, day, hour } = eatParts(date);
@@ -230,14 +250,13 @@ export function listBatchWindowsForBookings(
}
// ---------------------------------------------------------------------------
// Board-display windows: full-day, midnight-based 3h slots over a date range.
// These are used ONLY for the batch-board UI grouping (not persisted, and
// independent of the cron intake hours above).
// Board-display windows: the REAL booking-window cycles derived from the
// train_scheduling_global_rules config (window open hour, lead days, duration,
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
// ---------------------------------------------------------------------------
/** Midnight-based 3-hour slot starts (0003, 0306, … 2124). */
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
/** A board window carries an EAT calendar date in addition to the slot times. */
export interface BoardWindow extends BatchWindow {
/** EAT calendar day as ISO `YYYY-MM-DD`. */
@@ -246,6 +265,15 @@ export interface BoardWindow extends BatchWindow {
dateLabel: string;
}
/** Config fields the board needs to reconstruct booking-window cycles. */
export interface BoardWindowConfig {
importWindowLeadDays: number;
windowOpenHour: number;
windowDurationHours: number;
reopenDelayMinutes: number;
exportBookingLeadHours: number;
}
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -257,119 +285,133 @@ function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
function boardWindowFromEatStart(
year: number,
month: number,
day: number,
startHour: number,
): BoardWindow {
const start = eatToUtc(year, month, day, startHour);
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
const end = eatToUtc(year, month, day, endHour);
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
const { year, month, day } = eatParts(start);
return {
key: start.toISOString(),
start,
end,
label: formatWindowLabel(start, end, endLabel),
label: formatWindowLabel(start, end),
date: `${year}-${pad2(month)}-${pad2(day)}`,
dateLabel: dayLabelFmt.format(start),
};
}
/** Which midnight-based 3h EAT slot a timestamp falls in. */
export function boardWindowForTimestamp(date: Date): BoardWindow {
const { year, month, day, hour } = eatParts(date);
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
for (const h of BOARD_WINDOW_HOURS) {
if (hour >= h) startHour = h;
}
return boardWindowFromEatStart(year, month, day, startHour);
}
/**
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
* clamped to the slot containing `openDate` on the first day and the slot
* containing `departureDate` on the last day. Returned in chronological order.
* The real booking-window cycles for a schedule, straight from config.
*
* IMPORT: first window opens at `windowOpenHour` EAT on `departure importWindowLeadDays`
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
*
* `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
* shows the real frozen window (and reopen cycles projected from it) even after
* the global rule changed — the recomputed open time would otherwise drift.
*/
export function listBoardWindowsForRange(
openDate: Date,
departureDate: Date,
export function listConfigBookingWindows(
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
anchorOpensAt?: Date | null,
): BoardWindow[] {
const startWin = boardWindowForTimestamp(openDate);
const endWin = boardWindowForTimestamp(departureDate);
// Guard against an inverted range (departure before open).
if (endWin.start.getTime() < startWin.start.getTime()) {
return [startWin];
if (direction === 'EXPORT') {
const start =
anchorOpensAt ??
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)];
}
const windows: BoardWindow[] = [];
const seen = new Set<string>();
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
let cursor = new Date(eatToUtc(
Number(startWin.date.slice(0, 4)),
Number(startWin.date.slice(5, 7)),
Number(startWin.date.slice(8, 10)),
12,
));
const lastDayMs = eatToUtc(
Number(endWin.date.slice(0, 4)),
Number(endWin.date.slice(5, 7)),
Number(endWin.date.slice(8, 10)),
12,
).getTime();
const durationMs = cfg.windowDurationHours * 3_600_000;
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
while (cursor.getTime() <= lastDayMs) {
const { year, month, day } = eatParts(cursor);
for (const h of BOARD_WINDOW_HOURS) {
const w = boardWindowFromEatStart(year, month, day, h);
if (
w.start.getTime() >= startWin.start.getTime() &&
w.start.getTime() <= endWin.start.getTime() &&
!seen.has(w.key)
) {
seen.add(w.key);
windows.push(w);
}
let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
if (
nextOpensAt.getTime() >= departure.getTime() ||
eatDay(nextOpensAt) !== eatDay(opensAt)
) {
break;
}
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
opensAt = nextOpensAt;
}
windows.sort(compareBatchWindows);
// Degenerate config (no window before departure) — surface a single window
// clamped to departure so the board still renders something meaningful.
if (windows.length === 0) {
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
}
return windows;
}
/** Which config booking-window a timestamp falls in; null if before/after all of them. */
function configWindowForTimestamp(
windows: BoardWindow[],
date: Date,
): BoardWindow | null {
const ms = date.getTime();
for (const w of windows) {
if (ms >= w.start.getTime() && ms < w.end.getTime()) return w;
}
return null;
}
/**
* Group items into board windows spanning [openDate, departureDate]. Empty
* windows are kept so the UI shows every slot. Items whose timestamp falls
* outside the range still get their own window (nothing hidden). Items without
* a timestamp go to `pendingKey`.
* Group items into the real config booking-window cycles for a schedule. Empty
* windows are kept so the UI shows every cycle. Items whose timestamp falls
* outside every window (e.g. a booking created before the window opened) are
* attached to the nearest window by start time so nothing is hidden. Items
* without a timestamp go to `pendingKey`.
*/
export function groupBookingsIntoBoardWindows<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
openDate: Date,
departureDate: Date,
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
pendingKey = 'pending-contract',
anchorOpensAt?: Date | null,
): Map<string, { window: BoardWindow | null; items: T[] }> {
const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt);
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
for (const w of windows) {
map.set(w.key, { window: w, items: [] });
}
map.set(pendingKey, { window: null, items: [] });
const firstWindow = windows[0] ?? null;
const lastWindow = windows[windows.length - 1] ?? null;
for (const item of items) {
const ts = getTimestamp(item);
if (!ts) {
map.get(pendingKey)!.items.push(item);
continue;
}
const w = boardWindowForTimestamp(ts);
if (!map.has(w.key)) {
map.set(w.key, { window: w, items: [] });
let w = configWindowForTimestamp(windows, ts);
if (!w) {
// Booked before the window opened → first cycle; after it closed → last cycle.
w =
firstWindow && ts.getTime() < firstWindow.start.getTime()
? firstWindow
: lastWindow;
}
if (!w) {
map.get(pendingKey)!.items.push(item);
continue;
}
map.get(w.key)!.items.push(item);
}

View File

@@ -590,6 +590,9 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
if (s.direction !== "IMPORT") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -630,6 +633,12 @@ export class BookingBatchService implements OnModuleInit {
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") {
throw new BadRequestException(
"The batch board only covers import schedules",
);
}
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
@@ -710,15 +719,43 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null;
// Display windows span the whole booking window: from when it opened
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
// Display windows are the REAL booking-window cycles this schedule was FROZEN
// with at creation (import: opens at its stored window time, lasts its rule's
// duration, reopens per its rule's delay; export: single FCFS lead window) —
// NOT the live global config. A later global-rules edit only re-derives
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
// must keep drawing from its own snapshot, anchored on its stored open time.
// Legacy rows with no snapshot fall back to the live config.
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const num = (v: unknown, fallback: number) => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
const windowCfg = {
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
windowDurationHours: num(
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
),
exportBookingLeadHours: num(
s.ruleExportBookingLeadHours,
liveCfg.exportBookingLeadHours,
),
};
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
openDate,
s.direction ?? null,
departureDate,
windowCfg,
undefined,
s.windowOpensAt ?? null,
);
const emptyCounts = () => ({

View File

@@ -7,6 +7,7 @@ import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { BookingBatchService } from './booking-batch.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@@ -19,12 +20,13 @@ import { type BookingWindowConfig } from './booking-window.config';
* schedule row, so every transition is derived purely from the clock — a restart
* resumes mid-phase with no loss (onModuleInit runs one tick immediately).
*
* Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept
* documents) → PAYMENT (batch reserves in priority order, customers pay) →
* reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
* Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW
* (staff accept documents) → PAYMENT (batch reserves in priority order, customers
* pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
* Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority).
* Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy
* fill (runBatchFill), which this tick invokes every 5th minute.
* Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy
* fill (runBatchFill), which this tick invokes every 5th minute. New schedules of
* every direction get a window phase.
*/
@Injectable()
export class BookingWindowService implements OnModuleInit {
@@ -37,6 +39,7 @@ export class BookingWindowService implements OnModuleInit {
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService,
) {}
async onModuleInit(): Promise<void> {
@@ -156,6 +159,7 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
await this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
@@ -193,6 +197,8 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
// Only announce the first opening of the day; reopen cycles don't re-notify.
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
);
@@ -325,6 +331,67 @@ export class BookingWindowService implements OnModuleInit {
}
}
/**
* SMS + email every active-contract customer on this schedule's route when its
* booking window opens, so they can book from the portal home before it closes.
* Fire-and-forget; a failed notification never blocks the window transition.
*/
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
try {
const rows: Array<{ phone: string | null; email: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT
COALESCE(co.contact_person_phone, co.phone) AS phone,
COALESCE(co.email, co.general_manager_email) AS email
FROM freight.contract_routes cr
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
JOIN freight.companies co ON co.id = c.company_id
WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return;
const closes = schedule.windowClosesAt
? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE })
: 'later today';
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
timeZone: BATCH_TIMEZONE,
});
const msg =
`Booking is now open for the train departing ${depart}. ` +
`Book your shipment from the portal home page before ${closes} EAT.`;
const seenPhone = new Set<string>();
const seenEmail = new Set<string>();
for (const r of rows) {
if (r.phone && !seenPhone.has(r.phone)) {
seenPhone.add(r.phone);
await this.notifications
.directSend('sms', r.phone, msg)
.catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`));
}
if (r.email && !seenEmail.has(r.email)) {
seenEmail.add(r.email);
await this.notifications
.directSend('email', r.email, msg)
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
}
}
this.logger.log(
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
);
} catch (err) {
this.logger.warn(
`notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`,
);
}
}
private async setPhase(
schedule: TrainSchedule,
patch: Partial<

View File

@@ -0,0 +1,15 @@
import { LoadingStatus } from '@edr/types';
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator';
export class UpdateImportLoadingStatusDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiProperty({ enum: LoadingStatus })
@IsEnum(LoadingStatus)
loadingStatus!: LoadingStatus;
}

View File

@@ -60,11 +60,13 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@Max(23)
windowOpenHour?: number;
// Stored in hours. The UI enters this in minutes/hours/days and converts to
// hours before sending, so the floor is 1 minute (0.0166h) — not 15 min.
@ApiPropertyOptional({ example: 3 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.25)
@Min(0.0166)
@Max(12)
windowDurationHours?: number;

View File

@@ -54,11 +54,13 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
windowOpenHour!: number;
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
// are exact. See WidenWindowDurationHoursPrecision migration.
@Column({
name: 'window_duration_hours',
type: 'numeric',
precision: 4,
scale: 2,
precision: 6,
scale: 4,
default: 3,
})
windowDurationHours!: number;

View File

@@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "./dto/pin-wagons.dto";
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
@@ -60,16 +61,38 @@ export class TrainSchedulingController {
@Get("my-booking-windows")
@ApiOperation({
summary:
"Upcoming/open booking windows on the signed-in customer's active contract lanes",
"Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)",
})
async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) {
// Every customer sees announced windows; companyId (when resolvable) just
// enriches lanes they hold a contract on so "Book now" can target it.
const companyId = await this.billingService.resolveCompanyId(
resolveAuthUserId(user),
);
if (!companyId) return [];
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
}
@Get("contracts/:contractId/booking-windows")
@ApiOperation({
summary:
"Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL",
})
getContractBookingWindows(
@Param("contractId", ParseUUIDPipe) contractId: string,
) {
return this.trainSchedulingService.getBookingWindowsForContract(contractId);
}
@Get("booking-windows")
@TrainSchedulingView()
@ApiOperation({
summary:
"All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards",
})
listBookingWindows() {
return this.trainSchedulingService.listAllBookingWindows();
}
@Get("global-rules")
@TrainSchedulingView()
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
@@ -325,6 +348,28 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getCompositionRemovals(id);
}
@Get("schedules/:id/import-loading-bookings")
@TrainSchedulingView()
@ApiOperation({
summary: "List import bookings eligible for loading confirmation on this schedule",
})
getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getImportLoadingBookings(id);
}
@Patch("schedules/:id/import-loading-status")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)",
})
updateImportLoadingStatus(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateImportLoadingStatusDto,
) {
return this.trainSchedulingService.updateImportLoadingStatus(id, dto);
}
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@ApiOperation({ summary: "Pin physical wagons to train set slots" })

View File

@@ -1,5 +1,6 @@
import {
AllocationLoadType,
LoadingStatus,
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
@@ -9,11 +10,12 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Not } from 'typeorm';
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
@@ -36,6 +38,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
@@ -45,6 +49,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
@@ -84,10 +89,6 @@ import {
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
import {
getDefaultContainerWagonTypeCode,
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
@@ -102,6 +103,7 @@ import {
import {
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
@@ -168,8 +170,30 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
max20ftPairWeightDiffTons: 10,
};
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
schedule_id: string;
contract_id: string | null;
contract_kind: string | null;
direction: string | null;
window_phase: string | null;
window_opens_at: Date | null;
window_closes_at: Date | null;
doc_review_ends_at: Date | null;
payment_phase_ends_at: Date | null;
booking_window_status: string;
booking_cycle_no: number;
scheduled_departure_date: Date;
origin_label: string | null;
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
}
@Injectable()
export class TrainSchedulingService {
private readonly logger = new Logger(TrainSchedulingService.name);
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
@@ -248,7 +272,76 @@ export class TrainSchedulingService {
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
// Fields that change the STAMPED open/close times of a schedule. docReview/
// payment/reopen are read live by the cron each tick, so they need no
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
const windowTimingChanged =
dto.importWindowLeadDays != null ||
dto.windowOpenHour != null ||
dto.windowDurationHours != null ||
dto.exportBookingLeadHours != null;
const saved = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.save(row);
// The cron reads config fresh every tick, so derived timings (doc review,
// payment, reopen) take effect on the next tick with no restart. But each
// schedule's initial open/close times were FROZEN at creation — re-stamp the
// ones whose window has not opened yet so a config edit applies to them too.
if (windowTimingChanged) {
await this.restampPendingWindows();
}
return saved;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
* past their window are left untouched — customers may have booked against the
* times they were shown, so those stay frozen. Returns the count re-stamped.
*/
async restampPendingWindows(): Promise<number> {
const cfg = await this.getWindowConfig();
const now = new Date();
const schedules = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
],
});
const repo = this.dataSource.getRepository(TrainSchedule);
let restamped = 0;
for (const s of schedules) {
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
const times =
s.direction === 'EXPORT'
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
// A not-yet-open schedule legitimately adopts the new rule, so refresh its
// snapshot alongside the re-stamped times — the board then draws the new
// window from this same rule.
await repo.update(s.id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
ruleWindowOpenHour: cfg.windowOpenHour,
ruleWindowDurationHours: cfg.windowDurationHours,
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
});
restamped += 1;
}
if (restamped > 0) {
this.logger.log(
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
);
}
return restamped;
}
/**
@@ -383,24 +476,54 @@ export class TrainSchedulingService {
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// IMPORT/EXPORT trains start with a CLOSED customer window; the window engine
// opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead).
// DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL).
// Every schedule starts with a CLOSED customer window; the window engine opens
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
// 24h before departure (FCFS). No schedule is ever always-open now.
const windowCfg = await this.getWindowConfig();
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead).
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = {
ruleWindowOpenHour: windowCfg.windowOpenHour,
ruleWindowDurationHours: windowCfg.windowDurationHours,
ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes,
ruleImportWindowLeadDays: windowCfg.importWindowLeadDays,
ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours,
};
const windowFields =
direction === 'IMPORT'
direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...computeImportWindowTimes(departure, windowCfg, new Date()),
...ruleSnapshot,
...computeExportWindowTimes(departure, windowCfg),
}
: direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...computeExportWindowTimes(departure, windowCfg),
}
: {};
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
@@ -480,16 +603,22 @@ export class TrainSchedulingService {
);
if (!validation.valid) {
// Put the violation detail in the message itself — global exception
// filters flatten the body, and "Booking validation failed" alone tells
// staff nothing (e.g. which wagon type is missing at the yard).
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.length) {
const shortfall = validation.deferredBookings
.map((d) => `${d.reference}: ${d.reason}`)
.join('; ');
throw new BadRequestException({
message: 'No bookings fit on available fleet wagons',
message: `No wagons available for the selected bookings${shortfall ? `${shortfall}` : ''}`,
violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
@@ -503,12 +632,14 @@ export class TrainSchedulingService {
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (limitLoco.maxPullWeightTons < totalWeightTons) {
// forceAssign lets staff overload the locomotive set knowingly — the
// validator has already surfaced it as a warning in that case.
if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
@@ -744,6 +875,87 @@ export class TrainSchedulingService {
);
}
async getImportLoadingBookings(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
if (!scheduleBookings.length) {
return { count: 0, items: [] };
}
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
const statusByBookingId = new Map(
scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]),
);
const candidateIds = scheduleBookings
.map((sb) => sb.bookingId)
.filter((id) => allocatedBookingIds.has(id));
if (!candidateIds.length) {
return { count: 0, items: [] };
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
const items = bookings
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
.map((b) => ({
id: b.id,
reference: b.reference ?? null,
customer: b.company?.name ?? null,
weightTons: b.cargoTotalWeightVgm,
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
}));
return { count: items.length, items };
}
async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const [scheduleBookings, allocations, bookings] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
this.bookingsRepository.findByIdsForScheduling(dto.bookingIds),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const invalid: string[] = [];
for (const id of dto.bookingIds) {
const booking = bookingById.get(id);
if (
!scheduledIds.has(id) ||
!allocatedIds.has(id) ||
!booking ||
booking.tradeDirection !== 'IMPORT' ||
booking.paymentStatus !== 'PAID'
) {
invalid.push(id);
}
}
if (invalid.length) {
throw new BadRequestException(
`Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getImportLoadingBookings(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -2045,9 +2257,16 @@ export class TrainSchedulingService {
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
// wrong status) can never be forced and stay violations.
const pushLimit = (issues: string[]) =>
forceAssign ? warnings.push(...issues) : violations.push(...issues);
if (resolvedMode === 'MIXED') {
violations.push(
...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
pushLimit(
validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
);
if (requireContainerPlacements) {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
@@ -2064,7 +2283,7 @@ export class TrainSchedulingService {
);
}
} else {
violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits));
pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits));
if (requireContainerPlacements && resolvedMode === 'CONTAINER') {
violations.push(
@@ -2087,8 +2306,8 @@ export class TrainSchedulingService {
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message)) {
violations.push(message);
if (!violations.includes(message) && !warnings.includes(message)) {
pushLimit([message]);
}
}
@@ -2115,9 +2334,9 @@ export class TrainSchedulingService {
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
pushLimit([
'Assigned locomotives cannot support the total train weight and length',
);
]);
}
} else {
const inServiceLocomotives = await this.locomotivesRepository.findAll({
@@ -2135,7 +2354,7 @@ export class TrainSchedulingService {
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No locomotive can support the total train weight and length');
pushLimit(['No locomotive can support the total train weight and length']);
}
}
@@ -2504,28 +2723,98 @@ export class TrainSchedulingService {
return violations;
}
/**
* Resolve the wagon type for a batch through the cargo-type / container-type
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
* when the relevant type has no wagon type configured — scheduling is blocked
* until an admin assigns one on the cargo-type / container-type config screen.
*/
private async resolveWagonType(
freightType: 'CONTAINER' | 'BULK',
bookingIds: string[],
): Promise<WagonType> {
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
if (freightType === 'CONTAINER') {
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
});
if (!wagonType) {
throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
// First container type present on the batch drives the container wagon
// type (matches the prior single-wagon-type-per-consist behavior).
const containerType = bookings
.flatMap((b) => b.bookingContainers ?? [])
.map((line) => line.containerType)
.find((ct): ct is NonNullable<typeof ct> => Boolean(ct));
if (!containerType) {
throw new BadRequestException('No container type found on the container booking(s)');
}
const wagonType = await this.loadWagonTypeForType(
containerType.wagonTypeId ?? null,
`Container type "${containerType.label ?? containerType.code}"`,
);
return wagonType;
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const cargoCode = bookings[0]?.cargoType?.code ?? null;
const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
const picked = pickBulkWagonType(wagonTypes, cargoCode);
if (!picked) {
throw new NotFoundException('No suitable bulk wagon type found');
const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct));
if (!cargoType) {
throw new BadRequestException('No cargo type found on the bulk booking(s)');
}
return picked;
return this.loadWagonTypeForType(
cargoType.wagonTypeId ?? null,
`Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`,
);
}
/**
* Load an active wagon type by FK id, throwing a clear error when the id is
* unset (type not configured) or points at a missing/inactive wagon type.
*/
private async loadWagonTypeForType(
wagonTypeId: string | null,
typeLabel: string,
): Promise<WagonType> {
if (!wagonTypeId) {
throw new BadRequestException(
`${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`,
);
}
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { id: wagonTypeId, isActive: true },
});
if (!wagonType) {
throw new NotFoundException(
`${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`,
);
}
return wagonType;
}
/**
* Soft wagon-type resolution for the customer-facing availability preview
* (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
* returns null (→ "no days") instead of throwing when nothing is configured,
* since this only estimates which days have wagons and creates no booking.
*/
private async resolveWagonTypeForPreview(
freightType: 'CONTAINER' | 'BULK',
cargoTypeCode: string | null,
): Promise<WagonType | null> {
if (freightType === 'BULK') {
if (!cargoTypeCode) return null;
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
where: { code: cargoTypeCode },
relations: { wagonType: true },
});
return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
}
// Container preview: the input carries no specific container type, so use the
// wagon type of the first configured (active) container type.
const containerType = await this.dataSource
.getRepository(ContainerType)
.findOne({
where: { isActive: true, wagonTypeId: Not(IsNull()) },
relations: { wagonType: true },
order: { displayOrder: 'ASC' },
});
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
}
private async persistTrainSetWagons(
@@ -2915,42 +3204,39 @@ export class TrainSchedulingService {
}
/**
* Upcoming/open booking windows for a customer's active-contract lanes —
* powers the portal home "booking windows" section. Only window-engine
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are
* always open and need no announcement.
* Upcoming/open booking windows announced on the portal home "booking
* windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
* are listed so every customer sees what is opening — not just those on their
* contract lanes; DOMESTIC trains are always open and need no announcement.
*
* When `companyId` is given, a matching active contract on the lane is
* LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
* "Book now"); customers with no covering contract still see the window with a
* null contract, and the portal routes them to the contract list to get one.
*/
async getBookingWindowsForCompany(companyId: string) {
const rows: Array<{
schedule_id: string;
direction: string | null;
window_phase: string | null;
window_opens_at: Date | null;
window_closes_at: Date | null;
booking_window_status: string;
booking_cycle_no: number;
scheduled_departure_date: Date;
origin_label: string | null;
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
async getBookingWindowsForCompany(companyId: string | null) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
LEFT JOIN freight.contract_routes cr
ON cr.origin_yard_id = ts.origin_station_id
AND cr.destination_yard_id = ts.destination_station_id
AND cr.deleted_at IS NULL
JOIN freight.contracts c
LEFT JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.company_id = $1
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
@@ -2962,22 +3248,123 @@ export class TrainSchedulingService {
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`,
[companyId],
);
return rows
.map((r) => this.mapBookingWindowRow(r))
.sort((a, b) => {
const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return ta - tb;
});
}
/**
* Upcoming/open booking windows on a single contract's routes. Used to gate the
* booking form for the customer AND Ethiopian GL (who books on the customer's
* behalf): no window row with isOpenNow=true → booking entry is hidden.
*/
async getBookingWindowsForContract(contractId: string) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.origin_yard_id = ts.origin_station_id
AND cr.destination_yard_id = ts.destination_station_id
AND cr.contract_id = $1
AND cr.deleted_at IS NULL
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
[contractId],
);
return rows.map((r) => this.mapBookingWindowRow(r));
}
/**
* All announced booking windows across every lane — import window cycles AND
* export FCFS lead windows — for staff dashboards (GL clearance queue). Same
* phase filter as the customer-facing lists, no contract scoping.
*/
async listAllBookingWindows() {
const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
train_number: string | null;
}
> = await this.dataSource.query(
`SELECT ts.id AS schedule_id,
ts.train_number,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.doc_review_ends_at,
ts.payment_phase_ends_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
);
return rows.map((r) => ({
...this.mapBookingWindowRow({
...r,
contract_id: null,
contract_kind: null,
}),
trainNumber: r.train_number,
}));
}
private mapBookingWindowRow(r: BookingWindowRow) {
return {
scheduleId: r.schedule_id,
contractId: r.contract_id,
contractKind: r.contract_kind,
direction: r.direction,
windowPhase: r.window_phase,
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
windowOpensAt: r.window_opens_at,
windowClosesAt: r.window_closes_at,
docReviewEndsAt: r.doc_review_ends_at,
paymentPhaseEndsAt: r.payment_phase_ends_at,
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,
origin: r.origin_label ?? r.origin_code ?? null,
destination: r.destination_label ?? r.destination_code ?? null,
}));
};
}
/** OPEN schedules a new booking may target (with rough remaining capacity).
@@ -3100,15 +3487,12 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
const wagonTypes = await this.dataSource.getRepository(WagonType).find();
// Resolve the wagon type this cargo needs.
const requiredType =
input.freightType === 'BULK'
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
: wagonTypes.find(
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
);
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
// Soft (customer availability preview): no days if unresolved, never throws.
const requiredType = await this.resolveWagonTypeForPreview(
input.freightType,
input.cargoTypeCode ?? null,
);
if (!requiredType) return { days: [] };
// How many wagons of that type the cargo needs.
@@ -3174,6 +3558,53 @@ export class TrainSchedulingService {
return days.includes(day);
}
/**
* Enforce the config-driven booking window at booking-create time.
*
* A booking is only allowed when the route has an OPEN departure the customer
* can join for the requested day — which, because the window engine keeps
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
* `importWindowLeadDays` before departure, for `windowDurationHours`).
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
*
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
* both gates are satisfied by checking that route for open departures. When a
* specific day is requested, require an open departure on that EAT day; when no
* day is given, require at least one open departure on the route at all.
* Throws `BadRequestException` when the window is closed. No-ops when the route
* yards are unknown (nothing to gate against).
*/
async assertBookingWindowOpen(input: {
originYardId?: string | null;
destinationYardId?: string | null;
scheduledDate?: Date | string | null;
direction?: string | null;
}): Promise<void> {
const { originYardId, destinationYardId } = input;
if (!originYardId || !destinationYardId) return;
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
if (days.length === 0) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'The export booking window for this route is not open yet'
: 'The import booking window for this route is closed right now',
);
}
if (input.scheduledDate) {
const day = eatDay(new Date(input.scheduledDate));
if (!days.includes(day)) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'No departure is within the export booking window on the selected day'
: 'The import booking window is not open for the selected day',
);
}
}
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
@@ -3212,6 +3643,21 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
// Booking-window phase + phase deadlines drive the countdown timers in the
// operations workspace (display only — the window engine enforces them).
windowPhase: schedule.windowPhase ?? null,
windowOpensAt: schedule.windowOpensAt
? schedule.windowOpensAt.toISOString()
: null,
windowClosesAt: schedule.windowClosesAt
? schedule.windowClosesAt.toISOString()
: null,
docReviewEndsAt: schedule.docReviewEndsAt
? schedule.docReviewEndsAt.toISOString()
: null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
? schedule.paymentPhaseEndsAt.toISOString()
: null,
route: schedule.route
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
: null,
@@ -3374,7 +3820,7 @@ export class TrainSchedulingService {
if (!validation.valid) {
throw new BadRequestException({
message: 'Booking validation failed',
message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations,
warnings: validation.warnings,
});

View File

@@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
const perWagon = containersPerWagonFromType(wagonsPerUnit);
const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of
// the line maps to units[i] (sortOrder order); the line-level number is only
// a legacy fallback — never invent numbers here.
const units = [...(line.units ?? [])].sort(
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
);
for (let i = 0; i < qty; i += 1) {
const unit = units[i];
rows.push({
bookingId: booking.id,
bookingReference: booking.reference,
@@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
containerTypeId: line.containerTypeId ?? '',
containerTypeCode: code,
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(line.vgmPerUnitTons),
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt,
wagonsPerUnit,
containersPerWagon: perWagon,
teuSlots,
containerNumber: line.containerNumber ?? null,
containerNumber:
unit?.containerNumber?.trim() || line.containerNumber || null,
});
}
}

View File

@@ -1,49 +0,0 @@
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
COFFEE: 'KW2',
GRAIN: 'KW2',
WHEAT: 'KW2',
SORGHUM: 'KW2',
CORN: 'KW2',
FERTILIZER: 'PW2',
SUGAR: 'PW2',
COAL: 'KW3',
STEEL: 'CW3',
ORE: 'CW3',
};
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
/**
* Resolve wagon type code from cargo type code for bulk freight.
*/
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
const normalized = cargoTypeCode.trim().toUpperCase();
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
}
/**
* Pick the best matching wagon type entity for bulk cargo.
*/
export function pickBulkWagonType(
wagonTypes: WagonType[],
cargoTypeCode?: string | null,
): WagonType | undefined {
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
if (direct) return direct;
return wagonTypes.find(
(wt) =>
wt.isActive &&
!wt.supportsContainer &&
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
);
}
export function getDefaultContainerWagonTypeCode(): string {
return DEFAULT_CONTAINER_WAGON_TYPE;
}

View File

@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
import { VehiclesService } from './vehicles.service';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@ApiTags('vehicles')
@ApiBearerAuth()
@Controller('vehicles')
@FleetView()
export class VehiclesController {
constructor(private readonly vehiclesService: VehiclesService) {}
constructor(
private readonly vehiclesService: VehiclesService,
private readonly fleetHistory: FleetHistoryService,
) {}
@Post()
@FleetManage()
@@ -57,6 +61,12 @@ export class VehiclesController {
return this.vehiclesService.findById(id);
}
@Get(':id/history')
@ApiOperation({ summary: 'Get vehicle assignment, status & mile history' })
history(@Param('id', ParseUUIDPipe) id: string) {
return this.fleetHistory.getVehicleHistory(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a vehicle' })

Some files were not shown because too many files have changed in this diff Show More