mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -55,8 +55,31 @@ REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# --- Notification broker (RabbitMQ) ---------------------------------------------
|
||||
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
|
||||
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
|
||||
# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared
|
||||
# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely
|
||||
# (dev without a local broker).
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -59,29 +64,30 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-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 { 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(),
|
||||
@@ -142,6 +148,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -161,7 +168,6 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidIndodeDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -180,7 +186,6 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
@@ -202,7 +207,6 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.run();
|
||||
await this.paidIndodeDemoBookingsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
@@ -215,4 +219,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("*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
/** Container allocation on a booking (allocate-containers endpoint). */
|
||||
export const AllocationManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.allocation.manage);
|
||||
|
||||
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
21
apps/edr-freight-api/src/logger.middleware.ts
Normal file
21
apps/edr-freight-api/src/logger.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter
|
||||
);
|
||||
|
||||
// Create indexes for service_types
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
const table = await queryRunner.getTable("freight.service_types");
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Create cargo_types table
|
||||
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface {
|
||||
name = "AddBookingWindowGlobalRules1861000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3,
|
||||
ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24,
|
||||
ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8,
|
||||
ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
|
||||
ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30,
|
||||
ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60,
|
||||
ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS import_window_lead_days,
|
||||
DROP COLUMN IF EXISTS export_booking_lead_hours,
|
||||
DROP COLUMN IF EXISTS window_open_hour,
|
||||
DROP COLUMN IF EXISTS window_duration_hours,
|
||||
DROP COLUMN IF EXISTS doc_review_minutes,
|
||||
DROP COLUMN IF EXISTS payment_window_minutes,
|
||||
DROP COLUMN IF EXISTS reopen_delay_minutes;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* arriveSchedule used to release only the primary locomotive of a train set, leaving
|
||||
* secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out
|
||||
* on a dispatched train — release every ASSIGNED locomotive that is not attached to a
|
||||
* currently-DISPATCHED schedule.
|
||||
*/
|
||||
export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface {
|
||||
name = "ReleaseStuckAssignedLocomotives1861000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives l
|
||||
SET status = 'AVAILABLE'
|
||||
WHERE l.status = 'ASSIGNED'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
JOIN (
|
||||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||
FROM freight.train_set_locomotives tsl
|
||||
WHERE tsl.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT t.id AS train_set_id, t.locomotive_id
|
||||
FROM freight.train_sets t
|
||||
WHERE t.locomotive_id IS NOT NULL
|
||||
) loco ON loco.train_set_id = tset.id
|
||||
WHERE ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND loco.locomotive_id = l.id
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Data fix — not reversible.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddScheduleWindowPhases1862000000000 implements MigrationInterface {
|
||||
name = "AddScheduleWindowPhases1862000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN window_phase varchar(20) NULL,
|
||||
ADD COLUMN window_opens_at timestamptz NULL,
|
||||
ADD COLUMN window_closes_at timestamptz NULL,
|
||||
ADD COLUMN doc_review_ends_at timestamptz NULL,
|
||||
ADD COLUMN doc_review_completed_at timestamptz NULL,
|
||||
ADD COLUMN payment_phase_ends_at timestamptz NULL,
|
||||
ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_train_schedules_window_phase
|
||||
ON freight.train_schedules (window_phase)
|
||||
WHERE window_phase IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS window_phase,
|
||||
DROP COLUMN IF EXISTS window_opens_at,
|
||||
DROP COLUMN IF EXISTS window_closes_at,
|
||||
DROP COLUMN IF EXISTS doc_review_ends_at,
|
||||
DROP COLUMN IF EXISTS doc_review_completed_at,
|
||||
DROP COLUMN IF EXISTS payment_phase_ends_at,
|
||||
DROP COLUMN IF EXISTS booking_cycle_no;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateBookingBatchOffers1863000000000 implements MigrationInterface {
|
||||
name = "CreateBookingBatchOffers1863000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE 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 idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add location_id column to vehicles table to track vehicle base location.
|
||||
*/
|
||||
export class AddLocationToVehicles1870000000000 implements MigrationInterface {
|
||||
name = "AddLocationToVehicles1870000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS location_id uuid;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS location_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add FREE and BUSY statuses to vehicle status enum.
|
||||
*/
|
||||
export class AddVehicleStatuses1880000000000 implements MigrationInterface {
|
||||
name = "AddVehicleStatuses1880000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create enum type if it doesn't exist
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN
|
||||
CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE');
|
||||
ELSE
|
||||
-- Add values if enum already exists but doesn't have them
|
||||
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE';
|
||||
ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE';
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Note: Postgres cannot drop individual enum values, so the down migration is a no-op
|
||||
// The enum values FREE and BUSY will remain but will be unused after downgrade
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Split the mixed vehicle status into two fields:
|
||||
* - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE)
|
||||
* - availability: assignment state (FREE, BUSY)
|
||||
*
|
||||
* Existing FREE/BUSY statuses are moved to availability and the status is
|
||||
* normalized back to ACTIVE.
|
||||
*/
|
||||
export class SeparateVehicleAvailability1890000000000 implements MigrationInterface {
|
||||
name = "SeparateVehicleAvailability1890000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY')
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Fold availability back into status before dropping the column
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles SET status = availability
|
||||
WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY')
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Add code, power_plate_no and trailer_plate_no columns to vehicles.
|
||||
* These fields existed in the DTO and UI form but had no entity columns,
|
||||
* so submitted values were silently dropped.
|
||||
*/
|
||||
export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface {
|
||||
name = "AddVehicleCodeAndPlates1890000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS code varchar,
|
||||
ADD COLUMN IF NOT EXISTS power_plate_no varchar,
|
||||
ADD COLUMN IF NOT EXISTS trailer_plate_no varchar
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS code,
|
||||
DROP COLUMN IF EXISTS power_plate_no,
|
||||
DROP COLUMN IF EXISTS trailer_plate_no
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Support email as a second OTP channel alongside phone (e.g. signup lets the
|
||||
* user choose which one to verify). `phone` becomes nullable since an
|
||||
* email-channel row has none, and `email` is added as a nullable unique column
|
||||
* mirroring `phone`'s shape.
|
||||
*/
|
||||
export class AddEmailToOtpVerifications1900000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddEmailToOtpVerifications1900000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
ALTER COLUMN phone DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
DROP COLUMN IF EXISTS email
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
ALTER COLUMN phone SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,31 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BookingView } from "../../common/booking-guards";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
@BookingView()
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) { }
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@Get("invoices")
|
||||
@ApiOperation({ summary: "List all invoices" })
|
||||
findAll() {
|
||||
return this.billingService.findAll();
|
||||
@ApiOperation({
|
||||
summary: "List invoices (paginated, filterable by company/status/search)",
|
||||
})
|
||||
findAll(@Query() query: FilterInvoiceDto) {
|
||||
return this.billingService.findAllPaginated(query);
|
||||
}
|
||||
|
||||
@Get("invoices/:id")
|
||||
|
||||
@@ -125,7 +125,7 @@ export class BillingService {
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -134,9 +134,56 @@ export class BillingService {
|
||||
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated invoice list for the backoffice — optionally narrowed to a
|
||||
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||
* invoices page).
|
||||
*/
|
||||
async findAllPaginated(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{ items: Invoice[]; total: number }> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
companyId: filter.companyId,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/** Invoice header plus its line items. */
|
||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.invoices.findById(id);
|
||||
const invoice = await this.invoices.findById(id, {
|
||||
relations: { company: true, companyProfile: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const lines = await this.invoiceLines.findAll({
|
||||
where: { invoiceId: id },
|
||||
@@ -375,7 +422,7 @@ export class BillingService {
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
@@ -691,7 +738,13 @@ export class BillingService {
|
||||
status: invoice.status,
|
||||
paymentId: invoice.paymentId ?? null,
|
||||
};
|
||||
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||
this.events
|
||||
.emitAsync(`${invoice.source}.invoice.${event}`, payload)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||
@@ -823,7 +876,10 @@ export class BillingService {
|
||||
): Promise<void> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
|
||||
where: {
|
||||
id: invoiceId,
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
},
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(
|
||||
@@ -881,7 +937,7 @@ export class BillingService {
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
orderRef: invoice.invoiceNumber.replace("-", "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
|
||||
export class FilterInvoiceDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number = 20;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Freight.InvoiceStatus })
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(Freight.InvoiceStatus))
|
||||
status?: Freight.InvoiceStatus;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { AllocateContainersDto } from './dto/allocate-containers.dto';
|
||||
import { AllocationManage } from '../../common/booking-guards';
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@@ -10,6 +11,7 @@ export class BookingAllocationController {
|
||||
constructor(private readonly bookingsService: BookingsService) {}
|
||||
|
||||
@Post(':bookingId/allocate-containers')
|
||||
@AllocationManage()
|
||||
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
||||
async allocateContainers(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
|
||||
@@ -97,6 +97,9 @@ export class BookingInvoiceService {
|
||||
*/
|
||||
@OnEvent("booking.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||
);
|
||||
switch (payload.type) {
|
||||
case "PREPAID":
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
@@ -135,7 +138,7 @@ export class BookingInvoiceService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (booking.paymentStatus === "PAID") return;
|
||||
// if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
@@ -143,9 +146,16 @@ export class BookingInvoiceService {
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
|
||||
try {
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
exchangeService as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ import {
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
|
||||
export interface OverweightLine {
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}
|
||||
|
||||
export interface ComputedPriceResult {
|
||||
lineItems: PriceLineItemDto[];
|
||||
@@ -27,6 +35,7 @@ export interface ComputedPriceResult {
|
||||
priorityScore: number;
|
||||
warnings: string[];
|
||||
hardBlocked: string[];
|
||||
overweightLines: OverweightLine[];
|
||||
}
|
||||
|
||||
type StoredPricingBreakdown = {
|
||||
@@ -67,6 +76,7 @@ export class BookingPricingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -94,12 +104,19 @@ export class BookingPricingService {
|
||||
},
|
||||
} as never);
|
||||
|
||||
// 20ft weight-pairing preview: surfaced now so the customer sees the problem
|
||||
// (and the overweight warning + surcharge) at the confirm step, before submit.
|
||||
// Submit re-runs this and HARD-BLOCKS on a non-empty result.
|
||||
const pairing = await this.containerValidationService.validate20ftPairing(booking);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
warnings: computed.warnings,
|
||||
overweightLines: computed.overweightLines,
|
||||
pairingErrors: pairing.map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,6 +186,35 @@ export class BookingPricingService {
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
}
|
||||
|
||||
// Overweight detail for the customer: map the engine's per-line results back
|
||||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||
// is derived from the line total minus the excess the engine computed.
|
||||
const overweightLines: OverweightLine[] = [];
|
||||
const containerLines = (booking.bookingContainers ?? []).filter(
|
||||
(bc) => bc.containerTypeId != null,
|
||||
);
|
||||
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||||
const wr = ruleResult.containerWeightResults[i];
|
||||
if (!wr?.isOverweight) continue;
|
||||
const line = containerLines[i];
|
||||
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
|
||||
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||
let code = line?.containerSize ?? '';
|
||||
if (line?.containerTypeId) {
|
||||
try {
|
||||
code = (await this.containerTypesService.findById(line.containerTypeId)).code;
|
||||
} catch {
|
||||
// fall back to the container size label
|
||||
}
|
||||
}
|
||||
overweightLines.push({
|
||||
containerTypeCode: code,
|
||||
totalVgmTons,
|
||||
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
|
||||
excessTons,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
@@ -178,6 +224,7 @@ export class BookingPricingService {
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
warnings: ruleResult.warnings,
|
||||
hardBlocked: ruleResult.hardBlocked,
|
||||
overweightLines,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => {
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
@@ -51,6 +52,7 @@ export class BookingTransitionService {
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
|
||||
) {}
|
||||
|
||||
@@ -58,6 +60,19 @@ export class BookingTransitionService {
|
||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||
}
|
||||
|
||||
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||
private async assert20ftPairable(booking: Booking): Promise<void> {
|
||||
const violations =
|
||||
await this.containerValidationService.validate20ftPairing(booking);
|
||||
if (violations.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot submit — 20ft containers cannot be paired on wagons: ${violations
|
||||
.map((v) => v.message)
|
||||
.join(' ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
||||
@@ -78,6 +93,11 @@ export class BookingTransitionService {
|
||||
requiresDirectorApproval: false,
|
||||
});
|
||||
|
||||
// 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
|
||||
// If no balanced pairing exists the booking cannot proceed (overweight only
|
||||
// warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
|
||||
await this.assert20ftPairable(booking);
|
||||
|
||||
const stored = booking.pricingBreakdown as {
|
||||
lineItems?: PriceLineItemDto[];
|
||||
totalAmount?: number;
|
||||
@@ -158,6 +178,7 @@ export class BookingTransitionService {
|
||||
hardBlocked: computed.hardBlocked,
|
||||
requiresDirectorApproval: false,
|
||||
});
|
||||
await this.assert20ftPairable(booking);
|
||||
|
||||
await this.pricingService.createPricingSnapshots(
|
||||
bookingId,
|
||||
@@ -991,6 +1012,17 @@ export class BookingTransitionService {
|
||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||
const now = new Date();
|
||||
|
||||
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
||||
// booking's day still has capacity — nothing below runs and the request stays
|
||||
// pending for staff to move/decline. (For a consolidated pair this is a rough
|
||||
// solo pre-check; the real combined-capacity reservation happens after the
|
||||
// booking is FULLY_EXECUTED, once both partners are ready.)
|
||||
const isExportTrain =
|
||||
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
||||
if (isExportTrain) {
|
||||
await this.bookingBatchService.pickExportSchedule(booking);
|
||||
}
|
||||
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
||||
@@ -1014,7 +1046,16 @@ export class BookingTransitionService {
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
|
||||
if (booking.scheduledDate) {
|
||||
if (isExportTrain) {
|
||||
// FCFS: reserve the slot and send the payment notification immediately;
|
||||
// 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,
|
||||
@@ -1029,6 +1070,12 @@ export class BookingTransitionService {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
activeBatchOffer?: {
|
||||
offeredWagons: number;
|
||||
totalWagons: number;
|
||||
offeredAmount: number;
|
||||
paymentDeadline: Date;
|
||||
} | null;
|
||||
}
|
||||
> {
|
||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||
@@ -1044,11 +1091,16 @@ export class BookingTransitionService {
|
||||
? 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;
|
||||
return {
|
||||
...booking,
|
||||
latestChangeRequestNote: note?.note ?? null,
|
||||
contractSummary: summary,
|
||||
nextStep,
|
||||
activeBatchOffer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,12 +617,14 @@ export class BookingsController {
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -33,14 +34,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature.
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -62,6 +63,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat
|
||||
forwardRef(() => ContractsModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
VehiclesModule,
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
@@ -78,6 +80,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ConsolidationService,
|
||||
ContainerValidationService,
|
||||
BookingReferenceDataService,
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
|
||||
@@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides).
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
* reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial.
|
||||
*
|
||||
* Partners must also ride the SAME booking day: consolidation shares one physical wagon,
|
||||
* and the window/batch pool is keyed on the EAT departure day, so a pair that can't board
|
||||
* the same train is useless. The day filter is applied only when THIS booking already has
|
||||
* a scheduled_date (draft bookings without a date match on route/type alone until they pick one).
|
||||
*/
|
||||
async findComplementaryConsolidationPartner(
|
||||
booking: Booking,
|
||||
@@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
): Promise<Booking | null> {
|
||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||
|
||||
return this.repository
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
@@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
||||
quantity,
|
||||
perWagon,
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.getOne();
|
||||
});
|
||||
|
||||
// Same EAT booking day, so the pair can share a wagon on one train. Skip only
|
||||
// when this booking has no date yet (matched again once it picks its day).
|
||||
if (booking.scheduledDate) {
|
||||
qb.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: booking.scheduledDate },
|
||||
);
|
||||
}
|
||||
|
||||
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||
}
|
||||
|
||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||
|
||||
@@ -31,6 +31,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
|
||||
@@ -94,6 +96,7 @@ export class BookingsService {
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
) {}
|
||||
|
||||
@@ -1520,6 +1523,16 @@ export class BookingsService {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
|
||||
const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, {
|
||||
where: {
|
||||
bookingId,
|
||||
containerId: In(allocations.map((a) => a.containerId)),
|
||||
},
|
||||
});
|
||||
const previousVehicleIds = previousAllocations
|
||||
.map((a) => a.vehicleId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const allocation of allocations) {
|
||||
await manager.delete(BookingContainerAllocation, {
|
||||
@@ -1536,6 +1549,16 @@ export class BookingsService {
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { validate20ftWeightPairing } from './container-pairing.util';
|
||||
|
||||
describe('validate20ftWeightPairing', () => {
|
||||
const MAX_DIFF = 10;
|
||||
|
||||
it('passes when a balanced pairing exists (adjacent diffs within cap)', () => {
|
||||
// sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10.
|
||||
const units = [
|
||||
{ label: 'A', grossWeightTons: 24 },
|
||||
{ label: 'B', grossWeightTons: 8 },
|
||||
{ label: 'C', grossWeightTons: 18 },
|
||||
{ label: 'D', grossWeightTons: 15 },
|
||||
];
|
||||
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a pair whose weight difference exceeds the cap', () => {
|
||||
// sorted: 5, 25 → single pair diff 20 > 10.
|
||||
const units = [
|
||||
{ label: 'HEAVY', grossWeightTons: 25 },
|
||||
{ label: 'LIGHT', grossWeightTons: 5 },
|
||||
];
|
||||
const result = validate20ftWeightPairing(units, MAX_DIFF);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']);
|
||||
expect(result[0].diffTons).toBe(20);
|
||||
});
|
||||
|
||||
it('allows an odd leftover unit (goes to consolidation, not a violation)', () => {
|
||||
// sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover.
|
||||
const units = [
|
||||
{ label: 'A', grossWeightTons: 10 },
|
||||
{ label: 'B', grossWeightTons: 12 },
|
||||
{ label: 'C', grossWeightTons: 30 },
|
||||
];
|
||||
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||
});
|
||||
|
||||
it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => {
|
||||
// Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail),
|
||||
// but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation.
|
||||
const units = [
|
||||
{ label: 'A', grossWeightTons: 20 },
|
||||
{ label: 'B', grossWeightTons: 12 },
|
||||
{ label: 'C', grossWeightTons: 22 },
|
||||
{ label: 'D', grossWeightTons: 10 },
|
||||
];
|
||||
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns nothing for fewer than two units', () => {
|
||||
expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]);
|
||||
expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Booking-time 20ft weight-pairing rule.
|
||||
*
|
||||
* A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the
|
||||
* same wagon their gross-weight difference must not exceed `maxPairDiffTons`
|
||||
* (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays
|
||||
* balanced. 40ft containers occupy a whole wagon alone and never pair.
|
||||
*
|
||||
* At booking time the customer enters every 20ft container's weight but not its
|
||||
* wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent
|
||||
* (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY
|
||||
* valid pairing exists this one finds it — a violation here means no balanced
|
||||
* pairing is possible and the booking must be blocked. An odd leftover 20ft is
|
||||
* fine: it has no partner in this booking and flows to consolidation.
|
||||
*/
|
||||
|
||||
export interface Container20ftUnit {
|
||||
/** Human label for messages, e.g. the container number. */
|
||||
label: string;
|
||||
grossWeightTons: number;
|
||||
}
|
||||
|
||||
export interface PairingViolation {
|
||||
message: string;
|
||||
/** The two container labels whose pairing exceeds the diff cap. */
|
||||
labels: [string, string];
|
||||
diffTons: number;
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Validate that the given 20ft units can all be paired onto wagons within the
|
||||
* weight-difference cap. Returns one violation per over-cap adjacent pair (empty
|
||||
* when every wagon pair is balanced or there is nothing to pair). A single
|
||||
* leftover unit (odd count) is not a violation.
|
||||
*/
|
||||
export function validate20ftWeightPairing(
|
||||
units: Container20ftUnit[],
|
||||
maxPairDiffTons: number,
|
||||
): PairingViolation[] {
|
||||
if (units.length < 2 || maxPairDiffTons == null) return [];
|
||||
|
||||
// Ascending by weight: adjacent pairs have the smallest possible diffs.
|
||||
const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons);
|
||||
const violations: PairingViolation[] = [];
|
||||
|
||||
for (let i = 0; i + 1 < sorted.length; i += 2) {
|
||||
const a = sorted[i];
|
||||
const b = sorted[i + 1];
|
||||
const diff = Math.abs(a.grossWeightTons - b.grossWeightTons);
|
||||
if (diff > maxPairDiffTons) {
|
||||
violations.push({
|
||||
message:
|
||||
`20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` +
|
||||
`${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` +
|
||||
`weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`,
|
||||
labels: [a.label, b.label],
|
||||
diffTons: round2(diff),
|
||||
});
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
|
||||
import {
|
||||
Container20ftUnit,
|
||||
PairingViolation,
|
||||
validate20ftWeightPairing,
|
||||
} from './container-pairing.util';
|
||||
|
||||
/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */
|
||||
const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10;
|
||||
|
||||
/**
|
||||
* Booking-time container validations that need the customer-entered per-unit
|
||||
* weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the
|
||||
* rule engine (which works on line totals) because pairing is per physical unit.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContainerValidationService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
private async maxPairDiffTons(): Promise<number> {
|
||||
const row = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||||
.then((rows) => rows[0] ?? null)
|
||||
.catch(() => null);
|
||||
const v = row?.max20ftPairWeightDiffTons;
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS;
|
||||
}
|
||||
|
||||
/** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */
|
||||
private async load20ftUnits(booking: Booking): Promise<Container20ftUnit[]> {
|
||||
const lines = (booking.bookingContainers ?? []).filter(
|
||||
(bc) => (bc.containerSize ?? '').includes('20'),
|
||||
);
|
||||
if (!lines.length) return [];
|
||||
|
||||
const units = await this.dataSource
|
||||
.getRepository(BookingContainerUnit)
|
||||
.find({
|
||||
where: { bookingContainerId: In(lines.map((l) => l.id)) },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
|
||||
return units.map((u) => ({
|
||||
label: u.containerNumber || u.id.slice(0, 8),
|
||||
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the 20ft weight-pairing rule for a booking. Returns one message per
|
||||
* pair whose weight difference exceeds the cap; empty when all 20ft can be
|
||||
* balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine —
|
||||
* it flows to consolidation. Callers hard-block a non-empty result.
|
||||
*/
|
||||
async validate20ftPairing(booking: Booking): Promise<PairingViolation[]> {
|
||||
// Only bookings whose 20ft lines actually carry per-unit weights can be
|
||||
// checked; contract-drawdown bookings do (units are required there).
|
||||
const containerLines = booking.bookingContainers ?? [];
|
||||
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
|
||||
if (!has20ft) return [];
|
||||
|
||||
const units = await this.load20ftUnits(booking);
|
||||
if (units.length < 2) return [];
|
||||
|
||||
const maxDiff = await this.maxPairDiffTons();
|
||||
return validate20ftWeightPairing(units, maxDiff);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,20 @@ export class PriceLineItemDto {
|
||||
currency!: string;
|
||||
}
|
||||
|
||||
export class OverweightLineDto {
|
||||
@ApiProperty()
|
||||
containerTypeCode!: string;
|
||||
|
||||
@ApiProperty()
|
||||
totalVgmTons!: number;
|
||||
|
||||
@ApiProperty()
|
||||
maxAllowedTons!: number;
|
||||
|
||||
@ApiProperty()
|
||||
excessTons!: number;
|
||||
}
|
||||
|
||||
export class GeneratePriceResponseDto {
|
||||
@ApiProperty()
|
||||
bookingId!: string;
|
||||
@@ -42,4 +56,16 @@ export class GeneratePriceResponseDto {
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
warnings!: string[];
|
||||
|
||||
/** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */
|
||||
@ApiProperty({ type: [OverweightLineDto] })
|
||||
overweightLines!: OverweightLineDto[];
|
||||
|
||||
/**
|
||||
* 20ft weight-pairing violations. Non-empty means the booking cannot be
|
||||
* balanced onto wagons and submit is HARD-BLOCKED — the customer must fix
|
||||
* container weights/quantities. (Overweight, by contrast, only warns.)
|
||||
*/
|
||||
@ApiProperty({ type: [String] })
|
||||
pairingErrors!: string[];
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ function makeService(overrides?: {
|
||||
children: [{ value: '2' }],
|
||||
}),
|
||||
};
|
||||
const glOperationsService = {
|
||||
t1State: jest.fn().mockResolvedValue({
|
||||
bookingId: 'b-general',
|
||||
wagonAllocated: false,
|
||||
trainDepartedAt: null,
|
||||
trainArrivedAt: null,
|
||||
closed: false,
|
||||
closedAt: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BookingClearanceService(
|
||||
bookingsRepository as never,
|
||||
@@ -74,6 +84,7 @@ function makeService(overrides?: {
|
||||
workflowService as never,
|
||||
milestoneService as never,
|
||||
dropdownSettingsService as never,
|
||||
glOperationsService as never,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
} from '@edr/types';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
@@ -11,6 +17,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
@@ -63,6 +70,23 @@ export interface BookingClearanceView {
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||
/** Import post-allocation T1 transit document state (null until wagon allocation). */
|
||||
t1?: ClearanceT1State | null;
|
||||
/** Train link state for the booking (both directions). */
|
||||
train?: ClearanceTrainState | null;
|
||||
gatepassGranted?: boolean;
|
||||
gatepassAt?: string | null;
|
||||
t1Closed?: boolean;
|
||||
t1ClosedAt?: string | null;
|
||||
offloaded?: boolean;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -75,6 +99,7 @@ export class BookingClearanceService {
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
) {}
|
||||
|
||||
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
||||
@@ -162,6 +187,29 @@ export class BookingClearanceService {
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
|
||||
let t1: ClearanceT1State | null = null;
|
||||
if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') {
|
||||
try {
|
||||
t1 = await this.glOperationsService.t1State(bookingId);
|
||||
} catch {
|
||||
t1 = null;
|
||||
}
|
||||
}
|
||||
|
||||
let train: ClearanceTrainState | null = null;
|
||||
try {
|
||||
train = await this.glOperationsService.trainState(bookingId);
|
||||
} catch {
|
||||
train = null;
|
||||
}
|
||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
||||
const bookingMilestone = (code: string) =>
|
||||
milestones.find((m) => m.milestoneCode === code);
|
||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
status: booking.status,
|
||||
@@ -192,6 +240,34 @@ export class BookingClearanceService {
|
||||
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
train,
|
||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
||||
gatepassAt:
|
||||
gatepassMilestone?.status === 'COMPLETED'
|
||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
||||
(gatepassMilestone.triggeredAt
|
||||
? gatepassMilestone.triggeredAt.toISOString()
|
||||
: null))
|
||||
: null,
|
||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||
t1ClosedAt:
|
||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
|
||||
: null,
|
||||
riskAssignedAt:
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,6 +364,12 @@ export class BookingClearanceService {
|
||||
: ContractDocPhase.CustomerDuty,
|
||||
} as never);
|
||||
|
||||
// Export: the declaration is the last GL ET pre-operation action — release
|
||||
// immediately so the customer can proceed without a separate confirm click.
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -421,6 +503,13 @@ export class BookingClearanceService {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||
} as never);
|
||||
|
||||
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
if (files.some((f) => f.code === 'delivery_order')) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -428,21 +517,18 @@ export class BookingClearanceService {
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
||||
}
|
||||
|
||||
if (!booking.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED');
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
@@ -450,8 +536,16 @@ export class BookingClearanceService {
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
if (vesselDepartureDate?.trim()) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
if (booking.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,16 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
SECOND_DUTY_ADVISED: {
|
||||
label: 'Additional Duty and Taxes Advised',
|
||||
ownerRegion: 'ET',
|
||||
triggeredByDoc: false,
|
||||
},
|
||||
SECOND_DUTY_PAID: {
|
||||
label: 'Additional Duty and Tax Paid',
|
||||
ownerRegion: 'CUST',
|
||||
triggeredByDoc: true,
|
||||
},
|
||||
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
@@ -68,6 +78,7 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false },
|
||||
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||
};
|
||||
|
||||
|
||||
@@ -91,6 +91,39 @@ export class ClearanceMilestoneService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find-or-create a post-booking milestone row from the catalog. Needed for codes
|
||||
* added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED).
|
||||
*/
|
||||
async ensureForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
tradeDirection: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (existing) return existing;
|
||||
|
||||
const { postBooking } = splitMilestones(tradeDirection);
|
||||
const idx = postBooking.findIndex((d) => d.code === code);
|
||||
if (idx < 0) {
|
||||
throw new NotFoundException(
|
||||
`Milestone ${code} is not a ${tradeDirection} post-booking milestone`,
|
||||
);
|
||||
}
|
||||
const def = postBooking[idx]!;
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
bookingId,
|
||||
milestoneCode: def.code,
|
||||
milestoneLabel: def.label,
|
||||
ownerRegion: def.ownerRegion,
|
||||
triggeredByDoc: def.triggeredByDoc,
|
||||
status: 'PENDING',
|
||||
sortOrder: idx,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark a milestone complete (by code) on a booking. */
|
||||
async completeForBooking(
|
||||
bookingId: string,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
@@ -13,6 +16,9 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
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';
|
||||
@@ -60,6 +66,9 @@ export class ContractBookingService {
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
@@ -111,6 +120,20 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
@@ -563,6 +586,145 @@ 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.
|
||||
*/
|
||||
async validateShipment(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<{
|
||||
overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}>;
|
||||
overweightSurchargeAmount: number;
|
||||
currency: string | null;
|
||||
pairingErrors: string[];
|
||||
}> {
|
||||
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: [],
|
||||
overweightSurchargeAmount: 0,
|
||||
currency: null,
|
||||
pairingErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 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).
|
||||
const resolved = 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 { line, ct, totalVgmTons };
|
||||
}),
|
||||
);
|
||||
|
||||
const ruleResult = await this.ruleEngineService.evaluate({
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
isHazardous: false,
|
||||
isReefer: contract.isReefer ?? false,
|
||||
isGovernment: false,
|
||||
allowConsolidation: false,
|
||||
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);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||||
const twentyFtUnits = resolved
|
||||
.filter((r) => (r.line.containerSize ?? '').includes('20'))
|
||||
.flatMap((r) =>
|
||||
(r.line.units ?? []).map((u, idx) => ({
|
||||
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
|
||||
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||
})),
|
||||
);
|
||||
const maxDiff = await this.max20ftPairDiffTons();
|
||||
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
|
||||
(v) => v.message,
|
||||
);
|
||||
|
||||
// Real overweight surcharge (same rate the rule engine bills at booking-create
|
||||
// time) so the confirm-modal total isn't missing the charge the warning refers to.
|
||||
// Rates are stored in USD; convert to the contract's payment currency the same
|
||||
// way BookingPricingService does so this preview matches the eventual booking total.
|
||||
const overweightModifier = ruleResult.appliedModifiers.find(
|
||||
(m) => m.surchargeCode === 'OVERWEIGHT_PER_TON',
|
||||
);
|
||||
let overweightSurchargeAmount = 0;
|
||||
if (overweightModifier) {
|
||||
const isEtb = contract.paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
overweightSurchargeAmount = isEtb
|
||||
? Math.round(overweightModifier.calculatedAmount * usdToEtb)
|
||||
: overweightModifier.calculatedAmount;
|
||||
}
|
||||
|
||||
return {
|
||||
overweightLines,
|
||||
overweightSurchargeAmount,
|
||||
currency: overweightLines.length ? contract.paymentCurrency : null,
|
||||
pairingErrors,
|
||||
};
|
||||
}
|
||||
|
||||
private async max20ftPairDiffTons(): Promise<number> {
|
||||
const row = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||||
.then((rows) => rows[0] ?? null)
|
||||
.catch(() => null);
|
||||
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
|
||||
return Number.isFinite(n) ? n : 10;
|
||||
}
|
||||
|
||||
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||
private async resolveContainerTypeForSize(
|
||||
size: string,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
} from '@edr/types';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
@@ -10,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
|
||||
import { contractClearanceCodes } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
@@ -77,6 +84,23 @@ export interface ContractClearanceView {
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||
t1?: ClearanceT1State | null;
|
||||
/** Train link state for the booking (both directions; null until a booking is linked). */
|
||||
train?: ClearanceTrainState | null;
|
||||
gatepassGranted?: boolean;
|
||||
gatepassAt?: string | null;
|
||||
t1Closed?: boolean;
|
||||
t1ClosedAt?: string | null;
|
||||
offloaded?: boolean;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -90,6 +114,7 @@ export class ContractClearanceService {
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(contract: Contract): boolean {
|
||||
@@ -208,8 +233,9 @@ export class ContractClearanceService {
|
||||
files,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
let bookingFiles: Awaited<ReturnType<FilesService['findByResource']>> = [];
|
||||
if (cycle?.bookingId) {
|
||||
const bookingFiles = await this.filesService.findByResource(
|
||||
bookingFiles = await this.filesService.findByResource(
|
||||
cycle.bookingId,
|
||||
'bookings',
|
||||
);
|
||||
@@ -224,11 +250,41 @@ export class ContractClearanceService {
|
||||
workflowFiles = [...byCode.values()];
|
||||
}
|
||||
|
||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||
let t1: ClearanceT1State | null = null;
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') {
|
||||
try {
|
||||
t1 = await this.glOperationsService.t1State(cycle.bookingId);
|
||||
} catch {
|
||||
t1 = null; // linked booking missing — view stays usable
|
||||
}
|
||||
}
|
||||
|
||||
let train: ClearanceTrainState | null = null;
|
||||
let bookingMilestones: ClearanceMilestone[] = [];
|
||||
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
||||
if (cycle?.bookingId) {
|
||||
try {
|
||||
train = await this.glOperationsService.trainState(cycle.bookingId);
|
||||
} catch {
|
||||
train = null;
|
||||
}
|
||||
bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||
cycle.bookingId,
|
||||
);
|
||||
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
|
||||
}
|
||||
const bookingMilestone = (code: string) =>
|
||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(
|
||||
bookingMilestones,
|
||||
bookingFiles,
|
||||
);
|
||||
|
||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
@@ -272,6 +328,34 @@ export class ContractClearanceService {
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
train,
|
||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
||||
gatepassAt:
|
||||
gatepassMilestone?.status === 'COMPLETED'
|
||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
||||
(gatepassMilestone.triggeredAt
|
||||
? gatepassMilestone.triggeredAt.toISOString()
|
||||
: null))
|
||||
: null,
|
||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||
t1ClosedAt:
|
||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
|
||||
: null,
|
||||
riskAssignedAt:
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -859,6 +943,12 @@ export class ContractClearanceService {
|
||||
});
|
||||
}
|
||||
|
||||
// Export: the declaration is the last GL ET pre-booking action — release
|
||||
// immediately so booking creation unlocks without a separate confirm click.
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
await this.workflowService.onExportReleased(contractId, userId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -1011,6 +1101,13 @@ export class ContractClearanceService {
|
||||
currentPhase: ContractDocPhase.GlDjCollection,
|
||||
});
|
||||
|
||||
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||
if (files.some((f) => f.code === 'delivery_order')) {
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -1018,6 +1115,7 @@ export class ContractClearanceService {
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
@@ -1025,17 +1123,11 @@ export class ContractClearanceService {
|
||||
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
@@ -1043,8 +1135,16 @@ export class ContractClearanceService {
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle && vesselDepartureDate?.trim()) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
});
|
||||
}
|
||||
if (cycle?.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
@@ -63,6 +64,7 @@ export class ContractTransitionService {
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly otpService: OtpService,
|
||||
) {}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
@@ -520,6 +522,12 @@ export class ContractTransitionService {
|
||||
if (existing) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
|
||||
// must be verified before the signature is applied.
|
||||
if (!dto.otpPhone || !dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from './dto/gl-operations.dto';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
GatepassDto,
|
||||
RoAmendmentDto,
|
||||
} from './dto/phased-clearance.dto';
|
||||
|
||||
@@ -610,9 +611,15 @@ export class ContractsController {
|
||||
uploadDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
|
||||
return this.clearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@@ -681,6 +688,30 @@ export class ContractsController {
|
||||
return this.clearanceService.djQueue(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-schedules')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
|
||||
djClearanceSchedules() {
|
||||
return this.glOperationsService.djSchedules();
|
||||
}
|
||||
|
||||
@Post('clearance/schedules/:scheduleId/gatepass')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
|
||||
})
|
||||
grantScheduleGatepass(
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
@Body() dto: GatepassDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.grantScheduleGatepass(
|
||||
scheduleId,
|
||||
dto?.gatepassAt,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||
|
||||
@Get('clearance/ops-queue')
|
||||
@@ -757,6 +788,18 @@ 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).',
|
||||
})
|
||||
validateShipment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
) {
|
||||
return this.contractBookingService.validateShipment(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/capacity')
|
||||
@ApiOperation({
|
||||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||
@@ -873,6 +916,147 @@ export class ContractsController {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/t1-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
|
||||
})
|
||||
uploadT1Documents(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/t1-close')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)',
|
||||
})
|
||||
closeT1(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/gatepass')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
|
||||
grantGatepass(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: GatepassDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.grantGatepass(
|
||||
bookingId,
|
||||
dto?.gatepassAt,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)',
|
||||
})
|
||||
createFinalInvoice(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body('amount') amountRaw: string,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('description') description: string | undefined,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.createFinalInvoice(
|
||||
bookingId,
|
||||
{
|
||||
amount: Number(amountRaw),
|
||||
currency: currency?.trim() || 'ETB',
|
||||
description,
|
||||
},
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
|
||||
uploadFinalInvoiceSlip(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice/confirm')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
])
|
||||
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
|
||||
confirmFinalInvoicePaid(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.confirmFinalInvoicePaid(
|
||||
bookingId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/second-duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)',
|
||||
})
|
||||
adviseSecondDuty(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.adviseSecondDuty(
|
||||
bookingId,
|
||||
{
|
||||
dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1',
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
},
|
||||
attachment,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/second-duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
|
||||
uploadSecondDutySlip(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.glOperationsService.uploadSecondDutySlip(bookingId, file);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
@@ -10,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||
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';
|
||||
@@ -64,16 +67,22 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
Booking,
|
||||
BookingContainerUnit,
|
||||
]),
|
||||
BillingModule,
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
SignaturesModule,
|
||||
OtpModule,
|
||||
CompaniesModule,
|
||||
// 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 =>
|
||||
|
||||
@@ -35,3 +35,12 @@ export class RoAmendmentDto {
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class GatepassDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gatepassAt?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class SignContractDto {
|
||||
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
|
||||
@@ -26,4 +26,19 @@ export class SignContractDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
consentText?: string;
|
||||
|
||||
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
|
||||
// SMS'd to the signer's phone, verified server-side before the signature is
|
||||
// applied. `otpPhone` is the number the code was sent to (the signed-in
|
||||
// customer's registered phone).
|
||||
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
|
||||
otp?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
otpPhone?: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface MilestoneMetadata {
|
||||
dutyAmount?: number;
|
||||
dutyCurrency?: string;
|
||||
declarationSerial?: string;
|
||||
/** When the gate pass was physically granted (GL DJ captures the time). */
|
||||
gatepassAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
ClearanceIncident,
|
||||
IncidentType,
|
||||
} from './entities/clearance-incident.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { persistExportTransportUploads } from './phased-clearance.util';
|
||||
import {
|
||||
persistExportTransportUploads,
|
||||
persistT1TransportUploads,
|
||||
} from './phased-clearance.util';
|
||||
|
||||
/**
|
||||
* Maps a GL post-booking document `code` to the milestone it auto-completes when
|
||||
@@ -18,7 +33,8 @@ import { persistExportTransportUploads } from './phased-clearance.util';
|
||||
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
|
||||
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
|
||||
delivery_order: 'DO_COLLECTED', // import — GL DJ
|
||||
t1_transport_document: 'T1_CLOSED', // import — GL ET
|
||||
// t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only
|
||||
// when GL Ethiopia accepts the T1 set after the train arrives (closeT1).
|
||||
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
|
||||
full_in_interchange: 'OFFLOADED', // export — GL DJ
|
||||
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
|
||||
@@ -37,6 +53,7 @@ export class GlOperationsService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly billingService: BillingService,
|
||||
) {}
|
||||
|
||||
private get bookings() {
|
||||
@@ -161,6 +178,629 @@ export class GlOperationsService {
|
||||
return { uploaded: files.length, completedMilestones };
|
||||
}
|
||||
|
||||
/** Wagon-allocation + train-schedule actuals for a booking (both directions). */
|
||||
async trainState(bookingId: string): Promise<Freight.ClearanceTrainState> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
|
||||
const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||||
const wagonAllocated =
|
||||
wagonMilestone?.status === 'COMPLETED' ||
|
||||
booking.schedulingStatus === 'SCHEDULED' ||
|
||||
booking.schedulingStatus === 'DISPATCHED' ||
|
||||
Boolean(booking.trainScheduleId);
|
||||
|
||||
let schedule: TrainSchedule | null = null;
|
||||
if (booking.trainScheduleId) {
|
||||
schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: booking.trainScheduleId } });
|
||||
}
|
||||
|
||||
return {
|
||||
wagonAllocated,
|
||||
departedAt: schedule?.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
arrivedAt: schedule?.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
||||
* allocation opens the upload window; train departure locks it; train arrival
|
||||
* lets GL Ethiopia close (accept) the T1 set.
|
||||
*/
|
||||
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
|
||||
const train = await this.trainState(bookingId);
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
|
||||
const closedMilestone = milestones.find(
|
||||
(m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
|
||||
);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
wagonAllocated: train.wagonAllocated,
|
||||
trainDepartedAt: train.departedAt,
|
||||
trainArrivedAt: train.arrivedAt,
|
||||
closed: Boolean(closedMilestone),
|
||||
closedAt: closedMilestone?.triggeredAt
|
||||
? new Date(closedMilestone.triggeredAt).toISOString()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
|
||||
* Replaces the previous batch; locked once the train departs or T1 is closed.
|
||||
*/
|
||||
async uploadT1Documents(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ uploaded: number }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('T1 transport documents apply to import shipments only.');
|
||||
}
|
||||
|
||||
const state = await this.t1State(bookingId);
|
||||
if (!state.wagonAllocated) {
|
||||
throw new BadRequestException(
|
||||
'Wagons must be allocated before T1 transport documents can be uploaded.',
|
||||
);
|
||||
}
|
||||
if (state.closed) {
|
||||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||||
}
|
||||
if (state.trainDepartedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has departed — T1 transport documents can no longer be changed.',
|
||||
);
|
||||
}
|
||||
|
||||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||||
return { uploaded: files.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close (accept) the T1/transport document set.
|
||||
* Import: GL Ethiopia closes once the train has arrived (T1 files required).
|
||||
* Export: GL Djibouti closes after the gate pass (transport document required).
|
||||
*/
|
||||
async closeT1(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceT1State> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
|
||||
const state = await this.t1State(bookingId);
|
||||
if (state.closed) return state;
|
||||
|
||||
if (tradeDirection === 'IMPORT') {
|
||||
if (!state.trainArrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has not arrived yet — T1 can be closed only after arrival.',
|
||||
);
|
||||
}
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
|
||||
if (!hasT1) {
|
||||
throw new BadRequestException(
|
||||
'No T1 transport documents on file — GL Djibouti must upload them first.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const done = (code: string) =>
|
||||
milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED';
|
||||
if (!done('EXPORT_TRANSPORT_ISSUED')) {
|
||||
throw new BadRequestException(
|
||||
'The transport document must be uploaded before T1 can be closed.',
|
||||
);
|
||||
}
|
||||
if (!done('GATEPASS_GRANTED')) {
|
||||
throw new BadRequestException('Grant the gate pass before closing T1.');
|
||||
}
|
||||
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||||
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||||
}
|
||||
|
||||
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
|
||||
return this.t1State(bookingId);
|
||||
}
|
||||
|
||||
/** Milestones GL DJ implicitly confirms when granting an export gate pass. */
|
||||
private static readonly EXPORT_ARRIVAL_CHAIN = [
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DEPARTED_TO_DJIBOUTI',
|
||||
'ARRIVED_AT_DJIBOUTI',
|
||||
];
|
||||
|
||||
/**
|
||||
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
|
||||
* Export: requires the train to have arrived at Djibouti; back-fills the
|
||||
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
|
||||
*/
|
||||
async grantGatepass(
|
||||
bookingId: string,
|
||||
gatepassAt?: string,
|
||||
userId?: string,
|
||||
): Promise<{ bookingId: string; gatepassAt: string }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Gate pass applies to customs bookings only.');
|
||||
}
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
|
||||
const existing = byCode.get('GATEPASS_GRANTED');
|
||||
if (existing?.status === 'COMPLETED') {
|
||||
return {
|
||||
bookingId,
|
||||
gatepassAt:
|
||||
existing.metadata?.gatepassAt ??
|
||||
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
|
||||
};
|
||||
}
|
||||
|
||||
const train = await this.trainState(bookingId);
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!train.arrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
|
||||
);
|
||||
}
|
||||
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||||
if (byCode.get(code)?.status === 'PENDING') {
|
||||
await this.milestoneService.completeForBooking(bookingId, code, userId);
|
||||
}
|
||||
}
|
||||
} else if (!train.wagonAllocated) {
|
||||
throw new BadRequestException(
|
||||
'Wagons must be allocated before the gate pass can be granted.',
|
||||
);
|
||||
}
|
||||
|
||||
const at = gatepassAt?.trim() || new Date().toISOString();
|
||||
await this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
'GATEPASS_GRANTED',
|
||||
{ gatepassAt: at },
|
||||
userId,
|
||||
);
|
||||
return { bookingId, gatepassAt: at };
|
||||
}
|
||||
|
||||
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
|
||||
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
|
||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
relations: {
|
||||
scheduleBookings: { booking: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
},
|
||||
order: { scheduledDepartureDate: 'DESC' },
|
||||
});
|
||||
|
||||
const withCustoms = schedules
|
||||
.filter((s) => s.status !== 'CANCELLED')
|
||||
.map((s) => ({
|
||||
schedule: s,
|
||||
customs: (s.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
|
||||
}))
|
||||
.filter((s) => s.customs.length > 0);
|
||||
|
||||
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
|
||||
const gatepassRows = bookingIds.length
|
||||
? await this.dataSource.getRepository(ClearanceMilestone).find({
|
||||
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
|
||||
})
|
||||
: [];
|
||||
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
|
||||
|
||||
return withCustoms.map(({ schedule, customs }) => {
|
||||
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
|
||||
return {
|
||||
id: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
status: schedule.status,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate
|
||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
||||
: null,
|
||||
actualDepartureAt: schedule.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
actualArrivalAt: schedule.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
freightType:
|
||||
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
|
||||
customsBookings: customs.map((b) => {
|
||||
const m = gatepassByBooking.get(b.id);
|
||||
const granted = m?.status === 'COMPLETED';
|
||||
return {
|
||||
bookingId: b.id,
|
||||
reference: b.reference ?? b.id,
|
||||
tradeDirection: b.tradeDirection ?? 'IMPORT',
|
||||
contractId: b.contractId ?? null,
|
||||
gatepassGranted: granted,
|
||||
gatepassAt: granted
|
||||
? (m?.metadata?.gatepassAt ??
|
||||
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click gate pass for every customs booking on a train schedule. Per-booking
|
||||
* guard failures are collected, not fatal. Import schedules also get the
|
||||
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
|
||||
*/
|
||||
async grantScheduleGatepass(
|
||||
scheduleId: string,
|
||||
gatepassAt?: string,
|
||||
userId?: string,
|
||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
|
||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id: scheduleId },
|
||||
relations: { scheduleBookings: { booking: true } },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
|
||||
const customs = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
|
||||
if (customs.length === 0) {
|
||||
throw new BadRequestException('No customs bookings ride this schedule.');
|
||||
}
|
||||
|
||||
let granted = 0;
|
||||
const skipped: Array<{ bookingId: string; error: string }> = [];
|
||||
for (const booking of customs) {
|
||||
try {
|
||||
await this.grantGatepass(booking.id, gatepassAt, userId);
|
||||
granted += 1;
|
||||
} catch (e) {
|
||||
skipped.push({
|
||||
bookingId: booking.id,
|
||||
error: e instanceof Error ? e.message : 'Failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
|
||||
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
if (!operation) {
|
||||
operation = opRepo.create({ trainScheduleId: scheduleId });
|
||||
}
|
||||
if (!operation.gatepassGrantedAt) {
|
||||
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
|
||||
await opRepo.save(operation);
|
||||
}
|
||||
}
|
||||
|
||||
return { granted, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||
* attached invoice document. The customer pays offline and attaches a slip;
|
||||
* GL (ET or DJ) then confirms to settle it.
|
||||
*/
|
||||
async createFinalInvoice(
|
||||
bookingId: string,
|
||||
input: { amount: number; currency: string; description?: string },
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Final invoice applies to customs bookings only.');
|
||||
}
|
||||
if (!(input.amount > 0)) {
|
||||
throw new BadRequestException('Invoice amount must be greater than zero.');
|
||||
}
|
||||
if (!file) throw new BadRequestException('Attach the invoice document.');
|
||||
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const offloaded = milestones.find(
|
||||
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!offloaded) {
|
||||
throw new BadRequestException(
|
||||
'Cargo must be offloaded before the final invoice can be raised.',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (
|
||||
existing &&
|
||||
existing.status !== Freight.InvoiceStatus.Cancelled &&
|
||||
existing.status !== Freight.InvoiceStatus.Expired
|
||||
) {
|
||||
throw new ConflictException('A final invoice already exists for this shipment.');
|
||||
}
|
||||
|
||||
const description = input.description?.trim() || 'Post-offload charges (Djibouti)';
|
||||
await this.billingService.generateInvoice({
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: bookingId,
|
||||
type: GL_FINAL_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: input.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: GL_FINAL_INVOICE_TYPE,
|
||||
description,
|
||||
quantity: 1,
|
||||
unitRate: input.amount,
|
||||
amount: input.amount,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'final_invoice',
|
||||
file,
|
||||
});
|
||||
|
||||
// Export clearance is administratively done once the final invoice goes out.
|
||||
await this.dataSource
|
||||
.getRepository(ContractClearanceCycle)
|
||||
.update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() });
|
||||
|
||||
void userId;
|
||||
const summary = await this.finalInvoiceSummary(bookingId);
|
||||
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Customer attaches the payment slip for the final invoice. */
|
||||
async uploadFinalInvoiceSlip(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<{ uploaded: boolean }> {
|
||||
await this.getBooking(bookingId);
|
||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('The final invoice is already paid.');
|
||||
}
|
||||
if (
|
||||
invoice.status === Freight.InvoiceStatus.Cancelled ||
|
||||
invoice.status === Freight.InvoiceStatus.Expired
|
||||
) {
|
||||
throw new BadRequestException('The final invoice is no longer payable.');
|
||||
}
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'final_invoice_slip',
|
||||
file,
|
||||
});
|
||||
return { uploaded: true };
|
||||
}
|
||||
|
||||
/** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */
|
||||
async confirmFinalInvoicePaid(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||
await this.getBooking(bookingId);
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||
}
|
||||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
if (!files.some((f) => f.code === 'final_invoice_slip')) {
|
||||
throw new BadRequestException(
|
||||
'The customer has not attached a payment slip yet.',
|
||||
);
|
||||
}
|
||||
await this.billingService.markInvoiceAsPaid(invoice.id);
|
||||
}
|
||||
|
||||
void userId;
|
||||
const summary = await this.finalInvoiceSummary(bookingId);
|
||||
if (!summary) throw new NotFoundException('Final invoice not found.');
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET advises (or skips) the post-arrival additional duty/tax round (import).
|
||||
* Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload.
|
||||
*/
|
||||
async adviseSecondDuty(
|
||||
bookingId: string,
|
||||
input: {
|
||||
dutyRequired: boolean;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
declarationSerial?: string;
|
||||
},
|
||||
attachment?: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<{ advised: boolean; skipped: boolean }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Additional duty applies to customs bookings only.');
|
||||
}
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
if (tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Additional duty applies to import shipments only.');
|
||||
}
|
||||
|
||||
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection);
|
||||
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection);
|
||||
|
||||
if (!input.dutyRequired) {
|
||||
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED');
|
||||
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||
return { advised: false, skipped: true };
|
||||
}
|
||||
|
||||
if (!input.amount || input.amount <= 0) {
|
||||
throw new BadRequestException('Duty amount must be greater than zero.');
|
||||
}
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2');
|
||||
if (!attachment && !hasNotice) {
|
||||
throw new BadRequestException('Attach the additional duty/tax notice.');
|
||||
}
|
||||
if (attachment) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_notice_2',
|
||||
file: attachment,
|
||||
});
|
||||
}
|
||||
|
||||
await this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
'SECOND_DUTY_ADVISED',
|
||||
{
|
||||
dutyAmount: input.amount,
|
||||
dutyCurrency: input.currency ?? 'ETB',
|
||||
declarationSerial: input.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
return { advised: true, skipped: false };
|
||||
}
|
||||
|
||||
/** Customer attaches the payment slip for the additional duty round. */
|
||||
async uploadSecondDutySlip(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<{ milestoneCompleted: boolean }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||||
if (advised?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException('No additional duty has been advised for this shipment.');
|
||||
}
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_receipt_2',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.milestoneService.ensureForBooking(
|
||||
bookingId,
|
||||
'SECOND_DUTY_PAID',
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||
return { milestoneCompleted: true };
|
||||
}
|
||||
|
||||
/** Second duty round state for clearance views. */
|
||||
secondDutyState(
|
||||
milestones: Array<{
|
||||
milestoneCode: string;
|
||||
status: string;
|
||||
metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null;
|
||||
}>,
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
): Freight.ClearanceSecondDuty | null {
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||||
const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID');
|
||||
if (!advised && !paid) return null;
|
||||
|
||||
const toRef = (code: string) => {
|
||||
const f = files.find((x) => x.code === code);
|
||||
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||||
};
|
||||
|
||||
return {
|
||||
advised: advised?.status === 'COMPLETED',
|
||||
skipped: advised?.status === 'SKIPPED',
|
||||
amount: advised?.metadata?.dutyAmount ?? null,
|
||||
currency: advised?.metadata?.dutyCurrency ?? null,
|
||||
declarationSerial: advised?.metadata?.declarationSerial ?? null,
|
||||
noticeFile: toRef('duty_tax_notice_2'),
|
||||
slipFile: toRef('duty_tax_receipt_2'),
|
||||
paid: paid?.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
/** Final-invoice state joined with its document + slip files, for clearance views. */
|
||||
async finalInvoiceSummary(
|
||||
bookingId: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) return null;
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const toRef = (code: string) => {
|
||||
const f = files.find((x) => x.code === code);
|
||||
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||||
};
|
||||
const line = await this.dataSource
|
||||
.getRepository(InvoiceLine)
|
||||
.findOne({ where: { invoiceId: invoice.id } });
|
||||
|
||||
return {
|
||||
id: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
status: invoice.status,
|
||||
totalAmount: Number(invoice.totalAmount),
|
||||
currency: invoice.currency,
|
||||
description: line?.description ?? null,
|
||||
invoiceFile: toRef('final_invoice'),
|
||||
slipFile: toRef('final_invoice_slip'),
|
||||
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||||
*/
|
||||
|
||||
@@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes import contracts still on Ethiopia-side clearance only', () => {
|
||||
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
|
||||
it('keeps import contracts from the start — DO upload is un-gated', () => {
|
||||
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes export contracts with no DJ activity or RO hold', () => {
|
||||
expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
isDeclarationFileCode,
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
isT1TransportFileCode,
|
||||
exportTransportFileLabel,
|
||||
t1TransportFileLabel,
|
||||
transitPermitFileLabel,
|
||||
type ClearanceWorkflowFile,
|
||||
} from '@edr/types';
|
||||
@@ -160,6 +162,50 @@ export async function persistExportTransportUploads(
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one T1 transport document in the upload batch. */
|
||||
export function assertT1TransportFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No T1 transport documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeT1TransportFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `t1_transport_document_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all T1 transport documents on a booking with a new multi-file batch. */
|
||||
export async function persistT1TransportUploads(
|
||||
store: DeclarationFileStore,
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeT1TransportFieldNames(files);
|
||||
assertT1TransportFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(bookingId, 'bookings');
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isT1TransportFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: `t1_transport_document_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === undefined || value === '') return false;
|
||||
@@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue(
|
||||
);
|
||||
if (hasDjActivity) return true;
|
||||
|
||||
const preFinalized =
|
||||
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
|
||||
if (tradeDirection === 'IMPORT' && preFinalized) return true;
|
||||
// Import DO upload is un-gated — Djibouti GL must see import customs items from
|
||||
// the start, not only after Ethiopia finalizes pre-clearance.
|
||||
if (tradeDirection === 'IMPORT') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -295,6 +341,22 @@ export function buildWorkflowFiles(
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
const extraT1 = files
|
||||
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraT1.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: t1TransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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';
|
||||
@@ -13,6 +13,12 @@ export class DriversService {
|
||||
) {}
|
||||
|
||||
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,6 +39,17 @@ 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);
|
||||
}
|
||||
@@ -48,12 +65,10 @@ export class DriversService {
|
||||
const qb = this.driverRepo.createQueryBuilder('d');
|
||||
|
||||
if (query.search) {
|
||||
const searchTerm = `%${query.search}%`;
|
||||
qb.where('d.firstName ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.lastName ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.email ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.licenseNumber ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.phoneNumber ILIKE :search', { search: searchTerm });
|
||||
qb.where(
|
||||
'(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
@@ -108,7 +123,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,12 @@ export class FirstMileController {
|
||||
return this.firstMileService.findById(id);
|
||||
}
|
||||
|
||||
@Get('acceptitem/:id')
|
||||
@ApiOperation({ summary: 'Get a first-mile accep by ID' })
|
||||
acceptItem(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.firstMileService.acceptBooking(id);
|
||||
}
|
||||
|
||||
@Post('accept/:reference')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere, In } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } 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 { 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 { FirstMileRepository } from './first-mile.repository';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
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 { 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 { FirstMileRepository } from "./first-mile.repository";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
||||
|
||||
type FirstMileListFilter = {
|
||||
status?: FirstMileStatus;
|
||||
@@ -26,10 +26,10 @@ type FirstMileListFilter = {
|
||||
};
|
||||
|
||||
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
||||
'status',
|
||||
'advancedPayment',
|
||||
'remainingPayment',
|
||||
'createdAt',
|
||||
"status",
|
||||
"advancedPayment",
|
||||
"remainingPayment",
|
||||
"createdAt",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
@@ -43,26 +43,28 @@ export class FirstMileService {
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 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
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile> {
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||
relations: { serviceType: true },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
|
||||
async acceptBookingByReference(
|
||||
bookingReference: string,
|
||||
): Promise<FirstMile | null> {
|
||||
const [booking] = await this.bookingsRepository.findAll({
|
||||
where: { reference: bookingReference },
|
||||
relations: { serviceType: true },
|
||||
@@ -89,20 +91,17 @@ export class FirstMileService {
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): Promise<FirstMile> {
|
||||
const label = booking.reference ?? booking.id;
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(`Booking ${label} is not paid`);
|
||||
}): Promise<FirstMile | null> {
|
||||
if (booking.paymentStatus !== "PAID") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
throw new BadRequestException(`Booking ${label} does not require a first mile`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = await this.findByBookingId(booking.id);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -118,8 +117,9 @@ export class FirstMileService {
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile)
|
||||
? (filter.sortBy as keyof FirstMile)
|
||||
: 'createdAt';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||
: "createdAt";
|
||||
const sortOrder =
|
||||
filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC";
|
||||
|
||||
const where: FindOptionsWhere<FirstMile> = {};
|
||||
if (filter.status) where.status = filter.status;
|
||||
@@ -129,7 +129,13 @@ export class FirstMileService {
|
||||
const [data, total] = await this.firstMileRepository.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,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
order: { [sortBy]: sortOrder },
|
||||
@@ -151,8 +157,12 @@ export class FirstMileService {
|
||||
@OnEvent("firstmile.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
try {
|
||||
await this.firstMileRepository.update(payload.sourceId, { paid: true } as any);
|
||||
this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
|
||||
await this.firstMileRepository.update(payload.sourceId, {
|
||||
paid: true,
|
||||
} as any);
|
||||
this.logger.log(
|
||||
`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
|
||||
@@ -163,7 +173,13 @@ export class FirstMileService {
|
||||
async findById(id: string): Promise<FirstMile> {
|
||||
const record = await this.firstMileRepository.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,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
@@ -181,9 +197,9 @@ export class FirstMileService {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return this.firstMileRepository.create({
|
||||
const record = await this.firstMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
status: dto.status ?? "READY_TO_TRANSIT",
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
@@ -191,13 +207,25 @@ export class FirstMileService {
|
||||
vehicleId: dto.vehicleId ?? null,
|
||||
paid: (dto as any).paid ?? false,
|
||||
});
|
||||
|
||||
if (dto.vehicleId) {
|
||||
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
|
||||
const [records] = await this.firstMileRepository.findAndCount({
|
||||
where: { bookingId },
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
||||
booking: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
cargoType: true,
|
||||
},
|
||||
vehicle: true,
|
||||
},
|
||||
take: 1,
|
||||
@@ -210,12 +238,10 @@ export class FirstMileService {
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): boolean {
|
||||
// Export bookings always need a first mile (pickup → origin yard); the
|
||||
// pickup address is captured at assignment time, not required upfront.
|
||||
return Boolean(
|
||||
booking.tradeDirection === 'EXPORT' ||
|
||||
booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile,
|
||||
booking.tradeDirection === 'EXPORT' &&
|
||||
(booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,9 +252,15 @@ export class FirstMileService {
|
||||
const updated = await this.firstMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
|
||||
...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
|
||||
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
|
||||
...(dto.advancedPayment !== undefined
|
||||
? { advancedPayment: dto.advancedPayment }
|
||||
: {}),
|
||||
...(dto.remainingPayment !== undefined
|
||||
? { remainingPayment: dto.remainingPayment }
|
||||
: {}),
|
||||
...(dto.estimatedKm !== undefined
|
||||
? { estimatedKm: dto.estimatedKm }
|
||||
: {}),
|
||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||
@@ -238,55 +270,115 @@ export class FirstMileService {
|
||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||
}
|
||||
|
||||
// Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE
|
||||
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
|
||||
if (dto.vehicleId) {
|
||||
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
||||
}
|
||||
if (existing.vehicleId) {
|
||||
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||
if (dto.vehicleId) {
|
||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||
}
|
||||
|
||||
// Trip finished — release the vehicles it was holding
|
||||
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||
await this.releaseVehicles(updated);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
|
||||
const existing = await this.findById(id);
|
||||
const updated = await this.firstMileRepository.update(id, { status });
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||
}
|
||||
|
||||
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||
await this.releaseVehicles(updated);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free every vehicle held by this record (direct assignment + container
|
||||
* 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);
|
||||
}
|
||||
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||
}
|
||||
|
||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
if (!vehicle.assignedDriverId) {
|
||||
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Vehicle ${vehicleId} has no assigned driver — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||
const driver = await this.driversService.findById(
|
||||
vehicle.assignedDriverId,
|
||||
);
|
||||
if (!driver.phoneNumber) {
|
||||
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||
this.logger.warn(
|
||||
`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||
const booking = (
|
||||
record as FirstMile & {
|
||||
booking?: {
|
||||
reference?: string;
|
||||
firstMilePickupAddress?: string | null;
|
||||
originYard?: { label?: string } | null;
|
||||
};
|
||||
}
|
||||
).booking;
|
||||
|
||||
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||
const driverName =
|
||||
`${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim();
|
||||
const message =
|
||||
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
|
||||
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
|
||||
(booking?.firstMilePickupAddress
|
||||
? `Pickup: ${booking.firstMilePickupAddress}. `
|
||||
: "") +
|
||||
(booking?.originYard?.label
|
||||
? `Destination: ${booking.originYard.label}.`
|
||||
: "");
|
||||
|
||||
void this.smsClient.sendSms({
|
||||
to: driver.phoneNumber,
|
||||
message,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
this.logger.log(
|
||||
`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
this.logger.error(
|
||||
`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +396,16 @@ export class FirstMileService {
|
||||
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const allocation of allocations) {
|
||||
await manager.delete(FirstMileContainerAllocation, {
|
||||
@@ -314,12 +416,20 @@ export class FirstMileService {
|
||||
firstMileId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
containerType: "CONTAINER",
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export class LastMileService {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: 0,
|
||||
@@ -131,12 +132,6 @@ export class LastMileService {
|
||||
}
|
||||
|
||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||
const [existing] = await this.lastMileRepository.findAll({
|
||||
where: { bookingId: dto.bookingId },
|
||||
take: 1,
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
return this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class SendEmailDto {
|
||||
@ApiProperty({
|
||||
description: "Recipient email address",
|
||||
example: "customer@example.com",
|
||||
})
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
to!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Email subject",
|
||||
example: "Your EDR Freight verification code",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
subject!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
html?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from "@nestjs/common";
|
||||
import { ClientProxy } from "@nestjs/microservices";
|
||||
import { SendEmailDto } from "./dtos/email.dto";
|
||||
|
||||
@Injectable()
|
||||
export class EmailClientService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(EmailClientService.name);
|
||||
|
||||
constructor(
|
||||
@Inject("EMAIL_SERVICE")
|
||||
private readonly emailClient: ClientProxy,
|
||||
) {}
|
||||
|
||||
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.enabled) return;
|
||||
this.emailClient
|
||||
.connect()
|
||||
.then(() => this.logger.log("connected to Email service"))
|
||||
.catch((err) => {
|
||||
console.error("Error happened at Email service", err);
|
||||
});
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.emailClient.emit("send-email", {
|
||||
to: dto.to,
|
||||
subject: dto.subject,
|
||||
text: dto.text,
|
||||
html: dto.html,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
);
|
||||
// Recipient + content are PII — debug only.
|
||||
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
|
||||
return { queued: true };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices";
|
||||
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { SmsClientService } from "./sms-client.service";
|
||||
import { EmailClientService } from "./email-client.service";
|
||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||
|
||||
@@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EMAIL_SERVICE",
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [process.env.RABBITMQ_URL as string],
|
||||
queue: process.env.EMAIL_QUEUE ?? "email_queue",
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [],
|
||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
|
||||
exports: [NotificationsService, SmsClientService],
|
||||
providers: [
|
||||
EmailNotificationStrategy,
|
||||
SmsNotificationStrategy,
|
||||
NotificationsService,
|
||||
SmsClientService,
|
||||
EmailClientService,
|
||||
],
|
||||
exports: [NotificationsService, SmsClientService, EmailClientService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// otp.controller.ts
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
|
||||
|
||||
import { OtpService } from "./otp.service";
|
||||
import { OtpService, OtpTarget } from "./otp.service";
|
||||
import { Public } from "@edr/api-common";
|
||||
|
||||
// Exactly one of phone/email must be present per request — the channel the
|
||||
// code is sent through / checked against.
|
||||
function toTarget(phone?: string, email?: string): OtpTarget {
|
||||
if (email) return { email };
|
||||
if (phone) return { phone };
|
||||
throw new BadRequestException("phone or email is required");
|
||||
}
|
||||
|
||||
@Controller("otp")
|
||||
@Public()
|
||||
export class OtpController {
|
||||
@@ -24,9 +33,12 @@ export class OtpController {
|
||||
@Post("send")
|
||||
async sendOtp(
|
||||
@Body("phone")
|
||||
phone: string
|
||||
phone?: string,
|
||||
|
||||
@Body("email")
|
||||
email?: string
|
||||
) {
|
||||
return this.otpService.sendOtp(phone);
|
||||
return this.otpService.sendOtp(toTarget(phone, email));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -36,13 +48,16 @@ export class OtpController {
|
||||
@Post("verify")
|
||||
async verifyOtp(
|
||||
@Body("phone")
|
||||
phone: string,
|
||||
phone: string | undefined,
|
||||
|
||||
@Body("email")
|
||||
email: string | undefined,
|
||||
|
||||
@Body("otp")
|
||||
otp: string
|
||||
) {
|
||||
return this.otpService.verifyOtp(
|
||||
phone,
|
||||
toTarget(phone, email),
|
||||
otp
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common";
|
||||
name: "otp_verifications",
|
||||
})
|
||||
export class OtpVerification extends BaseEntity{
|
||||
// Exactly one of phone/email is set per row — the channel the code was sent
|
||||
// through.
|
||||
@Column({
|
||||
unique: true,
|
||||
nullable: true,
|
||||
})
|
||||
phone!: string;
|
||||
phone?: string;
|
||||
|
||||
@Column({
|
||||
unique: true,
|
||||
nullable: true,
|
||||
})
|
||||
email?: string;
|
||||
|
||||
@Column()
|
||||
otp!: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
exports: [
|
||||
OtpRepository,
|
||||
OtpService,
|
||||
],
|
||||
})
|
||||
export class OtpModule {}
|
||||
@@ -31,17 +31,44 @@ export class OtpRepository {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find By Email
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async findByEmail(
|
||||
email: string
|
||||
) {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find By Target (either channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async findByTarget(
|
||||
target: { phone?: string; email?: string }
|
||||
) {
|
||||
return target.email
|
||||
? this.findByEmail(target.email)
|
||||
: this.findByPhone(target.phone!);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createOtp(
|
||||
phone: string,
|
||||
target: { phone?: string; email?: string },
|
||||
otp: string
|
||||
) {
|
||||
const entity =
|
||||
this.repository.create({
|
||||
phone,
|
||||
phone: target.phone,
|
||||
email: target.email,
|
||||
otp,
|
||||
verified: false,
|
||||
});
|
||||
@@ -70,10 +97,10 @@ export class OtpRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify Phone
|
||||
// Mark Verified
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async verifyPhone(
|
||||
async markVerified(
|
||||
otpVerification: OtpVerification
|
||||
) {
|
||||
otpVerification.verified =
|
||||
@@ -83,4 +110,18 @@ export class OtpRepository {
|
||||
otpVerification
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete OTP (single-use consume)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hard delete so the unique `phone` row is freed and a fresh code can be
|
||||
// requested for the same number on the next action.
|
||||
async deleteOtp(
|
||||
otpVerification: OtpVerification
|
||||
) {
|
||||
return this.repository.remove(
|
||||
otpVerification
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,80 @@
|
||||
// otp.service.ts
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
import { OtpRepository } from "./otp.repository";
|
||||
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
import { EmailClientService } from "../notifications/email-client.service";
|
||||
|
||||
// Exactly one of phone/email is set — enforced by the controller before it
|
||||
// reaches here.
|
||||
export type OtpTarget = { phone?: string; email?: string };
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
logger = new Logger(OtpService.name);
|
||||
constructor(
|
||||
private readonly otpRepository: OtpRepository,
|
||||
private readonly smsClient: SmsClientService
|
||||
) {}
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly emailClient: EmailClientService,
|
||||
) { }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generate OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generateOtp(): string {
|
||||
return Math.floor(
|
||||
100000 + Math.random() * 900000
|
||||
).toString();
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendOtp(phone: string) {
|
||||
async sendOtp(target: OtpTarget) {
|
||||
try {
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
// recipient of the SMS.
|
||||
// recipient of the SMS/email.
|
||||
const otp = this.generateOtp();
|
||||
|
||||
// find existing phone
|
||||
const existingPhone =
|
||||
await this.otpRepository.findByPhone(
|
||||
phone
|
||||
);
|
||||
// find existing row for this channel
|
||||
const existing = await this.otpRepository.findByTarget(target);
|
||||
|
||||
// update existing otp
|
||||
if (existingPhone) {
|
||||
await this.otpRepository.updateOtp(
|
||||
existingPhone,
|
||||
otp
|
||||
);
|
||||
if (existing) {
|
||||
await this.otpRepository.updateOtp(existing, otp);
|
||||
} else {
|
||||
// create new otp
|
||||
await this.otpRepository.createOtp(
|
||||
phone,
|
||||
otp
|
||||
);
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
|
||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||
await this.smsClient.sendSms({
|
||||
to: phone,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
await this.emailClient.sendEmail({
|
||||
to: target.email,
|
||||
subject: "Your EDR Freight verification code",
|
||||
text: `Your verification code is ${otp}`,
|
||||
});
|
||||
} else {
|
||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||
await this.smsClient.sendSms({
|
||||
to: target.phone as string,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
|
||||
return {
|
||||
success: true,
|
||||
|
||||
message:
|
||||
"OTP sent successfully",
|
||||
message: "OTP sent successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Failed to send OTP"
|
||||
);
|
||||
throw new BadRequestException("Failed to send OTP");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,40 +82,70 @@ export class OtpService {
|
||||
// Verify OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async verifyOtp(
|
||||
phone: string,
|
||||
otp: string
|
||||
) {
|
||||
// find phone
|
||||
const otpData =
|
||||
await this.otpRepository.findByPhone(
|
||||
phone
|
||||
);
|
||||
async verifyOtp(target: OtpTarget, otp: string) {
|
||||
// find the channel's row
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
|
||||
// phone not found
|
||||
// not found
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
"Phone number not found"
|
||||
target.email ? "Email address not found" : "Phone number not found",
|
||||
);
|
||||
}
|
||||
|
||||
// invalid otp
|
||||
if (otpData.otp !== otp) {
|
||||
throw new BadRequestException(
|
||||
"Invalid OTP"
|
||||
);
|
||||
throw new BadRequestException("Invalid OTP");
|
||||
}
|
||||
|
||||
// verify phone
|
||||
await this.otpRepository.verifyPhone(
|
||||
otpData
|
||||
);
|
||||
// mark verified
|
||||
await this.otpRepository.markVerified(otpData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
message:
|
||||
"Phone verified successfully",
|
||||
message: target.email
|
||||
? "Email verified successfully"
|
||||
: "Phone verified successfully",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify OTP for a sensitive action (sudo mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
|
||||
// contract signature). Unlike verifyOtp above — which marks a phone verified
|
||||
// and leaves the code in place — this enforces a short TTL and consumes the
|
||||
// code on success so it can never be replayed.
|
||||
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
async verifyOtpForAction(phone: string, otp: string) {
|
||||
const otpData = await this.otpRepository.findByPhone(phone);
|
||||
|
||||
if (!otpData) {
|
||||
throw new BadRequestException(
|
||||
"No verification code was requested for this phone",
|
||||
);
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
}
|
||||
|
||||
if (otpData.otp !== otp) {
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
|
||||
// single-use: consume on success
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Post,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
@@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service";
|
||||
@Public()
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
private readonly logger = new Logger(InternalPaymentController.name);
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
|
||||
})
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
|
||||
})
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
this.logger.log(`Marking payment ${event} as PAID`);
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,9 +93,8 @@ export class PaymentRepository {
|
||||
p.paid_at,
|
||||
p.created_at
|
||||
FROM freight.payments p
|
||||
JOIN freight.bookings b ON b.id = p.ref_id
|
||||
JOIN freight.bookings b ON b.id = p.ref_id::uuid
|
||||
WHERE b.company_id = $1
|
||||
AND p.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC`,
|
||||
[companyId],
|
||||
|
||||
@@ -189,48 +189,53 @@ export class PaymentService {
|
||||
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
try {
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
payerAccount: input.payerAccount,
|
||||
returnUrl:
|
||||
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||
failureUrl:
|
||||
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||
});
|
||||
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
const immediateSuccess =
|
||||
snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
const intent = await this.upsertIntent(input, snapshot);
|
||||
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
if (immediateSuccess) {
|
||||
// Settle the projection but DO NOT notify billing — billing settles
|
||||
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||
// avoiding a settle-before-correlation race.
|
||||
await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
notify: false,
|
||||
});
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
intentId: intent.id,
|
||||
// `intent` still reflects the projection status ("processing" on immediate
|
||||
// success — settlement is applied by the caller, not shown synchronously).
|
||||
response: this.formatIntentResponse(intent),
|
||||
immediateSuccess,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create or update the local intent projection from a provider snapshot. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
import { IsIn, IsNumber, IsUUID, Min } from 'class-validator';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
|
||||
@@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto {
|
||||
@Min(0)
|
||||
@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' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -19,10 +18,4 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { 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 +30,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 +44,51 @@ 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new weight limit rule. */
|
||||
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
|
||||
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,34 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
/**
|
||||
* Booking-window lifecycle for the one-booking-day cycle
|
||||
* (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE).
|
||||
* NULL on legacy and DOMESTIC schedules — the window engine ignores those.
|
||||
*/
|
||||
@Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true })
|
||||
windowPhase?: string | null;
|
||||
|
||||
@Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true })
|
||||
windowOpensAt?: Date | null;
|
||||
|
||||
@Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true })
|
||||
windowClosesAt?: Date | null;
|
||||
|
||||
@Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true })
|
||||
docReviewEndsAt?: Date | null;
|
||||
|
||||
/** Staff finished document review early — starts the batch/payment phase immediately. */
|
||||
@Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true })
|
||||
docReviewCompletedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true })
|
||||
paymentPhaseEndsAt?: Date | null;
|
||||
|
||||
/** 1-based count of open→settle cycles run on the booking day. */
|
||||
@Column({ name: 'booking_cycle_no', type: 'int', default: 0 })
|
||||
bookingCycleNo!: number;
|
||||
|
||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||
scheduleBookings?: TrainScheduleBooking[];
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:00–06: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:00–03: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:00–24: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 (06–09 slot)
|
||||
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 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:00–11: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 → 06–09 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,6 +121,66 @@ function windowFromEatStart(
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */
|
||||
export function eatDayToUtc(day: string, hour: number, minute = 0): Date {
|
||||
const [year, month, dayOfMonth] = day.split('-').map(Number);
|
||||
return eatToUtc(year, month, dayOfMonth, hour, minute);
|
||||
}
|
||||
|
||||
/** Shift a `yyyy-MM-dd` EAT day key by whole days. */
|
||||
export function shiftEatDay(day: string, deltaDays: number): string {
|
||||
// Noon UTC keeps the +3h EAT offset from crossing a day boundary.
|
||||
const [year, month, dayOfMonth] = day.split('-').map(Number);
|
||||
const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12));
|
||||
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String(
|
||||
shifted.getUTCDate(),
|
||||
).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export interface InitialWindowTimes {
|
||||
windowOpensAt: Date;
|
||||
windowClosesAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus
|
||||
* `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its
|
||||
* computed window has fully passed gets a same-day window starting now instead,
|
||||
* capped at departure.
|
||||
*/
|
||||
export function computeImportWindowTimes(
|
||||
departure: Date,
|
||||
cfg: {
|
||||
importWindowLeadDays: number;
|
||||
windowOpenHour: number;
|
||||
windowDurationHours: number;
|
||||
},
|
||||
now: Date,
|
||||
): InitialWindowTimes {
|
||||
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
|
||||
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
|
||||
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||
if (closesAt.getTime() <= now.getTime()) {
|
||||
opensAt = now;
|
||||
closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||
}
|
||||
if (closesAt.getTime() > departure.getTime()) {
|
||||
closesAt = departure;
|
||||
}
|
||||
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
|
||||
}
|
||||
|
||||
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
|
||||
export function computeExportWindowTimes(
|
||||
departure: Date,
|
||||
cfg: { exportBookingLeadHours: number },
|
||||
): InitialWindowTimes {
|
||||
return {
|
||||
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
|
||||
windowClosesAt: departure,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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);
|
||||
@@ -170,14 +230,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 (00–03, 03–06, … 21–24). */
|
||||
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`. */
|
||||
@@ -186,6 +245,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',
|
||||
@@ -197,119 +265,124 @@ 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.
|
||||
*/
|
||||
export function listBoardWindowsForRange(
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
export function listConfigBookingWindows(
|
||||
direction: string | null | undefined,
|
||||
departure: Date,
|
||||
cfg: BoardWindowConfig,
|
||||
): 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 = 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 = 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',
|
||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||||
const windows = listConfigBookingWindows(direction, departure, cfg);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
/**
|
||||
* Tunables for the demand-batching booking → allocation flow.
|
||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
||||
* Times run in EAT so window boundaries match the local operating clock.
|
||||
*
|
||||
* Cadence and pay-window durations moved to the train_scheduling_global_rules
|
||||
* table (TrainSchedulingService.getWindowConfig) — the window engine
|
||||
* (BookingWindowService) drives all timing off that config.
|
||||
*/
|
||||
|
||||
/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */
|
||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
// export const BATCH_CRON = '*/3 * * * *';
|
||||
export const BATCH_CRON = '*/5 * * * *';
|
||||
// export const BATCH_CRON = '0 */3 * * *';//
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
||||
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
getBookableSchedules: jest.Mock;
|
||||
getWindowConfig: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
@@ -77,6 +78,15 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
violations: [],
|
||||
}),
|
||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||
getWindowConfig: jest.fn().mockResolvedValue({
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
windowOpenHour: 8,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 90,
|
||||
}),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
@@ -187,17 +197,24 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
// Two OPEN trains on the same route + day, train A earlier than train B.
|
||||
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
||||
// Two OPEN legacy trains on the same route + day, train A earlier than train B.
|
||||
// fillRouteDay now selects fillable schedules straight from the repository.
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: trainA,
|
||||
scheduleDate: '2026-06-20T06:00:00.000Z',
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: null,
|
||||
},
|
||||
{
|
||||
id: trainB,
|
||||
scheduleDate: '2026-06-20T09:00:00.000Z',
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'),
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowPhase: null,
|
||||
},
|
||||
]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||||
@@ -252,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reserves both partners of a consolidated pair together on one train', async () => {
|
||||
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
|
||||
const consol = (id: string, partnerId: string, priority: number): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
isGovernment: false,
|
||||
priorityScore: priority,
|
||||
status: 'FULLY_EXECUTED',
|
||||
wagonsRequired: 1,
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: partnerId,
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
}) as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
consol('a', 'b', 30),
|
||||
consol('b', 'a', 20),
|
||||
]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
// Both reserved on the same (first) train; neither reported unplaced.
|
||||
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||
expect(reservedIds.sort()).toEqual(['a', 'b']);
|
||||
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
|
||||
const lonely = {
|
||||
id: 'a',
|
||||
reference: 'a',
|
||||
isGovernment: false,
|
||||
priorityScore: 30,
|
||||
status: 'FULLY_EXECUTED',
|
||||
wagonsRequired: 1,
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: 'missing-partner',
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
} as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
// Never reserved — waits for its partner in a later cycle.
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -7,8 +8,8 @@ import {
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
@@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { Freight } from "@edr/types";
|
||||
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_WINDOW_MS,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
@@ -41,6 +39,8 @@ import {
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
@@ -90,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
allocationIssue: string | null;
|
||||
/** Set when this booking shares a wagon with a consolidation partner. */
|
||||
consolidationPartnerId: string | null;
|
||||
consolidationPartnerRef: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
@@ -121,6 +124,13 @@ export interface BatchBoardScheduleDetail {
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
windowPhase: string | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -138,6 +148,13 @@ export interface BatchBoardSchedule {
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
windowPhase: string | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -189,6 +206,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly billing: BillingService,
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
|
||||
) {}
|
||||
|
||||
@@ -284,11 +302,17 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||
/**
|
||||
* Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules —
|
||||
* schedules with a `windowPhase` are driven exclusively by the window engine
|
||||
* (BookingWindowService), never by the periodic legacy fill.
|
||||
*/
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: "OPEN" },
|
||||
});
|
||||
const open = (
|
||||
await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: "OPEN" },
|
||||
})
|
||||
).filter((s) => s.windowPhase == null);
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
for (const s of open) {
|
||||
if (!s.scheduledDepartureDate) continue;
|
||||
@@ -340,6 +364,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.update(bookingId, { paymentStatus: "PAID" });
|
||||
}
|
||||
|
||||
// Paying inside the window accepts an open partial offer — reduce the booking
|
||||
// to the offered part before it boards (remainder returns to the contract cap).
|
||||
if (this.splitService) {
|
||||
await this.splitService.applySplit(bookingId);
|
||||
}
|
||||
|
||||
const linked =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
if (!linked) {
|
||||
@@ -381,6 +411,128 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.ensurePaidBookingAllocated(bookingId);
|
||||
}
|
||||
|
||||
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
|
||||
async getOpenOfferSummary(bookingId: string): Promise<{
|
||||
offeredWagons: number;
|
||||
totalWagons: number;
|
||||
offeredAmount: number;
|
||||
paymentDeadline: Date;
|
||||
} | null> {
|
||||
if (!this.splitService) return null;
|
||||
const offer = await this.splitService.findOpenOffer(bookingId);
|
||||
if (!offer) return null;
|
||||
return {
|
||||
offeredWagons: offer.offeredWagons,
|
||||
totalWagons: offer.totalWagons,
|
||||
offeredAmount: Number(offer.offeredAmount),
|
||||
paymentDeadline: offer.paymentDeadline,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- export FCFS -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Export is first-come-first-serve: no window cycle, no priority, no batch.
|
||||
* Pick the earliest open export train on the booking's corridor/day that still
|
||||
* fits the booking. Throws ConflictException when every train is full — the
|
||||
* staff accept fails and no more export bookings are taken.
|
||||
*/
|
||||
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
||||
if (!booking.scheduledDate) {
|
||||
throw new BadRequestException('Booking has no scheduled date');
|
||||
}
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
this.isFillable(s),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||
);
|
||||
if (!candidates.length) {
|
||||
throw new ConflictException(
|
||||
'No export train is accepting bookings for this day',
|
||||
);
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const required = need ?? this.needFor(booking, wagonLengths);
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (this.fits(required, budget)) return schedule.id;
|
||||
}
|
||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||
* (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
|
||||
* against the combined shared-wagon need; the first partner's accept just waits.
|
||||
* Throws ConflictException (before this booking is persisted-ready) when there is
|
||||
* no export capacity for the day, so staff accept fails.
|
||||
*/
|
||||
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||
const partnerId = booking.consolidationPartnerId ?? null;
|
||||
if (!partnerId) {
|
||||
const scheduleId = await this.pickExportSchedule(booking);
|
||||
await this.reserveOnExport([booking], scheduleId);
|
||||
return;
|
||||
}
|
||||
|
||||
const partner = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
|
||||
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
|
||||
// waits; the partner's later accept will reserve the pair.
|
||||
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
||||
return;
|
||||
}
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const need = this.combinedNeed(booking, partner, wagonLengths);
|
||||
const scheduleId = await this.pickExportSchedule(booking, need);
|
||||
await this.reserveOnExport([booking, partner], scheduleId);
|
||||
}
|
||||
|
||||
/** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
|
||||
private async reserveOnExport(
|
||||
bookings: Booking[],
|
||||
scheduleId: string,
|
||||
): Promise<void> {
|
||||
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||
this.armSettle(scheduleId);
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
}
|
||||
}
|
||||
|
||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
|
||||
const unlinked =
|
||||
@@ -393,9 +545,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cron entry point -----------------------------------------------------
|
||||
// ---- legacy fill entry point ----------------------------------------------
|
||||
|
||||
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
|
||||
/**
|
||||
* Legacy periodic fill for schedules without a window phase (DOMESTIC and
|
||||
* pre-migration trains). Invoked by BookingWindowService's tick — the old
|
||||
* standalone cron was replaced by the window engine.
|
||||
*/
|
||||
async runBatchFill(): Promise<void> {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||
@@ -434,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));
|
||||
@@ -474,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);
|
||||
@@ -499,6 +664,27 @@ export class BookingBatchService implements OnModuleInit {
|
||||
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
||||
);
|
||||
|
||||
// Resolve consolidation-partner references for the shared-wagon badge. Most
|
||||
// partners are on this same schedule; look up any that aren't in one query.
|
||||
const refById = new Map(
|
||||
bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
|
||||
);
|
||||
const missingPartnerIds = [
|
||||
...new Set(
|
||||
bookings
|
||||
.map((b) => b.consolidationPartnerId)
|
||||
.filter((id): id is string => Boolean(id) && !refById.has(id!)),
|
||||
),
|
||||
];
|
||||
if (missingPartnerIds.length) {
|
||||
const partners = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.find({ where: { id: In(missingPartnerIds) } });
|
||||
for (const p of partners) {
|
||||
refById.set(p.id, p.reference ?? p.id.slice(0, 8));
|
||||
}
|
||||
}
|
||||
|
||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonLengths);
|
||||
const alloc = allocationByBooking.get(b.id);
|
||||
@@ -524,20 +710,27 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: null,
|
||||
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
||||
allocationIssue: alloc?.issue ?? null,
|
||||
consolidationPartnerId: b.consolidationPartnerId ?? null,
|
||||
consolidationPartnerRef: b.consolidationPartnerId
|
||||
? (refById.get(b.consolidationPartnerId) ?? null)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
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 from the global-rules
|
||||
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
|
||||
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
|
||||
// export: single FCFS lead window) — not a fixed clock grid.
|
||||
const windowCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
openDate,
|
||||
s.direction ?? null,
|
||||
departureDate,
|
||||
windowCfg,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
@@ -595,6 +788,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
direction: s.direction ?? null,
|
||||
windowPhase: s.windowPhase ?? null,
|
||||
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
|
||||
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
|
||||
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
|
||||
paymentPhaseEndsAt: s.paymentPhaseEndsAt
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -679,6 +881,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
direction: s.direction ?? null,
|
||||
windowPhase: s.windowPhase ?? null,
|
||||
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
|
||||
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
|
||||
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
|
||||
paymentPhaseEndsAt: s.paymentPhaseEndsAt
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -722,11 +933,27 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- core fill ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether the batch engine may reserve/allocate onto this schedule right now.
|
||||
* Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate.
|
||||
* Import window cycle: the engine fills while the customer window is CLOSED —
|
||||
* during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups).
|
||||
* Export: FCFS while the booking window is open.
|
||||
*/
|
||||
isFillable(schedule: TrainSchedule): boolean {
|
||||
if (schedule.bookingWindowStatus === "FULL") return false;
|
||||
if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN";
|
||||
if (schedule.direction === "EXPORT") {
|
||||
return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN";
|
||||
}
|
||||
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
|
||||
}
|
||||
|
||||
/** Fill one schedule from its priority-ordered pool until full. */
|
||||
async fillSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
|
||||
if (!schedule || !this.isFillable(schedule)) return;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
@@ -746,13 +973,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
const need = isPair
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
|
||||
if (!this.fits(need, budget)) {
|
||||
if (booking.isGovernment) {
|
||||
if (isGov) {
|
||||
budget = await this.preemptForGovernment(
|
||||
scheduleId,
|
||||
need,
|
||||
@@ -761,14 +994,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||
}
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
if (isGov) {
|
||||
await this.allocate(scheduleId, booking, "gov");
|
||||
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, scheduleId);
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
@@ -793,22 +1028,33 @@ export class BookingBatchService implements OnModuleInit {
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleIds = bookable
|
||||
// The day's fillable schedules on this exact corridor, earliest first. Fillable
|
||||
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
|
||||
// the batch must run while the customer window is closed.
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduleIds = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.bookingWindowStatus === "OPEN" &&
|
||||
s.scheduleDate != null &&
|
||||
eatDay(new Date(s.scheduleDate)) === day,
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
this.isFillable(s),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduleDate).getTime() -
|
||||
new Date(b.scheduleDate).getTime(),
|
||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
|
||||
@@ -845,15 +1091,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
const need = isPair
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
|
||||
// First train (earliest departure) that fits this booking as-is.
|
||||
// First train (earliest departure) that fits this unit as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
|
||||
if (!target && booking.isGovernment) {
|
||||
// Government booking fits nowhere on its own — try to preempt commercial
|
||||
if (!target && isGov) {
|
||||
// Government fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(
|
||||
@@ -870,15 +1124,45 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// Fits no train this day — stays in the pool, retried next batch.
|
||||
// A consolidated pair is placed whole or not at all — never split.
|
||||
if (!isPair) {
|
||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||
// partial-capacity offer on the train with the most free wagons.
|
||||
const partialTarget = [...trains]
|
||||
.filter((t) => t.budget.wagons >= 1)
|
||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||
if (
|
||||
partialTarget &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
booking.contractKind === "GENERAL" &&
|
||||
this.splitService
|
||||
) {
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
partialTarget.id,
|
||||
partialTarget.budget,
|
||||
need,
|
||||
);
|
||||
if (offered) {
|
||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||
partialTarget.armed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
if (partner) this.notifier.unplaced(partner, day);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
if (isGov) {
|
||||
await this.allocate(target.id, booking, "gov");
|
||||
if (partner) await this.allocate(target.id, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
@@ -893,29 +1177,127 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
/**
|
||||
* Offer the largest fitting part of an over-capacity booking as a partial
|
||||
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
||||
* meaningful partial fits / an offer is already open.
|
||||
*/
|
||||
private async tryPartialOffer(
|
||||
booking: Booking,
|
||||
scheduleId: string,
|
||||
budget: Capacity,
|
||||
need: Capacity,
|
||||
): Promise<Capacity | null> {
|
||||
if (!this.splitService) return null;
|
||||
// A consolidated booking is already half of a shared wagon — never split it.
|
||||
if (booking.consolidationPartnerId) return null;
|
||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
|
||||
const sized = await this.splitService.sizeOffer(
|
||||
booking,
|
||||
budget.wagons,
|
||||
need.wagons,
|
||||
bulkCapacityTons,
|
||||
);
|
||||
if (!sized) return null;
|
||||
|
||||
const offeredNeed: Capacity = {
|
||||
wagons: sized.offeredWagons,
|
||||
weightTons: sized.offeredWeightTons,
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
};
|
||||
if (!this.fits(offeredNeed, budget)) return null;
|
||||
|
||||
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
|
||||
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
|
||||
// Reserve like a normal batch selection, but the partial invoice + partial
|
||||
// pay-now notification were already produced by createOffer.
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
selectedForBatchAt: new Date(),
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
return offeredNeed;
|
||||
}
|
||||
|
||||
private async loadBulkWagonCapacityTons(): Promise<number> {
|
||||
const cw3 = await this.dataSource
|
||||
.getRepository(WagonType)
|
||||
.findOne({ where: { code: "CW3" } });
|
||||
const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60;
|
||||
return capacity > 0 ? capacity : 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
|
||||
* how to treat a reservation with no deadline (durable path: leave it; timeout
|
||||
* path: expire it). Consolidated pairs settle atomically: both allocate only
|
||||
* when both paid; if either partner expires, both expire (a half-paid shared
|
||||
* wagon must not ship). Returns whether anything changed.
|
||||
*/
|
||||
private async settleReserved(
|
||||
scheduleId: string,
|
||||
expireUnpaidUnknownDeadline: boolean,
|
||||
): Promise<boolean> {
|
||||
const reserved =
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
const byId = new Map(reserved.map((b) => [b.id, b]));
|
||||
const done = new Set<string>();
|
||||
let anySettled = false;
|
||||
|
||||
for (const booking of reserved) {
|
||||
const paid =
|
||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
||||
const expired = booking.paymentDeadline
|
||||
? booking.paymentDeadline.getTime() <= now
|
||||
: false;
|
||||
const isPaid = (b: Booking) =>
|
||||
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||
const isExpired = (b: Booking) =>
|
||||
b.paymentDeadline
|
||||
? b.paymentDeadline.getTime() <= now
|
||||
: expireUnpaidUnknownDeadline;
|
||||
|
||||
if (paid) {
|
||||
for (const booking of reserved) {
|
||||
if (done.has(booking.id)) continue;
|
||||
const partner = booking.consolidationPartnerId
|
||||
? (byId.get(booking.consolidationPartnerId) ?? null)
|
||||
: null;
|
||||
|
||||
if (partner) {
|
||||
done.add(booking.id);
|
||||
done.add(partner.id);
|
||||
// Both-or-neither: allocate the shared wagon only when both partners paid;
|
||||
// if either lapsed, expire both so no half-paid wagon rides.
|
||||
if (isPaid(booking) && isPaid(partner)) {
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
await this.allocate(scheduleId, partner, "paid");
|
||||
anySettled = true;
|
||||
} else if (isExpired(booking) || isExpired(partner)) {
|
||||
await this.expire(booking);
|
||||
await this.expire(partner);
|
||||
anySettled = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
done.add(booking.id);
|
||||
if (isPaid(booking)) {
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
anySettled = true;
|
||||
} else if (expired) {
|
||||
} else if (isExpired(booking)) {
|
||||
await this.expire(booking);
|
||||
anySettled = true;
|
||||
}
|
||||
}
|
||||
return anySettled;
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const anySettled = await this.settleReserved(scheduleId, false);
|
||||
if (anySettled) await this.fillSchedule(scheduleId);
|
||||
}
|
||||
|
||||
@@ -924,25 +1306,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
/** Allocate paid reservations, expire the rest, then top up. */
|
||||
async settleBatch(scheduleId: string): Promise<void> {
|
||||
this.removeTimeout(scheduleId);
|
||||
const reserved =
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
const now = Date.now();
|
||||
|
||||
for (const booking of reserved) {
|
||||
const paid =
|
||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
||||
const expired = booking.paymentDeadline
|
||||
? booking.paymentDeadline.getTime() <= now
|
||||
: true;
|
||||
|
||||
if (paid) {
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
} else if (expired) {
|
||||
await this.expire(booking);
|
||||
}
|
||||
// else: still within window (rare at settle) → leave for the re-armed timeout
|
||||
}
|
||||
|
||||
await this.settleReserved(scheduleId, true);
|
||||
await this.fillSchedule(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
@@ -1063,7 +1427,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
@@ -1136,6 +1500,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
// An unpaid partial offer dies with the reservation — the booking stays whole.
|
||||
if (this.splitService) {
|
||||
await this.splitService.expireOpenOffer(booking.id);
|
||||
}
|
||||
// Pay window closed before settlement → expire the booking's open invoice too
|
||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||
// source-agnostic.
|
||||
@@ -1198,6 +1566,74 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- capacity helpers -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Collapse consolidated partners into single pool entries so the fill treats a
|
||||
* shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
|
||||
* - no `consolidationPartnerId` → passes through as a lone booking.
|
||||
* - consolidated + partner also in this pool → emitted ONCE (at the position of
|
||||
* whichever partner ranks first) as a pair; the partner is not emitted again.
|
||||
* - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
|
||||
* it waits for the partner to become ready in a later cycle).
|
||||
* The pool is already priority-ordered, so emitting the pair at the first-seen
|
||||
* partner's slot ranks it by the stronger (max-priority) partner automatically.
|
||||
*/
|
||||
private groupConsolidatedPool(
|
||||
pool: Booking[],
|
||||
): Array<{ primary: Booking; partner: Booking | null }> {
|
||||
const byId = new Map(pool.map((b) => [b.id, b]));
|
||||
const emitted = new Set<string>();
|
||||
const units: Array<{ primary: Booking; partner: Booking | null }> = [];
|
||||
for (const booking of pool) {
|
||||
if (emitted.has(booking.id)) continue;
|
||||
const partnerId = booking.consolidationPartnerId ?? null;
|
||||
if (!partnerId) {
|
||||
emitted.add(booking.id);
|
||||
units.push({ primary: booking, partner: null });
|
||||
continue;
|
||||
}
|
||||
const partner = byId.get(partnerId) ?? null;
|
||||
if (!partner) {
|
||||
// Both-or-neither: partner not ready in this pool → skip the pair entirely.
|
||||
emitted.add(booking.id);
|
||||
continue;
|
||||
}
|
||||
emitted.add(booking.id);
|
||||
emitted.add(partner.id);
|
||||
units.push({ primary: booking, partner });
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined capacity need of a consolidated pair sharing wagons. The whole point of
|
||||
* consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
|
||||
* the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
|
||||
* independently-rounded-up needs (that is the capacity consolidation saves).
|
||||
*/
|
||||
private combinedNeed(
|
||||
primary: Booking,
|
||||
partner: Booking,
|
||||
wagonLengths: WagonLengths,
|
||||
): Capacity {
|
||||
const containers = (b: Booking): number =>
|
||||
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const totalContainers = containers(primary) + containers(partner);
|
||||
const sharedWagons =
|
||||
totalContainers > 0
|
||||
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
|
||||
: this.wagonsFor(primary) + this.wagonsFor(partner);
|
||||
const weightTons =
|
||||
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||
return {
|
||||
wagons: sharedWagons,
|
||||
weightTons,
|
||||
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private wagonsFor(booking: Booking): number {
|
||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||
return Math.ceil(booking.wagonsRequired);
|
||||
@@ -1359,7 +1795,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return (schedule.maxWagons ?? 0) - used;
|
||||
}
|
||||
|
||||
private async setWindow(
|
||||
async setWindow(
|
||||
scheduleId: string,
|
||||
status: "OPEN" | "FULL" | "CLOSED",
|
||||
): Promise<void> {
|
||||
@@ -1368,22 +1804,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.update(scheduleId, { bookingWindowStatus: status });
|
||||
}
|
||||
|
||||
/** No wagon slots left for allocated + reserved bookings. */
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) return false;
|
||||
return (await this.remainingWagons(schedule)) <= 0;
|
||||
}
|
||||
|
||||
// ---- timer plumbing -------------------------------------------------------
|
||||
|
||||
/** Configured customer pay window in ms (global rules, with defaults). */
|
||||
private async paymentWindowMs(): Promise<number> {
|
||||
const cfg = await this.trainSchedulingService.getWindowConfig();
|
||||
return cfg.paymentWindowMinutes * 60_000;
|
||||
}
|
||||
|
||||
private timeoutName(scheduleId: string): string {
|
||||
return `settle:${scheduleId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process accelerator only — the durable settle enforcement is the window
|
||||
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
|
||||
*/
|
||||
private armSettle(scheduleId: string): void {
|
||||
this.removeTimeout(scheduleId);
|
||||
const handle = setTimeout(() => {
|
||||
void this.settleBatch(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
|
||||
void this.paymentWindowMs()
|
||||
.then((delayMs) => {
|
||||
this.removeTimeout(scheduleId);
|
||||
const handle = setTimeout(() => {
|
||||
void this.settleBatch(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}, delayMs);
|
||||
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(
|
||||
`armSettle ${scheduleId} skipped: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}, PAYMENT_WINDOW_MS);
|
||||
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
||||
}
|
||||
|
||||
private removeTimeout(scheduleId: string): void {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PAYMENT_WINDOW_MS } from './booking-batch.constants';
|
||||
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
@@ -43,12 +42,32 @@ export class BookingNotifierService {
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000);
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW');
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit
|
||||
* this train. Paying accepts the split; letting the deadline pass keeps the
|
||||
* booking whole and expires it for this train.
|
||||
*/
|
||||
async payNowPartial(
|
||||
b: Booking,
|
||||
deadline: Date,
|
||||
offeredWagons: number,
|
||||
totalWagons: number,
|
||||
): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const msg =
|
||||
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
|
||||
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import {
|
||||
BookingBatchOffer,
|
||||
OfferedLine,
|
||||
} from './entities/booking-batch-offer.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
|
||||
export interface SizedOffer {
|
||||
offeredWagons: number;
|
||||
totalWagons: number;
|
||||
offeredLines: OfferedLine[] | null;
|
||||
offeredWeightTons: number;
|
||||
offeredAmount: number;
|
||||
offeredPricingBreakdown: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial-capacity booking splits (import batch). The offer is sized and priced
|
||||
* against an in-memory clone — the booking row is untouched until the customer
|
||||
* pays, which is the act of accepting the split (applySplit). No payment →
|
||||
* offer expires and the booking stays whole.
|
||||
*
|
||||
* Only GENERAL-contract commercial bookings are offered partials: the remainder
|
||||
* returns to the contract's quantity cap (derived live from booking_container
|
||||
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||
* any later window within contract validity.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingSplitService {
|
||||
private readonly logger = new Logger(BookingSplitService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => BookingPricingService))
|
||||
private readonly pricing: BookingPricingService,
|
||||
@Inject(forwardRef(() => BookingInvoiceService))
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Size the largest part of the booking that fits `freeWagons`, priced via an
|
||||
* in-memory clone. Returns null when nothing meaningful fits (no whole
|
||||
* container unit / no bulk tonnage, or pricing failed).
|
||||
*/
|
||||
async sizeOffer(
|
||||
booking: Booking,
|
||||
freeWagons: number,
|
||||
totalWagons: number,
|
||||
bulkWagonCapacityTons: number,
|
||||
): Promise<SizedOffer | null> {
|
||||
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
|
||||
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
let offeredLines: OfferedLine[] | null = null;
|
||||
let offeredWeightTons = 0;
|
||||
let offeredWagons = 0;
|
||||
const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking);
|
||||
clone.adjustedTotalAmount = null;
|
||||
|
||||
if (containers.length) {
|
||||
offeredLines = [];
|
||||
let remaining = freeWagons;
|
||||
const clonedContainers: BookingContainer[] = [];
|
||||
for (const line of containers) {
|
||||
const quantity = Number(line.quantity ?? 0);
|
||||
const lineWagons = Number(line.wagonsRequired ?? 0);
|
||||
if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue;
|
||||
const perUnit = lineWagons / quantity;
|
||||
// Largest unit count whose wagon need still fits the remaining budget.
|
||||
let take = Math.min(quantity, Math.floor(remaining / perUnit));
|
||||
while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1;
|
||||
if (take <= 0) continue;
|
||||
const takeWagons = Math.ceil(take * perUnit);
|
||||
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
|
||||
offeredLines.push({
|
||||
bookingContainerId: line.id,
|
||||
quantity: take,
|
||||
wagonsRequired: takeWagons,
|
||||
totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000,
|
||||
});
|
||||
offeredWeightTons += take * vgmPerUnit;
|
||||
offeredWagons += takeWagons;
|
||||
remaining -= takeWagons;
|
||||
|
||||
const clonedLine: BookingContainer = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(line)),
|
||||
line,
|
||||
{
|
||||
quantity: take,
|
||||
wagonsRequired: takeWagons,
|
||||
totalVgmTons: take * vgmPerUnit,
|
||||
},
|
||||
);
|
||||
clonedContainers.push(clonedLine);
|
||||
}
|
||||
if (!offeredLines.length || offeredWagons <= 0) return null;
|
||||
clone.bookingContainers = clonedContainers;
|
||||
} else {
|
||||
// Bulk: split by weight — the offered part is what freeWagons can carry.
|
||||
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
|
||||
offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons);
|
||||
if (offeredWeightTons <= 0) return null;
|
||||
offeredWagons = Math.min(
|
||||
freeWagons,
|
||||
Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)),
|
||||
);
|
||||
}
|
||||
|
||||
offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000;
|
||||
clone.cargoTotalWeightVgm = offeredWeightTons;
|
||||
clone.wagonsRequired = offeredWagons;
|
||||
|
||||
try {
|
||||
const priced = await this.pricing.computePriceForBooking(clone);
|
||||
return {
|
||||
offeredWagons,
|
||||
totalWagons,
|
||||
offeredLines,
|
||||
offeredWeightTons,
|
||||
offeredAmount: priced.totalAmount,
|
||||
offeredPricingBreakdown: {
|
||||
lineItems: priced.lineItems,
|
||||
totalAmount: priced.totalAmount,
|
||||
currency: priced.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
partialOfWagons: totalWagons,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the offer and swap the booking's payable to a partial invoice for the
|
||||
* offered amount. Any previous open offer for the booking is superseded.
|
||||
*/
|
||||
async createOffer(
|
||||
booking: Booking,
|
||||
scheduleId: string,
|
||||
sized: SizedOffer,
|
||||
deadline: Date,
|
||||
): Promise<BookingBatchOffer> {
|
||||
const repo = this.dataSource.getRepository(BookingBatchOffer);
|
||||
await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' });
|
||||
|
||||
// The full-amount invoice must not stay payable next to the partial one.
|
||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID');
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(
|
||||
{ ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking,
|
||||
{ dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending },
|
||||
);
|
||||
|
||||
const offer = await repo.save(
|
||||
repo.create({
|
||||
bookingId: booking.id,
|
||||
trainScheduleId: scheduleId,
|
||||
offeredWagons: sized.offeredWagons,
|
||||
totalWagons: sized.totalWagons,
|
||||
offeredLines: sized.offeredLines,
|
||||
offeredWeightTons: sized.offeredWeightTons,
|
||||
offeredAmount: sized.offeredAmount,
|
||||
offeredPricingBreakdown: sized.offeredPricingBreakdown,
|
||||
invoiceId: invoice.id,
|
||||
paymentDeadline: deadline,
|
||||
status: 'OFFERED',
|
||||
}),
|
||||
);
|
||||
await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons);
|
||||
return offer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment received inside the window — the customer accepted the split.
|
||||
* Reduce the booking to the offered lines/weight; the remainder returns to the
|
||||
* contract cap automatically (bookedQuantities derives from live lines).
|
||||
* Idempotent: no OFFERED offer → no-op.
|
||||
*/
|
||||
async applySplit(bookingId: string): Promise<void> {
|
||||
const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({
|
||||
where: { bookingId, status: 'OFFERED' },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (!offer) return;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (offer.offeredLines?.length) {
|
||||
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
|
||||
const lines = await manager.getRepository(BookingContainer).find({
|
||||
where: { bookingId },
|
||||
});
|
||||
for (const line of lines) {
|
||||
const kept = keptByLine.get(line.id);
|
||||
if (!kept) {
|
||||
await manager.getRepository(BookingContainer).softDelete(line.id);
|
||||
await manager
|
||||
.getRepository(BookingContainerUnit)
|
||||
.softDelete({ bookingContainerId: line.id });
|
||||
continue;
|
||||
}
|
||||
const dropCount = Number(line.quantity) - kept.quantity;
|
||||
await manager.getRepository(BookingContainer).update(line.id, {
|
||||
quantity: kept.quantity,
|
||||
wagonsRequired: kept.wagonsRequired,
|
||||
totalVgmTons: kept.totalVgmTons,
|
||||
hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity),
|
||||
reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity),
|
||||
});
|
||||
if (dropCount > 0) {
|
||||
// Trim surplus physical units, last-entered first.
|
||||
const units = await manager.getRepository(BookingContainerUnit).find({
|
||||
where: { bookingContainerId: line.id },
|
||||
order: { sortOrder: 'DESC', createdAt: 'DESC' },
|
||||
take: dropCount,
|
||||
});
|
||||
if (units.length) {
|
||||
await manager
|
||||
.getRepository(BookingContainerUnit)
|
||||
.softDelete(units.map((u) => u.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
wagonsRequired: offer.offeredWagons,
|
||||
cargoTotalWeightVgm: offer.offeredWeightTons,
|
||||
totalAmount: offer.offeredAmount,
|
||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||
} as never);
|
||||
|
||||
await manager
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update(offer.id, { status: 'APPLIED' });
|
||||
});
|
||||
this.logger.log(
|
||||
`Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Pay window closed without payment — offer dies, booking stays whole. */
|
||||
async expireOpenOffer(bookingId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
|
||||
}
|
||||
|
||||
async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
|
||||
return this.dataSource.getRepository(BookingBatchOffer).findOne({
|
||||
where: { bookingId, status: 'OFFERED' },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user