Merge branch 'dev' into freight/nati-1

This commit is contained in:
Nathnael
2026-07-29 13:39:20 +00:00
269 changed files with 12185 additions and 5087 deletions

View File

@@ -2,6 +2,7 @@ import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
RequestMethod,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
@@ -31,6 +32,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -101,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
@Module({
imports: [
@@ -173,6 +176,7 @@ import { LoggerMiddleware } from "./logger.middleware";
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
TransitAgentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -240,6 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware";
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -328,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
consumer.apply(LoginAudienceMiddleware).forRoutes(
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/mfa-verify", method: RequestMethod.POST },
);
}
}

View File

@@ -0,0 +1,40 @@
import { generateGrnNumber, grnOwnerSlug } from './grn.util';
/**
* The GRN is mapped to the goods owner for BOTH directions, so a note is
* identifiable by who owns the cargo. The reference slice stays the uniqueness
* anchor — one owner can have several bookings received the same day.
*/
const date = new Date('2026-07-27T09:15:00Z');
const bookingId = '1a2b3c4d-1111-2222-3333-444455556666';
describe('GRN number', () => {
it('maps an import GRN to the owner', () => {
expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe(
'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D',
);
});
it('maps an export GRN to the owner the same way', () => {
expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe(
'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D',
);
});
it('keeps the owner-less format when there is no owner (manual walk-in)', () => {
expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D');
expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D');
});
it('stays unique per booking for one owner on one day', () => {
const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme');
const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme');
expect(a).not.toBe(b);
});
it('strips punctuation and caps the owner segment', () => {
expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT');
expect(grnOwnerSlug('a/b c')).toBe('ABC');
expect(grnOwnerSlug(null)).toBeNull();
});
});

View File

@@ -1,13 +1,41 @@
/**
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<OWNER>-<REF8>`.
*
* The GRN is mapped to the goods OWNER (the booking's customer / consignee) for
* both import and export, so a note is identifiable by who owns the cargo
* without opening it. The trailing reference slice stays as the uniqueness
* anchor — one owner can have several bookings received on the same day.
* Owner-less receipts (manual walk-ins with no booking) fall back to the
* original `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>` form.
*
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
* raised in a warehouse — the two live in different tables
* (facility_handling_events vs warehouse_inventory), and a second generator would
* eventually let their formats drift apart.
*/
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
export function generateGrnNumber(
direction: string,
referenceId: string,
date: Date,
ownerName?: string | null,
): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
const owner = grnOwnerSlug(ownerName);
const base = `GRN-${direction.toUpperCase()}-${stamp}`;
return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`;
}
/**
* Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a
* long company name can't run away with the number. Null when there is nothing
* usable, which drops the segment rather than emitting an empty `--`.
*/
export function grnOwnerSlug(ownerName?: string | null): string | null {
const slug = (ownerName ?? '')
.normalize('NFKD')
.replace(/[^a-zA-Z0-9]+/g, '')
.toUpperCase()
.slice(0, 12);
return slug || null;
}

View File

@@ -2,6 +2,7 @@ import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import {
HttpExceptionFilter,
@@ -11,8 +12,25 @@ import {
import { AppModule } from "./app.module";
/**
* JSON body ceiling. Signing posts the signature AND the company stamp as
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
*/
const JSON_BODY_LIMIT = '20mb';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
// is not a declared dependency of this app (it arrives under
// @nestjs/platform-express), so importing it directly resolved only through
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
// image, where `pnpm deploy --prod` installs declared dependencies only.
// This also RECONFIGURES the default parsers rather than racing them.
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
// Dev CORS: reflect any localhost origin and allow credentials so the
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
@@ -28,6 +46,9 @@ async function bootstrap() {
"Accept",
"Authorization",
"X-Requested-With",
// Which freight frontend is calling — /auth/login uses this to reject
// cross-audience credentials (EDRFREIGHT-415).
"X-Client-App",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",
@@ -40,6 +61,13 @@ async function bootstrap() {
"x-delegator-position-id",
"x-current-project-id",
"x-current-position-id",
// Headers sent by the freight-backoffice OKR/objective-service client
// (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts)
// under yet another naming convention — unprefixed "tenant-key"/"unit-id",
// and "x-delegated-position-id" (delegated, not delegator).
"tenant-key",
"unit-id",
"x-delegated-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck detention clocks. Detention was timed once per last-mile leg
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
* delivery shared one window and was billed identical days — wrong the moment
* two trucks arrive or return at different times.
*
* Deliberately NEW columns rather than reusing the existing per-truck
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
* events stamped by release(), whereas detention runs from arrival at the
* DESTINATION until the truck is released/returned.
*
* Both nullable — a truck without its own window falls back to the leg-level
* timestamps, so legacy legs keep billing exactly as before.
*/
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
name = 'AddPerTruckDetentionWindow2860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS returned_at,
DROP COLUMN IF EXISTS destination_arrived_at;
`);
}
}

View File

@@ -0,0 +1,131 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Indode's real 11-yard layout, plus the plumbing to auto-route a booking to
* the right yard by cargo type (and, for container yards, trade direction):
*
* - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only
* meaningful for CONTAINER_YARD, where import and export stacks are
* physically separate (Yard 5 vs Yard 6). Everything else takes cargo
* either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means
* "not a customer cargo yard" — Yards 10/11 (service/equipment) are
* CONTAINER_YARD structurally but must never be offered for ordinary
* import/export cargo, so the frontend match requires an EXACT IMPORT/
* EXPORT direction hit for container freight rather than treating BOTH as
* a wildcard.
* - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors
* the existing `cargo_type_wagon_types` join table). Empty = open to any
* cargo type of the yard's structural type (additive, never restrictive
* by default), so this cannot break a yard nobody has configured yet.
*
* Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here
* so Yards 1 and 9 have a real mapping ready for when they reopen.
*/
export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface {
name = "IndodeYardsAndCargoRouting2990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_yards
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types (
yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE,
cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
PRIMARY KEY (yard_id, cargo_type_id)
)
`);
// New cargo types Indode's yard list names but the catalog didn't have yet.
await queryRunner.query(`
INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active)
VALUES
('FERTILIZER', 'Fertilizer', 'PER_TON', true),
('COFFEE', 'Coffee', 'PER_TON', true),
('TEA', 'Tea', 'PER_TON', true)
ON CONFLICT (code) DO NOTHING
`);
// The 11 real yards at Indode Open Warehouse (code 'IOW').
await queryRunner.query(`
INSERT INTO freight.warehouse_yards
(warehouse_id, name, code, type, direction, status, is_active)
SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE'
FROM freight.warehouses w
CROSS JOIN (VALUES
('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'),
('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'),
('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'),
('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'),
('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'),
('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'),
('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'),
('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'),
('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE')
) AS y(code, name, type, direction, status)
WHERE w.code = 'IOW'
ON CONFLICT (warehouse_id, code) DO NOTHING
`);
// One default zone per new yard, matching its yard's type — every existing
// yard (CY-1, CY-A) already follows this one-zone-per-yard shape.
await queryRunner.query(`
INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active)
SELECT y.id, y.name || ' Zone 1', 'Z1',
CASE y.type
WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE'
WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE'
WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE'
WHEN 'BULK_YARD' THEN 'BULK_ZONE'
ELSE 'GENERAL_CARGO_ZONE'
END,
y.status, y.status = 'ACTIVE'
FROM freight.warehouse_yards y
JOIN freight.warehouses w ON w.id = y.warehouse_id
WHERE w.code = 'IOW' AND y.code LIKE 'Y%'
ON CONFLICT (yard_id, code) DO NOTHING
`);
// Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally
// left with no rows — direction alone decides those, per the entity comment.
await queryRunner.query(`
INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id)
SELECT y.id, ct.id
FROM freight.warehouses w
JOIN freight.warehouse_yards y ON y.warehouse_id = w.id
JOIN (VALUES
('Y1', 'FERTILIZER'),
('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'),
('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'),
('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'),
('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'),
('Y7', 'PERISHABLE'),
('Y9', 'COFFEE'), ('Y9', 'TEA')
) AS m(yard_code, cargo_code) ON m.yard_code = y.code
JOIN freight.cargo_types ct ON ct.code = m.cargo_code
WHERE w.code = 'IOW'
ON CONFLICT (yard_id, cargo_type_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.warehouse_zones z
USING freight.warehouse_yards y, freight.warehouses w
WHERE z.yard_id = y.id AND y.warehouse_id = w.id
AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
await queryRunner.query(`
DELETE FROM freight.warehouse_yards y
USING freight.warehouses w
WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
// Cargo types and the join table are left in place — other data may have
// started referencing them since; dropping columns/tables is not reversible
// once real rows exist, and leaving them is harmless.
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backoffice contract suspension (reversible freeze at any post-signature step)
* and customer-initiated contract cancellation.
*
* Only one new column is needed: the status to restore when the suspension is
* lifted. The reason and the actor already have a home — contract_review_notes
* rows with note_type SUSPENSION / SUSPENSION_LIFTED / CANCELLATION.
*/
export class AddContractSuspension3000000000000 implements MigrationInterface {
name = 'AddContractSuspension3000000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`,
);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Transit-assignee handshake on the SHIPMENT, not the contract.
*
* Clearance runs per booking now, so the ask GL Ethiopia raises before filing a
* customs declaration ("who handles this shipment in Djibouti?") and Djibouti's
* answer belong on the booking. The contract-cycle columns added by
* 2950000000000 stay for the legacy contract-level cycles.
*/
export class AddBookingTransitAssignee3010000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS transit_assignee_requested_at,
DROP COLUMN IF EXISTS transit_assignee_request_note,
DROP COLUMN IF EXISTS transit_assignee_name,
DROP COLUMN IF EXISTS transit_assignee_assigned_at
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A contract's `created_at` is the DRAFT row's insert time, not when the
* customer actually submitted it for review — a DRAFT can sit edited for days
* first. `submitted_at` is stamped by ContractTransitionService.submit /
* confirmSubmit so the history UI can show a real submission time.
*/
export class AddContractSubmittedAt3020000000000 implements MigrationInterface {
name = 'AddContractSubmittedAt3020000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`,
);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary
* `freight.files` rows (resource `gl_exchange`), so they only need the metadata
* a free-form upload has and a catalog-driven one does not: the uploader's own
* title, who uploaded it (the only user allowed to change it afterwards) and
* whether the customer may see it in the portal.
*/
export class AddGlExchangeDocumentFields3030000000000
implements MigrationInterface
{
name = 'AddGlExchangeDocumentFields3030000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS title varchar(300),
ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid,
ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`,
);
// Every read of a thread is "all files of one resource" — index the pair.
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_files_resource_lookup
ON freight.files (resource, resource_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`,
);
await queryRunner.query(
`ALTER TABLE freight.files
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS visible_to_customer,
DROP COLUMN IF EXISTS uploaded_by_user_id,
DROP COLUMN IF EXISTS uploaded_by_name;`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTransitAgents3040000000000 implements MigrationInterface {
name = "AddTransitAgents3040000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.transit_agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(150) NOT NULL,
valid_from date NOT NULL,
valid_to date NOT NULL,
is_active boolean NOT NULL DEFAULT true,
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 ix_transit_agents_is_active
ON freight.transit_agents (is_active)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`);
}
}

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { LoginAudienceMiddleware } from './login-audience.middleware';
/**
* Touches only the DataSource, so build off the prototype rather than
* standing up a full Nest module — same pattern as
* warehouses/receive-export-paid.spec.ts.
*/
function makeMiddleware(userType: string | undefined) {
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
const middleware = Object.create(
LoginAudienceMiddleware.prototype,
) as LoginAudienceMiddleware;
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
return middleware;
}
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
return {
header: (name: string) =>
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
body: { email },
} as any;
}
describe('LoginAudienceMiddleware', () => {
it('rejects when the client app header is missing', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq(undefined), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects an unrecognized client app header', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('mobile'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an employee account signing in through the portal client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('portal'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects a customer account signing in through the backoffice client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await expect(
middleware.use(makeReq('backoffice'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows an employee account through the backoffice client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await middleware.use(makeReq('backoffice'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('allows a customer account through the portal client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('lets an unknown identifier fall through to the login handler', async () => {
const middleware = makeMiddleware(undefined);
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { NextFunction, Request, Response } from 'express';
export const CLIENT_APP_HEADER = 'x-client-app';
// EUserType values from @tria-plc/api-common, duplicated here to avoid
// pulling in the full enum just for this string comparison.
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
backoffice: ['employee'],
portal: ['individual', 'external_organization'],
};
/**
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
* against email/username/phone_number only (see vendor
* findUserForLogin), with no check that the account's userType belongs on
* the app that's asking. A backoffice (employee) client presenting a
* customer's credentials — or vice versa — must not get a session.
*/
@Injectable()
export class LoginAudienceMiddleware implements NestMiddleware {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async use(req: Request, _res: Response, next: NextFunction) {
const clientApp = req.header(CLIENT_APP_HEADER);
const allowedUserTypes = clientApp
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
: undefined;
if (!allowedUserTypes) {
throw new ForbiddenException(
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
);
}
const identifier: unknown = req.body?.email;
if (typeof identifier !== 'string' || !identifier) {
// No identifier to look up — the vendor DTO validation rejects the
// request on its own.
return next();
}
const [user] = await this.dataSource.query(
`SELECT user_type AS "userType" FROM iam.users
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
[identifier],
);
if (user && !allowedUserTypes.includes(user.userType)) {
throw new ForbiddenException(
`This account cannot sign in through the ${clientApp} application`,
);
}
next();
}
}

View File

@@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Booking cancelled', msg);
}
/**
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
* deep-linked to the Djibouti clearance page where the name is entered — the
* customs declaration is blocked until they answer.
*/
transitAssigneeRequested(b: Booking, note: string | null): void {
const msg =
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
});
}
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */
@@ -287,11 +317,11 @@ export class BookingLifecycleNotifierService {
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
`Please review and approve it in the portal, then pay and upload the payment slip.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
@@ -331,6 +361,15 @@ export class BookingLifecycleNotifierService {
);
}
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
finalInvoiceApprovedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Final invoice approved',
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
@@ -362,6 +401,32 @@ export class BookingLifecycleNotifierService {
);
}
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
draftDeclarationReady(b: Booking, price: number, currency: string): void {
const msg =
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
this.inApp(b, 'Draft declaration ready for review', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/**
* The customer asked for a change on the draft declaration. This goes to
* STAFF, not the customer: GL Ethiopia is the one who has to send a
* corrected draft, and the clearance page is where they do it.
*/
draftDeclarationChangeRequested(b: Booking, note: string): void {
const msg =
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
const label =

View File

@@ -37,7 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };
}

View File

@@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };
}

View File

@@ -48,7 +48,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -70,6 +70,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
}
@@ -164,11 +165,12 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };
}

View File

@@ -7,7 +7,7 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -56,11 +56,12 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
private isPhasedCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
}
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
@@ -376,6 +377,8 @@ export class BookingTransitionService {
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.completed(fresh);
// A ONE_TIME contract closes on its single shipment being delivered.
this.events.emit('booking.completed', { bookingId });
// Customer tracking: close out the tail milestones so a finished shipment
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
// implied by delivery; a storage invoice that was never raised is skipped
@@ -491,7 +494,7 @@ export class BookingTransitionService {
operationReady?: boolean;
}> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
return this.bookingClearanceService.getClearanceView(bookingId);
}
const { inputCode, outputCode, includesCustoms } =
@@ -650,7 +653,7 @@ export class BookingTransitionService {
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
@@ -732,7 +735,7 @@ export class BookingTransitionService {
}
if (
status === 'QUERIED' &&
this.isPhasedGeneralCustoms(booking) &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
@@ -755,7 +758,7 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
@@ -767,7 +770,7 @@ export class BookingTransitionService {
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) {
if (this.isPhasedCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
@@ -817,7 +820,7 @@ export class BookingTransitionService {
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
);

View File

@@ -823,6 +823,34 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration',
})
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/assign')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@@ -872,6 +900,59 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
})
async uploadBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@Body('price') priceRaw: string,
@Body('currency') currency: string | undefined,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
Number(priceRaw),
currency ?? 'ETB',
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/accept')
@ApiOperation({
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/change')
@ApiOperation({
summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
})
async requestBookingDraftDeclarationChange(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.requestDraftDeclarationChange(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })

View File

@@ -1,8 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import {
DataSource,
DeepPartial,
EntityManager,
FindOptionsWhere,
In,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -26,6 +34,22 @@ import {
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
/** A booking is ready for a batch: commercial = signed, government = approved/paid. */
const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`;
/**
* Suspending a contract freezes its bookings, so they drop out of every
* scheduling pool. Filtering here (rather than letting the write guard throw)
* keeps the batch crons quiet — a frozen contract simply stops being a
* candidate until the suspension is lifted.
*/
const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM freight.contracts c
WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED'
))`;
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
@@ -68,6 +92,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ where: { reference } });
}
/**
* Suspending a contract freezes its bookings too, so the single write path
* every booking mutation funnels through is the place to enforce it — one
* guard instead of one per transition method.
*
* The batch/scheduling pools filter suspended contracts out up front
* (see {@link excludeSuspendedContract}), so the engine and its crons never
* reach a frozen booking and this only ever fires on a user-initiated action.
*
* ponytail: the seven `manager.getRepository(Booking)` writes inside
* train-scheduling transactions bypass this — they only run on bookings the
* pool already handed out, which the filter above has excluded. Move them onto
* this repository if that ever stops holding.
*/
private async assertContractNotSuspended(id: string): Promise<void> {
const row = await this.repository
.createQueryBuilder('booking')
.select('contract.status', 'status')
.innerJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.where('booking.id = :id', { id })
.getRawOne<{ status: string }>();
if (row?.status === 'SUSPENDED') {
throw new ConflictException(
'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.',
);
}
}
override async update(
id: string,
data: DeepPartial<Booking>,
): Promise<Booking | null> {
await this.assertContractNotSuspended(id);
return super.update(id, data);
}
/**
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
* Includes soft-deleted bookings so the next number clears references that
@@ -551,6 +611,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
);
}
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
async findReviewNotes(
bookingId: string,
type: ReviewNoteType,
): Promise<BookingReviewNote[]> {
return this.dataSource.getRepository(BookingReviewNote).find({
where: { bookingId, type },
order: { createdAt: 'DESC' },
});
}
async findLatestReviewNote(
bookingId: string,
type?: ReviewNoteType,
@@ -1032,7 +1103,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
'scheduleBooking.booking_id = booking.id',
)
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
.andWhere('scheduleBooking.id IS NULL')
.andWhere(NOT_ON_SUSPENDED_CONTRACT);
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
@@ -1091,10 +1163,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1130,10 +1200,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1170,10 +1238,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')

View File

@@ -62,13 +62,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
it('ONE_TIME contract shipments carry the same per-booking set', () => {
// Contracts no longer collect clearance documents — every shipment does,
// whatever kind of contract it draws on.
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
* DOMESTIC has no customs, so one shared set serves every intercity booking
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
@@ -77,16 +76,6 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
export const REVIEW_NOTE_TYPES = [
'CHANGES_REQUESTED',
'REJECTION',
'STAFF_NOTE',
/**
* The customer asked GL Ethiopia to correct the draft customs declaration
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DRAFT_DECL_CHANGE_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' })

View File

@@ -557,6 +557,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
/**
* Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this
* shipment in transit, Djibouti answers with a name (free text — the officer is
* not a platform user). The import declaration is blocked until `name` is set.
*/
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
transitAssigneeRequestedAt?: Date | null;
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
transitAssigneeRequestNote?: string | null;
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
transitAssigneeName?: string | null;
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
transitAssigneeAssignedAt?: Date | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;

View File

@@ -15,6 +15,9 @@ const generalImportBooking = {
dutyRequired: true,
roHoldReason: null,
vesselDepartureDate: null,
// Djibouti already named the transit officer — the declaration gate is open.
transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'),
transitAssigneeName: 'Ahmed Bourhan',
} as Booking;
const generalExportBooking = {
@@ -27,6 +30,8 @@ const generalExportBooking = {
function makeService(overrides?: {
booking?: Booking;
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
@@ -38,10 +43,14 @@ function makeService(overrides?: {
};
const filesService = {
upsertByCode: jest.fn().mockResolvedValue({}),
upload: jest.fn().mockResolvedValue({}),
deleteByCode: jest.fn().mockResolvedValue(undefined),
findByResource: jest.fn().mockResolvedValue([]),
};
const fileUploadSettingsService = {
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
getByCode: overrides?.docsApproved
? jest.fn().mockResolvedValue({ fields: [] })
: jest.fn().mockRejectedValue(new Error('no setting')),
};
const workflowService = {
assertPriorCompleteForBooking: overrides?.workflowThrows
@@ -49,6 +58,7 @@ function makeService(overrides?: {
: jest.fn().mockResolvedValue(undefined),
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined),
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
@@ -91,7 +101,15 @@ function makeService(overrides?: {
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
} as never, // notifier
{ listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange
{
getAssignable: jest
.fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
);
return {
@@ -186,6 +204,82 @@ describe('BookingClearanceService', () => {
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects an import declaration before Djibouti names the transit officer', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toThrow(/Request a transit assignee/i);
expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled();
});
it('lets the export declaration through without a transit assignee', async () => {
const { service, workflowService } = makeService({
docsApproved: true,
booking: {
...generalExportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await service.uploadDeclaration('b-export', [
{ fieldname: 'decl' } as Express.Multer.File,
]);
expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled();
});
});
describe('transit assignee handshake', () => {
it('refuses an assignment GL Ethiopia never asked for', async () => {
const { service } = makeService({
booking: {
...generalImportBooking,
transitAssigneeRequestedAt: null,
transitAssigneeName: null,
} as Booking,
});
await expect(
service.assignTransitAssignee('b-general', 'Ahmed Bourhan'),
).rejects.toThrow(/has not requested a transit assignee/i);
});
it('stamps the ask and then the name', async () => {
const { service, bookingsRepository } = makeService({
booking: {
...generalImportBooking,
transitAssigneeName: null,
} as Booking,
});
await service.requestTransitAssignee('b-general', ' night shift ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeRequestedAt: expect.any(Date),
transitAssigneeRequestNote: 'night shift',
}),
);
await service.assignTransitAssignee('b-general', ' Ahmed Bourhan ');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
transitAssigneeName: 'Ahmed Bourhan',
transitAssigneeAssignedAt: expect.any(Date),
}),
);
});
});
describe('uploadReleaseOrder', () => {

View File

@@ -1,10 +1,13 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
ContractDocPhase,
isDraftDeclarationFileCode,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
type GlExchangeDocument,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
@@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.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';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -71,12 +76,44 @@ export interface BookingClearanceView {
roAmendmentRequestedAt?: string | null;
operationReady?: boolean;
preClearanceFinalized?: boolean;
/**
* Pre-declaration handshake with GL Djibouti: who handles this shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot
* file the import customs declaration before it is set.
*/
transitAssignee?: {
requestedAt: string | null;
requestNote: string | null;
name: string | null;
assignedAt: string | null;
} | null;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* Import only: the draft customs declaration GL Ethiopia sends before filing
* the real one. Present once a draft has been uploaded, regardless of
* accept state — `accepted` tells the caller which.
*/
draftDeclaration?: {
price: number;
currency: string;
files: Array<{ id: string; name: string; url: string }>;
accepted: boolean;
} | null;
/**
* The customer's open change request on the current draft declaration.
* Present only until GL sends a corrected draft; `rounds` counts how many
* times it has been sent back.
*/
draftDeclarationChangeRequest?: {
note: string;
raisedAt: string;
rounds: number;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: ClearanceT1State | null;
@@ -87,6 +124,8 @@ export interface BookingClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for this booking (what came off the train, and where). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -97,6 +136,8 @@ export interface BookingClearanceView {
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
/** GL-shared documents this booking's uploader marked visible to the customer. */
exchangeDocuments?: GlExchangeDocument[];
}
@Injectable()
@@ -111,15 +152,14 @@ export class BookingClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
private async assertPhasedCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
}
if (booking.contractKind !== 'GENERAL') {
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
}
@@ -127,7 +167,7 @@ export class BookingClearanceService {
private async loadBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
await this.assertPhasedGeneralCustoms(booking);
await this.assertPhasedCustoms(booking);
return booking;
}
@@ -214,6 +254,11 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const draftDeclaration = this.buildDraftDeclaration(files, milestones);
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
bookingId,
milestones,
);
const workflowFiles = buildWorkflowFiles(
files,
booking.tradeDirection ?? 'IMPORT',
@@ -241,6 +286,12 @@ export class BookingClearanceService {
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
// GL↔GL exchange documents shared with the customer. The two desks may work
// the thread on the booking (per-booking customs) or on its contract
// (pre-booking clearance), so the customer's view spans both.
const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer(
[bookingId, booking.contractId ?? ''],
);
return {
bookingId,
@@ -272,8 +323,21 @@ export class BookingClearanceService {
: null,
operationReady: boundary,
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
transitAssignee: {
requestedAt: booking.transitAssigneeRequestedAt
? booking.transitAssigneeRequestedAt.toISOString()
: null,
requestNote: booking.transitAssigneeRequestNote ?? null,
name: booking.transitAssigneeName ?? null,
assignedAt: booking.transitAssigneeAssignedAt
? booking.transitAssigneeAssignedAt.toISOString()
: null,
},
dutyAdvice,
draftDeclaration,
draftDeclarationChangeRequest,
workflowFiles,
exchangeDocuments,
t1,
train,
gatepassGranted: gatepass.granted,
@@ -284,6 +348,7 @@ export class BookingClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: await this.glOperationsService.offloadState(bookingId, milestones),
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -330,6 +395,46 @@ export class BookingClearanceService {
};
}
private buildDraftDeclaration(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): BookingClearanceView['draftDeclaration'] {
const uploaded = milestones.find(
(m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED',
);
if (!uploaded?.metadata) return null;
const price = uploaded.metadata.draftDeclarationPrice;
const currency = uploaded.metadata.draftDeclarationCurrency;
if (typeof price !== 'number' || typeof currency !== 'string') return null;
const draftFiles = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''))
.map((f) => ({ id: f.id, name: f.name, url: f.url }));
const accepted =
milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status ===
'COMPLETED';
return { price, currency, files: draftFiles, accepted };
}
private async buildDraftDeclarationChangeRequest(
bookingId: string,
milestones: ClearanceMilestone[],
): Promise<BookingClearanceView['draftDeclarationChangeRequest']> {
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (!uploaded || uploaded.status === 'COMPLETED') return null;
const notes = await this.bookingsRepository.findReviewNotes(
bookingId,
'DRAFT_DECL_CHANGE_REQUEST',
);
const latest = notes[0];
if (!latest) return null;
return {
note: latest.note,
raisedAt: latest.createdAt.toISOString(),
rounds: notes.length,
};
}
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) return true;
@@ -352,14 +457,59 @@ export class BookingClearanceService {
);
}
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
isPhasedCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) &&
booking.contractKind === 'GENERAL' &&
Boolean(booking.contractId)
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
);
}
/**
* GL Ethiopia asks Djibouti to name the officer who will handle this shipment
* in transit. The import declaration is gated on the answer, so this is the
* first thing ET does once the customer documents are approved. Re-requesting
* is allowed (a nudge) and simply restamps the ask.
*/
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
}
/**
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = booking.transitAssigneeName ?? null;
await this.bookingsRepository.update(bookingId, {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
}
async uploadDeclaration(
bookingId: string,
files: Express.Multer.File[],
@@ -373,6 +523,16 @@ export class BookingClearanceService {
'All required customer documents must be approved before uploading a declaration.',
);
}
// Import only: the declaration is filed against whoever physically handles
// the shipment in Djibouti, so that name must be in first. Exports have no
// such handshake — their Djibouti steps come after the declaration.
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
throw new BadRequestException(
booking.transitAssigneeRequestedAt
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
@@ -398,12 +558,6 @@ 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);
}
@@ -460,6 +614,124 @@ export class BookingClearanceService {
return this.bookingsService.findById(bookingId);
}
/**
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review before the real declaration is filed. Repeatable —
* each call replaces the previous draft's files/price and re-arms the step,
* which is what a re-send after a change request needs.
*/
async uploadDraftDeclaration(
bookingId: string,
files: Express.Multer.File[],
price: number,
currency: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
if (!Number.isFinite(price) || price < 0) {
throw new BadRequestException('A valid estimated price is required.');
}
// Backfills the two new milestone rows for bookings seeded before this step
// existed — a blind complete() 404s on a booking with no such row yet.
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DRAFT_DECLARATION_UPLOADED',
);
await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files);
await this.milestoneService.completeWithMetadataForBooking(
bookingId,
'DRAFT_DECLARATION_UPLOADED',
{ draftDeclarationPrice: price, draftDeclarationCurrency: currency },
userId,
);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
}
/**
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (uploaded?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to accept yet.');
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
return this.bookingsService.findById(bookingId);
}
/**
* The customer sends the draft declaration back with a reason. Nothing is
* filed; the upload milestone reopens so the step becomes actionable again
* for GL Ethiopia, with the customer's message shown beside it. GL re-sends
* (same endpoint as the first time), which closes the request — the loop may
* run as many rounds as it takes.
*/
async requestDraftDeclarationChange(
bookingId: string,
note: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (!note?.trim()) {
throw new BadRequestException(
'Say what needs to change so GL can correct the draft.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to request a change on yet.');
}
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
throw new BadRequestException(
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
'DRAFT_DECL_CHANGE_REQUEST',
userId,
);
// Back to GL: reopening the milestone is what re-arms the step (the
// stepper picks its active step from milestone completion).
await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED');
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
@@ -664,6 +936,10 @@ export class BookingClearanceService {
'RELEASE_ORDER_SECURED',
userId,
);
// Release Order is now the last GL DJ pre-operation action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}
@@ -720,7 +996,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
@@ -733,7 +1009,7 @@ export class BookingClearanceService {
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {

View File

@@ -60,6 +60,11 @@ export class BookingRequestService {
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',

View File

@@ -18,6 +18,8 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true },
DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false },
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },

View File

@@ -353,10 +353,10 @@ export class ClearanceWorkflowService {
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
if (tradeDirection === 'EXPORT') {
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone('RELEASE_ORDER_SECURED')) {
return ContractDocPhase.GlDjCollection;
}
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
return ContractDocPhase.GlEtPostClearance;
}
@@ -450,13 +450,6 @@ export class ClearanceWorkflowService {
: 'Proceed to request operation';
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
@@ -464,6 +457,13 @@ export class ClearanceWorkflowService {
milestoneCode: 'DECLARED',
};
}
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone(EXPORT_BOUNDARY)) {
return {
actor: 'GL_ET',

View File

@@ -24,7 +24,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
@@ -132,6 +131,82 @@ describe('ContractBookingService — quantity-cap completion', () => {
expect(contractsRepository.update).not.toHaveBeenCalled();
});
describe('completion on booking delivery', () => {
function makeDeliveryService(contract: Partial<Contract>) {
const contractsRepository = {
findById: jest.fn().mockResolvedValue(contract),
update: jest.fn().mockResolvedValue(undefined),
};
const bookingsRepository = {
findById: jest
.fn()
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ createdToStaff: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, contractsRepository };
}
it('completes a ONE_TIME contract when its booking is delivered', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
bulk: null,
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
contractKind: 'GENERAL',
status: 'CONTRACT_ACTIVE',
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
});
it('reopens a completed contract when capacity was released', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(

View File

@@ -55,7 +55,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
{} as never, // containerTypesService
{} as never, // ruleEngineService
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{
createdToStaff: jest.fn(),

View File

@@ -0,0 +1,74 @@
import { ForbiddenException } from '@nestjs/common';
import { ContractBookingService } from './contract-booking.service';
import { Contract } from './entities/contract.entity';
/**
* Who may open a shipment instance on a customs (Path B) contract. The customer
* initiates his own ONE_TIME customs booking and uploads the GL-input documents
* on it; GL still clears it and completes it with cargo and price. GENERAL
* customs instances come from a shipment request, and completing/creating a
* customs booking outright stays GL-only.
*/
describe('ContractBookingService — customs booking gate', () => {
function makeService() {
return new ContractBookingService(
{} as never, // contractsRepository
{} as never, // bookingsRepository
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{} as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
}
type WithPrivate = {
assertGate: (
c: Contract,
isGlActor: boolean,
isInitiate?: boolean,
) => Promise<string>;
};
const customsContract = (contractKind: 'ONE_TIME' | 'GENERAL'): Contract =>
({
id: 'c-1',
contractKind,
status: 'FULLY_EXECUTED',
customsClearingEnabled: true,
}) as Contract;
const gate = (c: Contract, isGl: boolean, isInitiate?: boolean) =>
(makeService() as never as WithPrivate).assertGate(c, isGl, isInitiate);
it('lets the customer initiate a ONE_TIME customs shipment', async () => {
await expect(gate(customsContract('ONE_TIME'), false, true)).resolves.toBe(
'CUSTOMER',
);
});
it('still lets GL initiate on the customer behalf', async () => {
await expect(gate(customsContract('ONE_TIME'), true, true)).resolves.toBe(
'GL_ET',
);
});
it('rejects a customer creating a customs booking outright (cargo + day)', async () => {
await expect(gate(customsContract('ONE_TIME'), false)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('rejects a customer initiating a GENERAL customs shipment (request only)', async () => {
await expect(
gate(customsContract('GENERAL'), false, true),
).rejects.toBeInstanceOf(ForbiddenException);
});
});

View File

@@ -37,16 +37,20 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import {
ContractsRepository,
TERMINAL_BOOKING_STATUSES,
} from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking
// slot) lives in contracts.repository.ts — the contract cancel gate needs the
// same list.
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
@@ -94,7 +98,6 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@@ -187,21 +190,14 @@ export class ContractBookingService {
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
// operations, and there is NO contract-level clearance cycle to link.
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
// per-booking gate with the intercity document set — ops finalize then puts
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
// intercity has no shipment-day request step.
const generalSelfClear =
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
// EVERY contract booking clears per booking now — both contract kinds, both
// paths, intercity included. Customs (Path B): GL runs the phased ET/DJ
// workflow on this booking. Non-customs (Path A) and intercity: the customer
// uploads his own document set on the booking and Operations reviews it
// (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation; intercity finalize goes straight to the ride-along pool).
// So the booking is always born in the clearance gate, never in the
// operations queue, and no contract-level clearance cycle exists to link.
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
@@ -218,24 +214,11 @@ export class ContractBookingService {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Bookings that enter the
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
// are scheduled later, so they are not gated here.
if (!generalCustoms && !generalSelfClear && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
// EXPORT rides whole or not at all (no split concept): reject the booking
// up front when no single open train on the day can carry it, telling the
// customer how much space is still bookable.
await this.assertExportTrainSpace(contract, route, dto);
}
// No booking-window / export-space gate here any more: every contract
// booking enters the clearance gate first and is scheduled only once the
// documents are approved. Both checks run at that point instead —
// `completeUnderContract` (bare instances) and `requestOperation` (bookings
// created with cargo) — against the day the customer actually picks.
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
@@ -265,10 +248,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -375,10 +355,7 @@ export class ContractBookingService {
// exactly once whether the booking parks for a partner or finalizes inline.
this.bookingNotifier.createdToStaff(withContainers ?? booking);
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const intendedStatus = 'AWAITING_DOCUMENTS';
if (
withContainers &&
freightType === 'CONTAINER' &&
@@ -404,11 +381,7 @@ export class ContractBookingService {
}
}
await this.finalizeContractBooking(
booking.id,
contract,
generalCustoms,
);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
@@ -417,13 +390,22 @@ export class ContractBookingService {
}
/**
* Initiate a BARE booking instance under a GENERAL non-customs contract
* (Path A per-booking self-clearance). One click, zero input: no schedule
* date, no cargo, no window check, no pricing. The instance starts in the
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
* Operations reviews and finalizes, and only then does the customer complete
* the booking (cargo + binding day + window check) via
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
* Initiate a BARE booking instance under an import/export contract — ONE_TIME
* or GENERAL, customs or not. One click, zero input: no schedule date, no
* cargo, no window check, no pricing. The instance starts in the clearance
* gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live:
*
* - Path A (self-clearance): the customer initiates, uploads his clearance
* proof, Operations reviews and finalizes.
* - Path B (customs, ONE_TIME): the customer initiates too, then uploads the
* GL-input documents on the instance; GL approves them and runs the phased
* ET/DJ workflow (pre-booking milestones are seeded here). GL may still
* initiate on his behalf. GENERAL customs instances come from a shipment
* request ({@link initiateForShipmentRequest}), not from here.
*
* Only after the clearance is finalized is the booking completed (cargo +
* binding day + window check) via {@link completeUnderContract} — by the
* customer on Path A, by GL on Path B.
*/
async initiateUnderContract(
contractId: string,
@@ -434,13 +416,12 @@ export class ContractBookingService {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
if (!generalSelfClear) {
// Intercity has no shipment day to defer to, so it is booked directly with
// its cargo (the documents still live on that booking). Everything else —
// ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance.
if (contract.tradeDirection === 'DOMESTIC') {
throw new BadRequestException(
'Initiate booking applies only to general import/export contracts without customs clearing.',
'Intercity shipments are booked directly with their cargo — there is no initiate step.',
);
}
@@ -453,12 +434,28 @@ export class ContractBookingService {
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
// The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here.
const createdByRole = await this.assertGate(contract, isGlActor, true);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
// completion enforces that the next booking takes the whole remainder.
if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Bare instance: no cargo, no date, no price. Draws no contract capacity
@@ -503,6 +500,16 @@ export class ContractBookingService {
} as never),
);
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
// milestones exist from initiation (the post-booking half is seeded when the
// booking is completed). Self-clearance has no milestone timeline.
if (contract.customsClearingEnabled) {
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
@@ -718,6 +725,15 @@ export class ContractBookingService {
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
if (!hasCargo) {
// ONE_TIME split chain: the instance that follows a paid partial must take
// the WHOLE outstanding remainder — same rule a booking created with cargo
// passes at creation.
if (
contract.contractKind === 'ONE_TIME' &&
(await this.hasSplitBooking(contract.id))
) {
await this.assertExactRemainder(contract, dto);
}
await this.assertWithinQuantityCap(contract, dto);
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
@@ -817,10 +833,7 @@ export class ContractBookingService {
// Invoice the now-priced booking and, for a customs instance, seed the
// post-booking milestones (pre-booking ones exist since initiation —
// ensure* fills only what is missing). Idempotent, non-blocking.
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.finalizeContractBooking(booking.id, contract);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
@@ -886,33 +899,17 @@ export class ContractBookingService {
private async finalizeContractBooking(
bookingId: string,
contract: Contract,
generalCustoms: boolean,
): Promise<void> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, bookingId);
}
await this.milestoneService.seedPostBookingMilestones(
bookingId,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed the full milestone timeline on the booking.
// ensure* skips codes that already exist — an initiated instance carries
// its pre-booking milestones from initiation, and a consolidation pairing
// replay must not duplicate the timeline.
// Customs runs per booking for BOTH contract kinds: seed the full milestone
// timeline on the booking. ensure* skips codes that already exist — an
// initiated instance carries its pre-booking milestones from initiation, and
// a consolidation pairing replay must not duplicate the timeline. The
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
// holds no clearance state at all.
if (contract.customsClearingEnabled) {
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
@@ -982,10 +979,7 @@ export class ContractBookingService {
booking.contractId,
);
if (!contract) continue;
const generalCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
await this.finalizeContractBooking(id, contract).catch(
(err) =>
this.logger.error(
`Failed to finalize paired contract booking ${booking.reference}: ${
@@ -1000,36 +994,38 @@ export class ContractBookingService {
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
private async assertGate(
contract: Contract,
isGlActor: boolean,
isInitiate = false,
): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason.
if (contract.status === 'SUSPENDED') {
throw new BadRequestException(
'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.',
);
}
if (contract.customsClearingEnabled) {
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
// The customer never books a customs contract himself.
if (!isGlActor) {
// Path B — the customer OPENS the shipment instance on a ONE_TIME customs
// contract (one click, no cargo) and uploads the GL-input documents on it;
// GL still runs the phased ET/DJ clearance and completes the booking with
// cargo, day and price. A GENERAL customs instance is opened by a shipment
// request instead, and completing any customs booking stays GL-only.
const customerMayInitiate = isInitiate && contract.contractKind === 'ONE_TIME';
if (!isGlActor && !customerMayInitiate) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
if (contract.contractKind === 'GENERAL') {
// GENERAL customs has NO contract clearance cycle — GL books per accepted
// shipment request while the contract is active; clearance is per booking.
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
'Contract must be active to book a shipment.',
);
}
return 'GL_ET';
}
// ONE_TIME customs — pre-booking boundary milestone must be complete.
const boundaryOk = await this.workflowService.isBoundaryComplete(
contract.id,
contract.tradeDirection,
);
if (!boundaryOk) {
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Pre-booking clearance is not complete — booking cannot be created yet.',
'Contract must be fully executed before booking a shipment.',
);
}
return 'GL_ET';
return isGlActor ? 'GL_ET' : 'CUSTOMER';
}
// Path A — customer (or staff) once the contract is executed.
@@ -1041,6 +1037,40 @@ export class ContractBookingService {
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
/**
* GL fallback worklist: executed ONE_TIME customs contracts with no live
* shipment instance yet. The customer normally opens it himself from the
* portal; this list lets GL do it on his behalf, and shows the contracts that
* are on no other queue (clearance lives on the booking, which does not exist
* yet). GENERAL customs is excluded — opened by shipment requests.
*/
async awaitingShipmentContracts(): Promise<Contract[]> {
const { items } = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: ['FULLY_EXECUTED'],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: 'createdAt',
sortOrder: 'DESC',
} as never);
const out: Contract[] = [];
for (const contract of items) {
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
continue;
}
// A split chain frees the slot for the remainder, so those contracts stay
// on the list even while the paid partial booking still exists.
if (await this.hasSplitBooking(contract.id)) {
out.push(contract);
continue;
}
if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract);
}
return out;
}
private async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
@@ -1418,6 +1448,53 @@ export class ContractBookingService {
];
}
/**
* A ONE_TIME contract carries exactly one shipment: once that booking is
* delivered (COMPLETED) the contract is fulfilled and moves to
* CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and
* blocking any further booking. A split ONE_TIME is the exception: its
* remainder chain must be rebooked and delivered first, so the contract stays
* open while the split remainder is outstanding.
*
* GENERAL contracts are untouched — they close on cap exhaustion or expiry.
* Best-effort: a status hiccup must never fail the booking that completed.
*/
@OnEvent('booking.completed')
async onBookingCompleted(payload: { bookingId: string }): Promise<void> {
try {
const booking = await this.bookingsRepository.findById(payload.bookingId);
if (!booking?.contractId) return;
const contract = await this.contractsRepository.findById(booking.contractId);
if (!contract || contract.contractKind === 'GENERAL') return;
// Already closed/expired/cancelled — nothing to do.
if (isEffectivelyExpired(contract)) return;
const outstanding = await this.splitOutstanding(contract);
if (outstanding) {
// 0.001 tolerance absorbs bulk-ton float rounding, same as the
// cap-exhaustion path below.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`,
);
} catch (err) {
this.logger.error(
`Could not close contract for completed booking ${payload.bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract, and under a ONE_TIME

View File

@@ -7,6 +7,7 @@ import {
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
@@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
@@ -34,7 +36,7 @@ import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -140,6 +142,8 @@ export interface ContractClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for the linked booking (null until one exists). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -165,6 +169,7 @@ export class ContractClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -433,6 +438,9 @@ export class ContractClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: cycle?.bookingId
? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones)
: null,
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -1060,46 +1068,6 @@ export class ContractClearanceService {
});
}
/**
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
/**
* Statuses a non-customs contract passes through around Operations
* clearance review — the set a caller may narrow {@link opsQueue} to.
*/
private static readonly OPS_CLEARANCE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'CONTRACT_CLOSED',
'CANCELLED',
];
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
// Callers may narrow to any subset of the ops-clearance lifecycle (the
// hub's status filter sends an explicit list); anything outside the
// whitelist is dropped so this endpoint can't become a general contract
// browser. No statuses given → the original under-review queue.
const requested = (filter.statuses ?? filter.status ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) =>
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
);
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** GL ET history: contracts that completed Path B clearance. */
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
@@ -1157,20 +1125,18 @@ export class ContractClearanceService {
}
/**
* GL Djibouti names the transit officer — free text, because the person is
* not a platform user. Answering unblocks the declaration for Ethiopia. A
* later call overwrites the name (reassignment) and re-notifies.
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(
contractId: string,
assignee: string,
transitAgentId: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (!cycle.transitAssigneeRequestedAt) {
@@ -1178,16 +1144,17 @@ export class ContractClearanceService {
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = cycle.transitAssigneeName ?? null;
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeName: assignee.trim(),
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
transitAssigneeAssignedByUserId: userId ?? null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
return updated;
}
@@ -1226,6 +1193,15 @@ export class ContractClearanceService {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
await this.ensureDeclarationPrerequisites(contractId, contract);
// The draft-declaration accept/change-request loop only exists on the
// booking-scoped clearance page (portal customers never see contract-scoped
// clearance) — skip it here so it can never block the ONE_TIME pre-booking
// flow, which has no UI to complete it. Contracts seeded before this step
// existed have no such row to skip — ignore, `assertPriorComplete` below
// already tolerates a missing milestone as "not required".
await this.workflowService
.skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED'])
.catch(() => undefined);
await this.workflowService.assertPriorComplete(
contractId,
contract.tradeDirection,
@@ -1255,12 +1231,6 @@ 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);
}
@@ -1601,6 +1571,10 @@ export class ContractClearanceService {
currentPhase: ContractDocPhase.GlEtOutput,
});
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
// Release Order is now the last GL DJ pre-booking action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleased(contractId, userId);
return { contract: await this.contractsService.findById(contractId), hold: false };
}
@@ -1693,80 +1667,4 @@ export class ContractClearanceService {
return this.contractsService.findById(contractId);
}
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const cycle = await this.contractsRepository.currentCycle(c.id);
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
filtered.push(c);
}
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
}

View File

@@ -0,0 +1,79 @@
import { ConflictException } from '@nestjs/common';
import { ContractsService } from './contracts.service';
import type { CreateContractDto } from './dto/create-contract.dto';
/**
* The duplicate guard blocks a new request only when EVERY commercial
* dimension matches a live contract — service type, operation type, contract
* kind, cargo scope and route. Any one differing must let the request through.
*/
describe('ContractsService duplicate guard', () => {
const LANE = { originYardId: 'yard-dj', destinationYardId: 'yard-mj' };
const existing = {
id: 'c-1',
reference: 'CTR-2026-00001',
status: 'PENDING_APPROVAL',
contractValidUntil: null,
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
};
const dto = (overrides: Partial<CreateContractDto> = {}) =>
({
serviceTypeId: 'svc-1',
tradeDirection: 'IMPORT',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
routes: [LANE],
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
...overrides,
}) as CreateContractDto;
const guard = (input: CreateContractDto) => {
const service = new ContractsService(
{} as never,
{ findDuplicateCandidates: async () => [existing] } as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
return (
service as unknown as {
assertNoDuplicateContract(companyId: string, dto: CreateContractDto): Promise<void>;
}
).assertNoDuplicateContract('company-1', input);
};
it('blocks an identical request', async () => {
await expect(guard(dto())).rejects.toBeInstanceOf(ConflictException);
});
it.each([
['operation type', { tradeDirection: 'EXPORT' }],
['contract kind', { contractKind: 'GENERAL' }],
['freight type', { freightType: 'BULK' }],
['cargo scope', { cargoScope: [{ containerSize: '20ft' }] }],
['route', { routes: [{ originYardId: 'yard-dj', destinationYardId: 'yard-aa' }] }],
])('allows a request with a different %s', async (_label, overrides) => {
await expect(guard(dto(overrides as Partial<CreateContractDto>))).resolves.toBeUndefined();
});
it('ignores quantity caps when comparing cargo scope', async () => {
await expect(
guard(
dto({
cargoScope: [
{ containerSize: '20ft', quantityCap: 10 },
{ containerSize: '40ft', quantityCap: 5 },
],
}),
),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // dropdownSettingsService
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),

View File

@@ -143,6 +143,33 @@ export class ContractNotifierService {
this.inApp(c, 'Contract rejected', msg);
}
/** Backoffice froze the contract — every action on it is blocked until lifted. */
suspended(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} has been suspended. Reason: ${reason}. ` +
`No new shipments can be booked and existing shipments are on hold until the suspension is lifted.`;
void this.notifyContact(c, msg, 'SUSPENDED');
this.inApp(c, 'Contract suspended', msg);
}
/** Backoffice lifted the suspension — the contract resumes where it left off. */
suspensionLifted(c: Contract, note?: string | null): void {
const msg =
`The suspension on your contract ${c.reference} has been lifted. ` +
`You can continue where you left off.${note ? ` Note: ${note}` : ''}`;
void this.notifyContact(c, msg, 'SUSPENSION LIFTED');
this.inApp(c, 'Contract suspension lifted', msg);
}
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(
c,
'Contract cancelled by customer',
`Contract ${c.reference} was cancelled by the customer. Reason: ${reason}`,
);
}
/**
* A later approver sent the contract back to an earlier stage of the chain.
* Staff-only: the customer is not involved in an internal send-back — their

View File

@@ -0,0 +1,132 @@
import { ContractTransitionService } from './contract-transition.service';
import type { Contract } from './entities/contract.entity';
/**
* Suspension is only worth having if it is reversible and if it actually
* freezes things, and the customer's own cancel is only safe while no shipment
* is running. Those three rules are the whole feature — everything else is
* plumbing.
*/
describe('ContractTransitionService — suspend / resume / customer cancel', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
status: 'CONTRACT_ACTIVE',
freightType: 'CONTAINER',
...over,
}) as Contract;
let current: Contract;
let repo: {
update: jest.Mock;
createReviewNote: jest.Mock;
countActiveBookings: jest.Mock;
};
let notifier: {
suspended: jest.Mock;
suspensionLifted: jest.Mock;
cancelledByCustomer: jest.Mock;
};
let service: ContractTransitionService;
/** A staff user holding the suspend key — authorization is tested elsewhere. */
const staff = {
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
};
beforeEach(() => {
current = contract();
repo = {
// Mirror the real repository: the update patches the row the next
// findById returns, so resume() reads what suspend() wrote.
update: jest.fn().mockImplementation((_id: string, patch: object) => {
current = { ...current, ...patch } as Contract;
return Promise.resolve(current);
}),
createReviewNote: jest.fn().mockResolvedValue(undefined),
countActiveBookings: jest.fn().mockResolvedValue(0),
};
notifier = {
suspended: jest.fn(),
suspensionLifted: jest.fn(),
cancelledByCustomer: jest.fn(),
};
// These three transitions touch only the repository, the read-back service
// and the notifier — the other 14 constructor deps stay unused, so the
// instance is built bare and only what is exercised is injected.
service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsRepository: repo,
contractsService: { findById: () => Promise.resolve(current) },
notifier,
});
});
it('freezes at the current step and remembers where to come back to', async () => {
current = contract({ status: 'CLEARANCE_UNDER_REVIEW' });
await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith('c-1', {
status: 'SUSPENDED',
statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW',
});
expect(notifier.suspended).toHaveBeenCalled();
});
it('restores the pre-suspension status when the suspension is lifted', async () => {
current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' });
await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never);
await service.resume('c-1', undefined, 'staff-1', staff as never);
expect(repo.update).toHaveBeenLastCalledWith('c-1', {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
statusBeforeSuspension: null,
});
});
it('refuses to suspend a contract the customer has not signed yet', async () => {
current = contract({ status: 'PENDING_APPROVAL' });
await expect(
service.suspend('c-1', 'too early', 'staff-1', staff as never),
).rejects.toThrow(/PENDING_APPROVAL/);
expect(repo.update).not.toHaveBeenCalled();
});
it('lets the customer cancel a contract with no live shipment', async () => {
await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1');
expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' });
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
'Changed supplier',
'CANCELLATION',
'user-1',
'CUSTOMER',
);
});
it('blocks the customer cancel while a shipment is still running', async () => {
repo.countActiveBookings.mockResolvedValue(2);
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/2 active shipments/);
expect(repo.update).not.toHaveBeenCalled();
});
it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => {
current = contract({ status: 'SUSPENDED' });
await expect(
service.cancelByCustomer('c-1', undefined, 'user-1'),
).rejects.toThrow(/suspended/);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -28,6 +28,7 @@ import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -38,10 +39,8 @@ import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import {
Contract,
ContractDocumentArticle,
@@ -131,6 +130,21 @@ function maskSignerContacts(contacts: { phone?: string; email?: string }): strin
.join(' and ');
}
/**
* Where the backoffice may freeze a contract: every step from the customer's
* signature onward, up to (but not including) the terminal states. Suspending
* an unsigned contract is meaningless — staff reject or request changes there.
*/
export const SUSPENDABLE_CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
@@ -154,7 +168,6 @@ export class ContractTransitionService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly filesService: FilesService,
private readonly signaturesService: SignaturesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
@@ -212,6 +225,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
@@ -228,6 +242,7 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
submittedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
@@ -1262,43 +1277,16 @@ export class ContractTransitionService {
lockedAt: now,
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
// intercity document set (DOMESTIC, ops-reviewed like Path A).
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled ?? false,
);
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
// both paths. Customs (Path B): the customer files shipment requests, GL
// books each one and the booking carries its own clearance. Self-clearance
// (Path A): the customer books, then uploads the clearance docs on that
// booking for Operations to review. Only ONE_TIME contracts keep the
// contract-level cycle below.
const isGeneral = contract.contractKind === 'GENERAL';
if (clearanceCode && !isGeneral) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
// No prepay gate: the customs clearance service fee (Path B) is billed on
// the booking invoice together with the freight, so the document step
// opens immediately.
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
// (which clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
}
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
// intercity. A signed contract carries no clearance cycle and collects no
// documents: the shipment instance created after signature does. Customs
// (Path B): the customer initiates the booking (GENERAL: via a shipment
// request) and uploads on it, GL reviews and completes it. Self-clearance
// (Path A) and intercity: the customer initiates/books and Operations
// reviews the booking documents.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
@@ -1308,6 +1296,123 @@ export class ContractTransitionService {
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
/**
* Backoffice freeze, available at every step from the customer signature
* onward. The pre-suspension status is stashed so {@link resume} can put the
* contract back exactly where it was — a suspension you cannot lift is just a
* cancellation under another name.
*
* While SUSPENDED nothing moves: no new bookings or shipment requests
* (ContractBookingService / BookingRequestService), and no writes to the
* contract's existing bookings (BookingsRepository.update).
*/
async suspend(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'SUSPENSION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'SUSPENDED',
statusBeforeSuspension: contract.status,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspended(updated, reason);
return updated;
}
/** Lift a suspension — the contract returns to the status it was frozen at. */
async resume(
contractId: string,
note: string | undefined,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
assertContractStatus(contract, ['SUSPENDED']);
// Legacy safety net: a row suspended before the column existed has nothing
// to restore. CONTRACT_ACTIVE is the post-signature resting state for both
// contract kinds, so it is the only sane default.
const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE';
if (note?.trim()) {
await this.contractsRepository.createReviewNote(
contractId,
note.trim(),
'SUSPENSION_LIFTED',
actorId,
'STAFF',
);
}
await this.contractsRepository.update(contractId, {
status: restored,
statusBeforeSuspension: null,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.suspensionLifted(updated, note ?? null);
return updated;
}
/**
* Customer cancels their own contract so they can request a fresh one for the
* same lane — the duplicate-contract guard treats CANCELLED as released.
* Blocked while any booking on the contract is still live: cancelling a
* contract with cargo in motion would strand it.
*/
async cancelByCustomer(
contractId: string,
reason: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
throw new ConflictException(
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
);
}
if (contract.status === 'SUSPENDED') {
throw new ConflictException(
'This contract is suspended by EDR — contact us to lift the suspension first.',
);
}
const active = await this.contractsRepository.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
'Cancel or complete them before cancelling the contract.',
);
}
const body = reason?.trim() || 'Cancelled by the customer.';
await this.contractsRepository.createReviewNote(
contractId,
body,
'CANCELLATION',
userId,
'CUSTOMER',
);
await this.contractsRepository.update(contractId, {
status: 'CANCELLED',
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.cancelledByCustomer(updated, body);
return updated;
}
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
import {
CancelContractDto,
RejectContractDto,
RejectStepDto,
RequestChangesDto,
ResumeContractDto,
SuspendContractDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
@@ -273,6 +276,18 @@ export class ContractsController {
return this.clearanceService.queue(filter);
}
// Must stay ABOVE @Get(':id') — declared after it, Nest matched the literal
// path as an id and ParseUUIDPipe answered 400 "uuid is expected".
@Get('awaiting-shipment')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.',
})
awaitingShipmentContracts() {
return this.contractBookingService.awaitingShipmentContracts();
}
@Get(':id')
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
async findOne(
@@ -453,6 +468,65 @@ export class ContractsController {
);
}
@Post(':id/suspend')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({
summary: 'Staff freeze a signed contract (reversible, any post-signature step)',
})
suspend(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SuspendContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.suspend(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/resume')
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
@ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' })
resume(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ResumeContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.resume(
id,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/cancel')
@ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)',
})
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelContractDto,
@CurrentUser() user: TCurrentUser,
) {
// Same ownership rule as renew: staff with bookings.view/contracts.view pass
// through, everyone else must own the contract's company.
const contract = await this.contractsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.transitionService.cancelByCustomer(
id,
dto.reason,
resolveAuthUserId(user),
);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@@ -763,16 +837,16 @@ export class ContractsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
assignTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.assignTransitAssignee(
id,
assignee,
transitAgentId,
resolveAuthUserId(user),
);
}
@@ -924,31 +998,8 @@ export class ContractsController {
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
}
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
etClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.etQueue(filter);
}
@Get('clearance/dj-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
djClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.djQueue(filter);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
})
opsClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.opsQueue(filter);
}
@Post(':id/clearance/ops-review')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
@@ -1017,7 +1068,7 @@ export class ContractsController {
@Post(':id/bookings/initiate')
@ApiOperation({
summary:
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
})
initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@@ -1243,6 +1294,20 @@ export class ContractsController {
);
}
@Post('bookings/:bookingId/final-invoice/approve')
@ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
})
approveFinalInvoice(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.approveFinalInvoice(
bookingId,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@@ -1335,7 +1400,7 @@ export class ContractsController {
) {
const file = (files ?? [])[0];
const booking = await this.bookingsService.findById(bookingId);
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) {
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
}
return this.glOperationsService.uploadDutySlip(bookingId, file);

View File

@@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeController } from './gl-exchange.controller';
import { GlExchangeService } from './gl-exchange.service';
import { BookingRequestService } from './booking-request.service';
import { BookingRequestRepository } from './booking-request.repository';
@@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// Provides the admin-editable contract document templates consumed by
// ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule,
TransitAgentsModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),
@@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [ContractsController],
controllers: [ContractsController, GlExchangeController],
providers: [
ContractsService,
ContractsRepository,
@@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractBookingService,
ClearanceMilestoneService,
GlOperationsService,
GlExchangeService,
BookingRequestService,
BookingRequestRepository,
// Contract PDF providers (template resolution + render + PDF) — stateless
@@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
BookingClearanceService,
ContractBookingService,
ClearanceMilestoneService,
GlExchangeService,
],
})
export class ContractsModule {}

View File

@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { Contract } from './entities/contract.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
@@ -16,6 +17,18 @@ import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
/**
* Booking statuses that release whatever the booking was holding — contract
* capacity, the one-time active slot, the cancel gate. Everything else counts
* as a live booking.
*/
export const TERMINAL_BOOKING_STATUSES = [
'EXPIRED',
'CANCELLED',
'COMPLETED',
'REJECTED',
];
export interface ContractListFilterOptions {
statuses?: string[];
status?: string;
@@ -68,8 +81,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
}
/**
* Non-terminal contracts for the same company + service type, with routes
* loaded — candidates for the duplicate-contract check on create(). Terminal
* Non-terminal contracts for the same company + service type, with routes and
* cargo scope loaded — candidates for the duplicate-contract check on
* create() (which also compares operation type, kind and scope). Terminal
* filtering happens in JS via isEffectivelyExpired (also covers the
* date-passed-but-not-yet-cron-flipped case).
*/
@@ -80,6 +94,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
return this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.routes', 'routes')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.where('contract.deleted_at IS NULL')
.andWhere('contract.company_id = :companyId', { companyId })
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
@@ -541,6 +556,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
// ── Review notes ──────────────────────────────────────────────────────────────
/**
* Bookings on the contract that have not reached a terminal state. Gates the
* customer's own contract cancellation (a contract carrying live cargo may
* not be cancelled) and is surfaced on the detail response so the portal can
* disable the button instead of failing the call.
*/
async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.getCount();
}
async createReviewNote(
contractId: string,
body: string,

View File

@@ -10,7 +10,7 @@ import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
//
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
@@ -73,6 +73,30 @@ function describeCargoScope(scope?: ContractCargoScope[]): string | null {
.join(', ');
}
/**
* Order-independent identity of a cargo scope — two contracts cover the same
* cargo only when they list the same container sizes / commodities. Quantity
* caps are deliberately ignored: they size a GENERAL contract, they don't make
* it a different scope.
*/
function cargoScopeKey(
scope?: Array<
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
> | null,
): string {
if (!scope?.length) return '';
return scope
.map((row) =>
[
row.containerSize?.trim().toLowerCase() ?? '',
row.cargoTypeId ?? '',
row.cargoFreeText?.trim().toLowerCase() ?? '',
].join('|'),
)
.sort()
.join(',');
}
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
@@ -190,25 +214,34 @@ export class ContractsService {
}
/**
* Same customer + same service type + an overlapping route already has a
* non-expired contract → block. A route "overlaps" if any origin/destination
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
* carry a single route in practice, and still correct if that changes.
* A live contract only blocks a new request when EVERY commercial dimension
* of the wizard matches it: service type, operation type (trade direction),
* contract kind, cargo scope and route. Change any one of them — a different
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
* customer may request another contract.
*
* A route "overlaps" if any origin/destination pair matches; cargo scope
* matches only when the two scope sets are identical (same freight type and
* the same container sizes / commodities).
*/
private async assertNoDuplicateContract(
companyId: string,
serviceTypeId: string,
routes: CreateContractDto['routes'],
dto: CreateContractDto,
): Promise<void> {
const candidates = await this.contractsRepository.findDuplicateCandidates(
companyId,
serviceTypeId,
dto.serviceTypeId,
);
const incomingScope = cargoScopeKey(dto.cargoScope);
const duplicate = candidates.find(
(c) =>
!isEffectivelyExpired(c) &&
c.tradeDirection === dto.tradeDirection &&
c.contractKind === dto.contractKind &&
c.freightType === dto.freightType &&
cargoScopeKey(c.cargoScope) === incomingScope &&
(c.routes ?? []).some((existingRoute) =>
routes.some(
dto.routes.some(
(r) =>
r.originYardId === existingRoute.originYardId &&
r.destinationYardId === existingRoute.destinationYardId,
@@ -220,7 +253,7 @@ export class ContractsService {
? duplicate.contractValidUntil.toISOString().slice(0, 10)
: 'its approval completes';
throw new ConflictException(
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
`An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
);
}
}
@@ -257,7 +290,7 @@ export class ContractsService {
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
if (companyId) {
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
await this.assertNoDuplicateContract(companyId, dto);
}
// Stamp the operational profile for portal scoping. A forwarder contract
@@ -813,6 +846,24 @@ export class ContractsService {
}
}
// Why the contract is frozen — shown to staff and customer alike.
if (contract.status === 'SUSPENDED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'SUSPENSION',
);
contract.latestSuspensionNote = note?.body ?? null;
} catch {
contract.latestSuspensionNote = null;
}
}
// Lets the portal disable "Cancel contract" instead of letting the customer
// click it and read a 400. The API re-checks on cancel regardless.
contract.activeBookingCount =
await this.contractsRepository.countActiveBookings(contract.id);
return contract;
}

View File

@@ -50,3 +50,17 @@ export class CancelContractDto {
@IsString()
reason?: string;
}
export class SuspendContractDto {
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
@IsString()
@MinLength(1)
reason!: string;
}
export class ResumeContractDto {
@ApiPropertyOptional({ description: 'Optional note recorded when the suspension is lifted' })
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -46,6 +46,9 @@ export interface MilestoneMetadata {
declarationSerial?: string;
/** When the gate pass was physically granted (GL DJ captures the time). */
gatepassAt?: string;
/** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */
draftDeclarationPrice?: number;
draftDeclarationCurrency?: string;
}
/**

View File

@@ -13,6 +13,12 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
* correct it. One row per round — the advice/dispute loop can repeat.
*/
'DUTY_DISPUTE',
/** Backoffice froze the contract; body is the reason shown to the customer. */
'SUSPENSION',
/** Backoffice lifted a suspension; body is the optional lift note. */
'SUSPENSION_LIFTED',
/** Customer cancelled their own contract; body is their reason. */
'CANCELLATION',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];

View File

@@ -29,6 +29,8 @@ export const CONTRACT_STATUSES = [
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
// Reversible backoffice freeze — see statusBeforeSuspension.
'SUSPENDED',
'CONTRACT_CLOSED',
'EXPIRED',
'REJECTED',
@@ -214,9 +216,21 @@ export class Contract extends BaseEntity {
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
/** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
/**
* Status the contract held when the backoffice suspended it, restored when
* the suspension is lifted. Null unless the contract is (or once was)
* SUSPENDED. A suspension without this would just be a cancellation.
*/
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
statusBeforeSuspension?: string | null;
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
clearanceStatus!: string;
@@ -343,4 +357,18 @@ export class Contract extends BaseEntity {
* contract_review_notes, not a column here.
*/
latestSendBackNote?: string | null;
/**
* Body of the most recent SUSPENSION review note, attached by
* ContractsService.findById while the contract is SUSPENDED so both sides see
* why it was frozen. Lives in contract_review_notes, not a column here.
*/
latestSuspensionNote?: string | null;
/**
* Count of this contract's non-terminal bookings, attached by
* ContractsService.findById. The portal disables customer cancellation while
* it is > 0 (the API enforces the same). Not a column.
*/
activeBookingCount?: number;
}

View File

@@ -0,0 +1,102 @@
import { BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { GlOperationsService } from './gl-operations.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The GL Djibouti final invoice lands as a DRAFT: the customer must approve it
* (which issues it) before a payment slip is accepted.
*/
describe('GlOperationsService — final invoice approval', () => {
const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({
id: 'inv-1',
invoiceNumber: 'INV-1',
status,
totalAmount: 1500,
currency: 'ETB',
issuedAt,
paidAt: null,
});
let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock };
let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock };
let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock };
let service: GlOperationsService;
beforeEach(() => {
billingService = {
findInvoice: jest.fn(),
updateStatus: jest.fn().mockResolvedValue(undefined),
};
filesService = {
findByResource: jest.fn().mockResolvedValue([]),
upsertByCode: jest.fn().mockResolvedValue(undefined),
};
notifier = {
finalInvoiceApprovedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
};
const dataSource = {
getRepository: (entity: unknown) =>
entity === Booking
? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) }
: { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) },
};
service = new GlOperationsService(
dataSource as never,
filesService as never,
{} as never, // milestoneService
billingService as never,
notifier as never,
);
});
it('issues the draft on customer approval and reports approvedAt', async () => {
const issued = new Date('2026-07-28T09:00:00.000Z');
billingService.findInvoice
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft))
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued));
const summary = await service.approveFinalInvoice('bk-1', 'user-1');
expect(billingService.updateStatus).toHaveBeenCalledWith(
'inv-1',
Freight.InvoiceStatus.Issued,
);
expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled();
expect(summary.approvedAt).toBe(issued.toISOString());
});
it('is a no-op when the invoice was already approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.approveFinalInvoice('bk-1');
expect(billingService.updateStatus).not.toHaveBeenCalled();
});
it('refuses a payment slip while the invoice is still a draft', async () => {
billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft));
await expect(
service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never),
).rejects.toThrow(BadRequestException);
expect(filesService.upsertByCode).not.toHaveBeenCalled();
});
it('accepts the payment slip once approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never);
expect(filesService.upsertByCode).toHaveBeenCalledWith(
expect.objectContaining({ code: 'final_invoice_slip' }),
);
});
});

View File

@@ -0,0 +1,133 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import {
GlExchangeService,
type GlExchangeActor,
type GlExchangeSide,
} from './gl-exchange.service';
/** Either GL desk may read and post; ownership decides who may edit. */
const GL_EXCHANGE_PERMS = [
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
];
/** Multipart bodies arrive as strings — "true"/"1" mean checked. */
const asBool = (raw: string | boolean | undefined): boolean =>
raw === true || raw === 'true' || raw === '1';
@ApiTags('gl-exchange')
@ApiBearerAuth()
@Controller('gl-exchange')
export class GlExchangeController {
constructor(private readonly exchangeService: GlExchangeService) {}
@Get(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@ApiOperation({
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
})
list(
@Param('entityId', ParseUUIDPipe) entityId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.list(entityId, resolveAuthUserId(user));
}
@Post(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Share a document with the other GL desk' })
upload(
@Param('entityId', ParseUUIDPipe) entityId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.upload(
entityId,
file,
{ title, visibleToCustomer: asBool(visibleToCustomer) },
this.actor(user),
);
}
@Patch('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Uploader edits a shared document (title, visibility, file)',
})
update(
@Param('documentId', ParseUUIDPipe) documentId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string | undefined,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.update(
documentId,
{
title,
visibleToCustomer:
visibleToCustomer == null ? undefined : asBool(visibleToCustomer),
},
file,
resolveAuthUserId(user),
);
}
@Delete('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@HttpCode(204)
@ApiOperation({ summary: 'Uploader removes a shared document' })
async remove(
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.exchangeService.remove(documentId, resolveAuthUserId(user));
}
/**
* Which desk is posting. A user holding only the Djibouti actions permission
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
* posts as Ethiopia.
*/
private actor(user: TCurrentUser): GlExchangeActor {
const side: GlExchangeSide =
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
? 'DJ'
: 'ET';
return {
userId: resolveAuthUserId(user),
name: actorLabel(user) ?? null,
side,
};
}
}

View File

@@ -0,0 +1,198 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { FilesService } from '../files/files.service';
import type { FileRecord } from '../files/entities/file.entity';
/**
* `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The
* thread is keyed by the entity the two desks are working on — a booking id on
* the per-booking clearance pages, a contract id on the pre-booking ones — so
* both desks opening the same record see the same documents.
*/
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
export type GlExchangeSide = 'ET' | 'DJ';
export interface GlExchangeActor {
userId: string;
name?: string | null;
side: GlExchangeSide;
}
export interface GlExchangeUploadInput {
title: string;
visibleToCustomer: boolean;
}
/**
* Free-form document exchange between the two Global Logistics desks. Anything
* either side needs the other to have (scans, correspondence, corrected forms)
* lands here under a title they choose, instead of a fixed clearance slot.
*
* Rules, all enforced here rather than in the UI:
* - both desks read every document in a thread, whoever uploaded it;
* - only the uploader may retitle, replace or remove one;
* - the customer sees only what its uploader marked visible.
*/
@Injectable()
export class GlExchangeService {
constructor(private readonly filesService: FilesService) {}
/** Every document on one thread, newest first, from a GL desk's view. */
async list(
entityId: string,
viewerId: string,
): Promise<Freight.GlExchangeDocument[]> {
const records = await this.filesService.findByResource(
entityId,
GL_EXCHANGE_RESOURCE,
);
return this.sort(records.map((r) => this.toDto(r, viewerId)));
}
/**
* The customer-facing slice across several threads (a booking and the
* contract it belongs to). Never exposes internal documents, and never marks
* anything editable — the customer is not a GL desk.
*/
async listVisibleToCustomer(
entityIds: string[],
): Promise<Freight.GlExchangeDocument[]> {
const ids = [...new Set(entityIds.filter(Boolean))];
if (ids.length === 0) return [];
const grouped = await this.filesService.findByResourceIdsGrouped(
ids,
GL_EXCHANGE_RESOURCE,
);
const visible = [...grouped.values()]
.flat()
.filter((r) => r.visibleToCustomer);
return this.sort(visible.map((r) => this.toDto(r, null)));
}
async upload(
entityId: string,
file: Express.Multer.File | undefined,
input: GlExchangeUploadInput,
actor: GlExchangeActor,
): Promise<Freight.GlExchangeDocument> {
const title = input.title?.trim();
if (!title) throw new BadRequestException('A document title is required.');
if (!file) throw new BadRequestException('A file is required.');
const record = await this.filesService.upload({
resourceId: entityId,
resource: GL_EXCHANGE_RESOURCE,
// No fixed slot exists for these — `code` carries the uploading desk, so
// a document's origin survives even if the uploader leaves the org.
code: actor.side,
file,
title,
visibleToCustomer: input.visibleToCustomer,
uploadedByUserId: actor.userId,
uploadedByName: actor.name ?? null,
});
return this.toDto(record, actor.userId);
}
/**
* Retitle, re-share or replace a document. Uploader only — the other desk
* reads it but never edits it. A replacement file supersedes the old record
* (soft-deleted, bytes kept) and carries its metadata forward.
*/
async update(
documentId: string,
patch: { title?: string; visibleToCustomer?: boolean },
file: Express.Multer.File | undefined,
actorId: string,
): Promise<Freight.GlExchangeDocument> {
const record = await this.assertUploader(documentId, actorId);
const title = patch.title?.trim();
if (patch.title != null && !title) {
throw new BadRequestException('A document title is required.');
}
if (file) {
const replacement = await this.filesService.upload({
resourceId: record.resourceId,
resource: GL_EXCHANGE_RESOURCE,
code: record.code,
file,
title: title ?? record.title,
visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer,
uploadedByUserId: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
});
await this.filesService.remove(record.id);
return this.toDto(replacement, actorId);
}
const updated = await this.filesService.updateMeta(record.id, {
...(title ? { title } : {}),
...(patch.visibleToCustomer != null
? { visibleToCustomer: patch.visibleToCustomer }
: {}),
});
return this.toDto(updated, actorId);
}
/** Uploader-only removal (soft delete — the stored bytes are kept). */
async remove(documentId: string, actorId: string): Promise<void> {
const record = await this.assertUploader(documentId, actorId);
await this.filesService.remove(record.id);
}
private async assertUploader(
documentId: string,
actorId: string,
): Promise<FileRecord> {
const record = await this.filesService.findById(documentId);
if (record.resource !== GL_EXCHANGE_RESOURCE) {
throw new NotFoundException(`Exchange document ${documentId} not found`);
}
if (record.uploadedByUserId !== actorId) {
throw new ForbiddenException(
'Only the person who uploaded this document can change it.',
);
}
return record;
}
private sort(
docs: Freight.GlExchangeDocument[],
): Freight.GlExchangeDocument[] {
return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
}
private toDto(
record: FileRecord,
viewerId: string | null,
): Freight.GlExchangeDocument {
return {
id: record.id,
entityId: record.resourceId,
// Pre-title rows (none in practice) fall back to the filename so a list
// never renders a blank row.
title: record.title ?? record.name,
side: record.code === 'DJ' ? 'DJ' : 'ET',
visibleToCustomer: record.visibleToCustomer,
uploadedById: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
uploadedAt: record.createdAt.toISOString(),
file: {
id: record.id,
name: record.name,
url: record.url,
size: record.size,
mimeType: record.mimeType,
},
canEdit: viewerId != null && record.uploadedByUserId === viewerId,
};
}
}

View File

@@ -282,6 +282,85 @@ export class GlOperationsService {
};
}
/**
* Offload facts for a booking, read-only: what came off the train at its
* destination (containers, wagons, tonnes) and where the goods went. Sourced
* from the booking's warehouse-inventory row — written by the auto-unload
* that runs on train arrival for both directions.
*/
async offloadState(
bookingId: string,
milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>,
): Promise<Freight.ClearanceOffloadState> {
const [row]: Array<{
destination: string | null;
containers: number;
wagons: number;
bookedWeight: string | null;
inventoryStatus: string | null;
unloadedAt: Date | null;
grnNumber: string | null;
offloadedWeight: string | null;
warehouse: string | null;
warehouseYard: string | null;
zone: string | null;
}> = await this.dataSource.query(
`SELECT COALESCE(dy.label, dy.code) AS "destination",
(SELECT COUNT(*)::int
FROM freight.booking_container bc
JOIN freight.booking_container_units bcu
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers",
(SELECT COUNT(*)::int
FROM freight.wagon_booking_allocations wba
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons",
b.cargo_total_weight_vgm AS "bookedWeight",
inv.status AS "inventoryStatus",
inv.unloaded_at AS "unloadedAt",
inv.grn_number AS "grnNumber",
inv.weight AS "offloadedWeight",
wh.name AS "warehouse",
wy.name AS "warehouseYard",
wz.name AS "zone"
FROM freight.bookings b
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN LATERAL (
SELECT i.*
FROM freight.warehouse_inventory i
WHERE i.booking_id = b.id AND i.deleted_at IS NULL
ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC
LIMIT 1
) inv ON TRUE
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id
LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED');
const offloadedAt =
milestone?.status === 'COMPLETED' && milestone.triggeredAt
? new Date(milestone.triggeredAt).toISOString()
: (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null);
// The warehouse records the real offloaded tonnage; before it does, the
// booked VGM is the best number we have.
const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0);
const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' ');
return {
offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt),
offloadedAt,
destination: row?.destination ?? null,
containers: row?.containers ?? 0,
wagons: row?.wagons ?? 0,
weightTons: weight || null,
grnNumber: row?.grnNumber ?? null,
location: location || null,
inventoryStatus: row?.inventoryStatus ?? null,
};
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
@@ -381,8 +460,9 @@ export class GlOperationsService {
/**
* 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.
* attached invoice document. It is issued as a DRAFT the customer must approve
* first; only then do they pay offline and attach a slip, and GL (ET or DJ)
* confirms to settle it.
*/
async createFinalInvoice(
bookingId: string,
@@ -445,7 +525,8 @@ export class GlOperationsService {
amount: input.amount,
},
],
status: Freight.InvoiceStatus.Issued,
// DRAFT until the customer approves it — approveFinalInvoice issues it.
status: Freight.InvoiceStatus.Draft,
});
await this.filesService.upsertByCode({
@@ -467,6 +548,40 @@ export class GlOperationsService {
return summary;
}
/**
* Customer approves the drafted final invoice — issues it, which is what
* unlocks the payment slip upload. Idempotent: approving twice is a no-op.
*/
async approveFinalInvoice(
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> {
const booking = 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 raised for this shipment.');
}
if (
invoice.status === Freight.InvoiceStatus.Cancelled ||
invoice.status === Freight.InvoiceStatus.Expired
) {
throw new BadRequestException('The final invoice is no longer payable.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued);
this.notifier.finalInvoiceApprovedToStaff(booking);
}
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice not found.');
return summary;
}
/** Customer attaches the payment slip for the final invoice. */
async uploadFinalInvoiceSlip(
bookingId: string,
@@ -483,6 +598,11 @@ export class GlOperationsService {
if (!invoice) {
throw new BadRequestException('No final invoice has been issued for this shipment.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
throw new BadRequestException(
'Approve the final invoice before attaching a payment slip.',
);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('The final invoice is already paid.');
}
@@ -688,6 +808,8 @@ export class GlOperationsService {
description: line?.description ?? null,
invoiceFile: toRef('final_invoice'),
slipFile: toRef('final_invoice_slip'),
// Issuing IS the customer approval (createFinalInvoice leaves it DRAFT).
approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null,
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
};
}

View File

@@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common';
import {
catalogEntriesForTradeDirection,
declarationFileLabel,
draftDeclarationFileLabel,
isDeclarationFileCode,
isDraftDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
@@ -72,6 +74,52 @@ export async function persistDeclarationUploads(
);
}
/** Require at least one draft declaration file in the upload batch. */
export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
}
/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */
export function normalizeDraftDeclarationFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `draft_declaration_${index}`,
}));
}
/** Replace all draft declaration files on a resource with a new multi-file upload batch. */
export async function persistDraftDeclarationUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDraftDeclarationFieldNames(files);
assertDraftDeclarationFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `draft_declaration_${index}`,
file,
}),
),
);
}
/** Require at least one transit permit file in the upload batch. */
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
@@ -341,6 +389,22 @@ export function buildWorkflowFiles(
});
});
const extraDraftDeclarations = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDraftDeclarations.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: draftDeclarationFileLabel(index),
uploadedBy: 'gl_et',
category: 'draft_declaration',
file: { id: file.id, name: file.name, url: file.url },
});
});
if (tradeDirection === 'IMPORT') {
const extraTransit = files
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))

View File

@@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.Mock;
transitAssigneeAssigned: jest.Mock;
};
let transitAgentsService: { getAssignable: jest.Mock };
let service: ContractClearanceService;
const cycle = (over: Record<string, unknown> = {}) => ({
@@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
};
transitAgentsService = {
getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }),
};
service = new ContractClearanceService(
repo as never,
contractsService as never,
@@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never,
{} as never,
notifier as never,
transitAgentsService as never,
);
});
@@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => {
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1');
expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
@@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeName: 'Ahmed Bourhan',
}),
);
transitAgentsService.getAssignable.mockResolvedValue({
id: 'agent-2',
name: 'Fatouma Ali',
});
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.anything(),
@@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => {
);
});
it('refuses an empty name', async () => {
it('refuses a suspended or out-of-window agent', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
transitAgentsService.getAssignable.mockRejectedValue(
new BadRequestException('suspended'),
);
await expect(
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses before Ethiopia has asked', async () => {
await expect(
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toThrow(/not requested/i);
expect(transitAgentsService.getAssignable).not.toHaveBeenCalled();
});
});

View File

@@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity {
/** Why the file was replaced — shown on the document's version history. */
@Column({ name: "replace_reason", type: "text", nullable: true })
replaceReason!: string | null;
/**
* Free-text label chosen by the uploader, when the document has no fixed slot
* (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every
* catalog-driven upload, whose label comes from its code.
*/
@Column({ name: "title", type: "varchar", length: 300, nullable: true })
title!: string | null;
/** Uploader's choice to share the document with the customer's portal. */
@Column({ name: "visible_to_customer", type: "boolean", default: false })
visibleToCustomer!: boolean;
/** Who uploaded it — the only user allowed to edit or remove it afterwards. */
@Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true })
uploadedByUserId!: string | null;
/** Uploader's display name, resolved once so lists need no IAM lookup. */
@Column({
name: "uploaded_by_name",
type: "varchar",
length: 200,
nullable: true,
})
uploadedByName!: string | null;
}

View File

@@ -15,6 +15,11 @@ export interface CreateFileInput {
resource: string;
code: string;
file: Express.Multer.File;
/** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */
title?: string | null;
visibleToCustomer?: boolean;
uploadedByUserId?: string | null;
uploadedByName?: string | null;
}
/**
@@ -101,9 +106,27 @@ export class FilesService {
url,
size: file.size,
mimeType: file.mimetype,
title: input.title ?? null,
visibleToCustomer: input.visibleToCustomer ?? false,
uploadedByUserId: input.uploadedByUserId ?? null,
uploadedByName: input.uploadedByName ?? null,
});
}
/**
* Edit the uploader-authored metadata of a stored file (title, customer
* visibility). Bytes are untouched — callers replacing content upload a new
* record instead.
*/
async updateMeta(
id: string,
patch: { title?: string; visibleToCustomer?: boolean },
): Promise<FileRecord> {
const updated = await this.filesRepository.update(id, patch);
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Replace the file stored under a resource + code (e.g. contract PDF). The
* previous version is retired, not destroyed — pass `replacedBy` to record who

View File

@@ -0,0 +1,29 @@
import { Type } from 'class-transformer';
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
/**
* One truck's detention window. Each truck reaches the destination and is
* released at its own time, so detention days differ between trucks on the
* same delivery. Null clears the value (falls back to the leg-level pair).
*/
export class TruckDetentionTimeInput {
@IsUUID()
vehicleId!: string;
/** Detention clock start — this truck reached the destination. */
@IsOptional()
@IsDateString()
destinationArrivedAt?: string | null;
/** Detention clock end — this truck was released/returned. Omit = still out. */
@IsOptional()
@IsDateString()
returnedAt?: string | null;
}
export class SetDetentionTimesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TruckDetentionTimeInput)
trucks!: TruckDetentionTimeInput[];
}

View File

@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;
/**
* Detention clock START for THIS truck: reached the delivery destination.
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
* leg-level `last_mile.arrived_at`.
*/
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
destinationArrivedAt?: Date | null;
/**
* Detention clock END for THIS truck: released / returned by the customer.
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
*/
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
returnedAt?: Date | null;
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
grossWeightTons?: number | null;

View File

@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
@@ -131,6 +132,18 @@ export class LastMileController {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/detention-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
})
async setDetentionTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDetentionTimesDto,
) {
return this.lastMileService.setDetentionTimes(id, dto.trucks);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -327,6 +327,8 @@ export class LastMileService {
*/
async arrivalTrucksForBooking(bookingId: string): Promise<
Array<{
/** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -359,6 +361,7 @@ export class LastMileService {
: [];
const out: Array<{
lastMileId: string;
vehicleId: string;
truckPlateNumber: string | null;
trailerPlateNumber: string | null;
@@ -386,6 +389,7 @@ export class LastMileService {
}
}
out.push({
lastMileId: lm.id,
vehicleId: vehicle.id,
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
trailerPlateNumber: vehicle.trailerPlateNo || null,
@@ -869,6 +873,46 @@ export class LastMileService {
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
* client-side. Does NOT generate an invoice — that's a separate explicit step.
*/
/**
* Per-truck detention windows. Each truck reaches the destination and is
* released at its own time, so every truck gets its own clock (and therefore
* its own chargeable days). Locked once the detention invoice exists.
*/
async setDetentionTimes(
id: string,
trucks: Array<{
vehicleId: string;
destinationArrivedAt?: string | null;
returnedAt?: string | null;
}>,
): Promise<LastMile> {
await this.findById(id);
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Detention times cannot be changed after the invoice is generated',
);
}
for (const t of trucks) {
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
const end = t.returnedAt ? new Date(t.returnedAt) : null;
if (start && end && end.getTime() < start.getTime()) {
throw new BadRequestException(
'A truck cannot be returned before it arrived — check the detention times',
);
}
await this.dataSource.manager.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: t.vehicleId },
{ destinationArrivedAt: start, returnedAt: end },
);
}
return this.findById(id);
}
async setDistances(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -1,13 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
import { IsBoolean, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
LOCOMOTIVE_STATUSES,
LOCOMOTIVE_TYPES,
} from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
// Extends the shared pagination DTO for `page`/`pageSize`/`search`; those are
// only read by `GET /locomotives/paged` — the plain list ignores them.
export class FilterLocomotivesDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
@@ -47,4 +50,14 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsUUID()
excludeTrainId?: string;
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdTo?: string;
}

View File

@@ -24,6 +24,14 @@ export class LocomotivesController {
return this.locomotivesService.findAll(filter);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('paged')
@StaffReference()
@ApiOperation({ summary: 'List locomotives, paginated ({items, meta})' })
findAllPaged(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAllPaged(filter);
}
@Get(':id')
@StaffReference()
@ApiOperation({ summary: 'Get a locomotive by ID' })

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
@@ -16,18 +16,22 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
}
/**
* List locomotives for the train-builder coupling picker: the usual
* status/type/yard filters, plus optional exclusion of any loco already
* coupled to a built train. `keepTrainId` spares that one train's own locos
* from the exclusion so they stay selectable while editing its consist.
* Filter/sort builder shared by the coupling picker and the paginated list:
* the usual status/type/yard filters, free-text over code + name, a
* registration-day range, and optional exclusion of any loco already coupled
* to a built train. `keepTrainId` spares that one train's own locos from the
* exclusion so they stay selectable while editing its consist.
*/
findForCoupling(opts: {
buildListQuery(opts: {
status?: LocomotiveStatus;
locomotiveType?: LocomotiveType;
currentYardId?: string;
excludeCoupled?: boolean;
keepTrainId?: string;
}): Promise<Locomotive[]> {
search?: string;
createdFrom?: string;
createdTo?: string;
}): SelectQueryBuilder<Locomotive> {
const qb = this.repository
.createQueryBuilder('locomotive')
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
@@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
if (opts.currentYardId)
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
const search = opts.search?.trim();
if (search) {
qb.andWhere('(locomotive.code ILIKE :search OR locomotive.name ILIKE :search)', {
search: `%${search}%`,
});
}
// Registration-day range, both ends inclusive (the UI picks whole days).
if (opts.createdFrom) {
qb.andWhere('locomotive.createdAt >= CAST(:createdFrom AS date)', {
createdFrom: opts.createdFrom,
});
}
if (opts.createdTo) {
qb.andWhere("locomotive.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
createdTo: opts.createdTo,
});
}
if (opts.excludeCoupled) {
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
// consist being edited still lists its current locomotives.
@@ -53,7 +76,11 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
}
return qb.getMany();
return qb;
}
findForCoupling(opts: Parameters<LocomotivesRepository['buildListQuery']>[0]): Promise<Locomotive[]> {
return this.buildListQuery(opts).getMany();
}
/**

View File

@@ -1,6 +1,9 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -53,6 +56,21 @@ export class LocomotivesService {
});
}
/** Same filters as `findAll` plus search/date range, on the shared list envelope. */
findAllPaged(filter: FilterLocomotivesDto): Promise<PaginatedResponse<Locomotive>> {
const qb = this.locomotivesRepository.buildListQuery({
status: filter.status as LocomotiveStatus | undefined,
locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
currentYardId: filter.currentYardId,
excludeCoupled: filter.excludeCoupled,
keepTrainId: filter.excludeTrainId,
search: filter.search,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
});
return paginateQuery(qb, filter);
}
/** Default max pull weight (tons) applied when the caller omits it. */
private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500;

View File

@@ -1,14 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import { RouteStatus } from '../entities/route.entity';
export class FilterRoutesDto {
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
@IsOptional()
@IsString()
search?: string;
// `search` (origin/destination/milestone yard codes and names) plus
// `page`/`pageSize` come from the shared pagination DTO; the page window is only
// read by `GET /routes/paged`.
export class FilterRoutesDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
@IsOptional()
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])

View File

@@ -21,6 +21,13 @@ export class RoutesController {
return this.routesService.findAll(filter);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('paged')
@ApiOperation({ summary: 'List routes, paginated ({items, meta})' })
findAllPaged(@Query() filter: FilterRoutesDto) {
return this.routesService.findAllPaged(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get route by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -4,9 +4,10 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { PaginatedResponse, TrainScheduleStatus } from '@edr/types';
import { DataSource, In, Not } from 'typeorm';
import { paginateArray } from '../../common/utils/pagination.util';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
@@ -68,6 +69,18 @@ export class RoutesService {
});
}
/**
* `findAll` on the shared `{items, meta}` envelope.
*
* ponytail: slices in memory — the corridor table is small (tens of rows) and
* both the ordering (formatted "A → B → C" label) and the search span the
* milestone collection, which a single SQL page window cannot express. Move to
* a query builder if routes ever grow past a few hundred.
*/
async findAllPaged(filter: FilterRoutesDto): Promise<PaginatedResponse<Route>> {
return paginateArray(await this.findAll(filter), filter);
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },

View File

@@ -1,5 +1,6 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonStockLedger } from './wagon-stock-ledger.util';
describe('BookingBatchService — PAID reconcile', () => {
const scheduleId = 'schedule-1';
@@ -40,10 +41,12 @@ describe('BookingBatchService — PAID reconcile', () => {
previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
wagonStockForSchedule: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
query: jest.Mock;
};
let notifier: {
payNow: jest.Mock;
@@ -90,6 +93,13 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
// No shortage by default — paid bookings link as before.
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
// No physical stock configured → the wagon-type gate stands down and these
// specs keep testing the abstract capacity budget on its own.
wagonStockForSchedule: jest.fn().mockResolvedValue({
mode: 'YARD',
remainingByTypeId: new Map<string, number>(),
codesByTypeId: new Map<string, string>(),
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
@@ -116,6 +126,10 @@ describe('BookingBatchService — PAID reconcile', () => {
};
await fn(manager);
}),
// cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds).
// Empty = unresolvable, so the physical-stock gate stands down and these
// specs keep exercising the abstract capacity budget alone.
query: jest.fn().mockResolvedValue([]),
};
notifier = {
@@ -1237,6 +1251,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
return genericRepo;
}),
transaction: jest.fn(),
query: jest.fn().mockResolvedValue([]),
};
const service = new BookingBatchService(
dataSource as never,
@@ -1344,3 +1359,106 @@ describe('BookingBatchService — built-train wagon capacity', () => {
});
});
});
/**
* The reported failure: a train advertising 20 free wagons where only 16 are of
* the type the booking can ride. Selecting all 20 took the customer's money for
* space that never existed and then stalled at allocation on wagon 17.
*/
describe('BookingBatchService — physical wagon-type gate', () => {
const NW5 = 'wagon-type-nw5';
const PW2 = 'wagon-type-pw2';
const WHOLE_LEG = { fromEdge: 0, toEdge: 1 };
/** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */
const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1);
const internals = (svc: BookingBatchService) =>
svc as unknown as {
hasWagonStock: (
stock: WagonStockLedger,
ids: string[],
needed: number,
leg: { fromEdge: number; toEdge: number },
) => boolean;
maybeOfferPartial: (
booking: Booking,
isPair: boolean,
candidates: unknown[],
need: { wagons: number; weightTons: number; lengthMeters: number },
ids: string[],
) => Promise<boolean>;
tryPartialOffer: unknown;
isSplitEligible: unknown;
};
const service = () =>
new BookingBatchService(
{ getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => {
const svc = internals(service());
const stock = mixedStock();
expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false);
expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true);
// A booking that may ride either type sees all 20.
expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true);
});
it('stands down when the booking has no allowed wagon type configured', () => {
// Unresolvable configuration must not strand every booking that uses it —
// the abstract capacity budget still governs.
expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true);
});
it('sizes the split offer to the wagons that physically exist, not the free slots', async () => {
const svc = service();
const inner = internals(svc);
// Isolate the sizing decision: eligibility and offer creation are covered
// elsewhere, what matters here is the room handed to tryPartialOffer.
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
const tryPartial = jest
.fn()
.mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 });
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
const stock = mixedStock();
const candidate = {
id: 'schedule-1',
// 20 abstract slots free, weight and length wide open.
budget: {
legOf: () => WHOLE_LEG,
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
subtract: jest.fn(),
},
armed: false,
stock,
};
const offered = await inner.maybeOfferPartial(
{ id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking,
false,
[candidate],
{ wagons: 20, weightTons: 2000, lengthMeters: 280 },
[NW5],
);
expect(offered).toBe(true);
// 16, not the 20 free slots — the customer is billed for what can be loaded.
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
// Those 16 are now held, so the next booking in the pass cannot re-take them.
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
});
});

View File

@@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay } from './batch-window.util';
import {
TrainSchedulingService,
effectiveWindowConfig,
} from './train-scheduling.service';
import { eatDay, listConfigBookingWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
@@ -87,6 +90,7 @@ import {
OverageTolerance,
stopYardsFor,
} from './corridor-capacity.util';
import { WagonStockLedger } from './wagon-stock-ledger.util';
export type { Capacity } from './corridor-capacity.util';
@@ -166,6 +170,13 @@ export type BookingAllocationStatus =
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
/**
* 0-based booking-window cycle this booking entered the pool in (derived from
* `fullyExecutedAt` against the schedule's window cycles). Ranking compares
* bookings within a cycle only — an earlier cycle always boards before a later
* one regardless of score. Null while the contract is still pending.
*/
windowCycleNo: number | null;
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
@@ -1320,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit {
}
}
const cycleOf = await this.windowCycleIndexer(s);
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id);
return {
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
@@ -1619,6 +1632,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const stock = await this.stockLedgerFor(schedule, budget);
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
await this.setWindow(scheduleId, "FULL");
@@ -1629,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit {
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
// must rank bulk bookings by their wagon-derived priority too.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
const units = this.groupConsolidatedPool(pool);
let armed = false;
let preempted = false;
@@ -1655,14 +1670,19 @@ export class BookingBatchService implements OnModuleInit {
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// Abstract room AND real wagons of a type this booking can ride — see
// fillRouteDayInternal for why both gates are needed.
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
`stocked=${stocked}`,
);
if (!budget.fits(need, leg)) {
if (!budget.fits(need, leg) || !stocked) {
if (isGov) {
const freed = await this.preemptForGovernment(
scheduleId,
@@ -1677,16 +1697,19 @@ export class BookingBatchService implements OnModuleInit {
// Doesn't fit whole. A split-eligible import booking is offered the part
// that fits in the remaining room (top-up path splits the boundary
// booking, mirroring fillRouteDay); otherwise skip and try the next.
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
id: scheduleId,
budget,
armed,
};
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
const cand: {
id: string;
budget: CorridorBudget;
armed: boolean;
stock: WagonStockLedger;
} = { id: scheduleId, budget, armed, stock };
if (
await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds)
) {
armed = cand.armed;
continue;
}
continue; // skip a unit that exceeds weight/length/wagons, try the next
continue; // skip a unit that exceeds weight/length/wagons/stock, try the next
}
}
@@ -1704,6 +1727,8 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
budget.subtract(need, leg);
// Hold the physical wagons too — the next unit must not re-count them.
stock.consume(wagonTypeIds, need.wagons, leg);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
@@ -1823,14 +1848,20 @@ export class BookingBatchService implements OnModuleInit {
}
const wagonDims = await this.loadWagonDims();
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
// Live per-schedule corridor budget + arm/changed flags, in departure order.
// Live per-schedule corridor budget + physical wagon-type stock + arm/changed
// flags, in departure order.
const trains: Array<{
id: string;
budget: CorridorBudget;
stock: WagonStockLedger;
armed: boolean;
changed: boolean;
}> = [];
// The day group shares one booking window (route+day grouping), so any
// member's window grid stands for the pool's cycle derivation.
let cycleSchedule: TrainSchedule | null = null;
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
@@ -1841,10 +1872,12 @@ export class BookingBatchService implements OnModuleInit {
);
continue;
}
cycleSchedule ??= schedule;
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false, changed: false });
const stock = await this.stockLedgerFor(schedule, budget);
trains.push({ id, budget, stock, armed: false, changed: false });
}
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
@@ -1860,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit {
// BULK bookings only get their real (wagon-derived) priority score now, at
// batch time — stamp it and re-rank before the fill consumes the pool.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
this.resortPoolByPriority(
pool,
cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined,
);
// 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);
@@ -1884,12 +1920,20 @@ export class BookingBatchService implements OnModuleInit {
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
t.budget.legOf(booking.originYardId, booking.destinationYardId);
// Consolidated pairs share one wagon set; the primary's types stand for both.
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
// First train (earliest departure) whose corridor carries this booking's
// leg and still fits it as-is.
// leg, still fits it as-is AND physically holds enough wagons of a type the
// booking can ride. Both gates matter: abstract room without the right
// wagon type is space the allocator can never turn into a loaded consist.
let target = trains.find((t) => {
const leg = legOn(t);
return leg != null && t.budget.fits(need, leg);
return (
leg != null &&
t.budget.fits(need, leg) &&
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
);
});
// Per-unit trace: chosen train + each train's remaining room on this leg.
@@ -1934,7 +1978,13 @@ export class BookingBatchService implements OnModuleInit {
// already consumed most of the room). Consolidated pairs / government /
// non-import never split — isSplitEligible guards that. Passing the live
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
const offered = await this.maybeOfferPartial(
booking,
isPair,
trains,
need,
wagonTypeIds,
);
if (offered) {
// A partial offer opens a real commercial pay window, same as reserve().
commercialReserved += 1;
@@ -1964,6 +2014,9 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
// Hold the physical wagons too, so the next unit in this pass sees them
// gone — otherwise two bookings both "fit" the same 16 NW5.
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
target.changed = true;
reservedThisPass += 1;
} catch (err) {
@@ -2027,14 +2080,32 @@ export class BookingBatchService implements OnModuleInit {
private async maybeOfferPartial(
booking: Booking,
isPair: boolean,
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
candidates: Array<{
id: string;
budget: CorridorBudget;
armed: boolean;
stock?: WagonStockLedger;
}>,
need: Capacity,
wagonTypeIds: string[] = [],
): Promise<boolean> {
if (!this.isSplitEligible(booking, isPair)) return false;
const target = candidates
.map((c) => {
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
if (!leg) return null;
const room = c.budget.remainingFor(leg);
// The offer may never exceed the wagons that physically exist in a type
// this booking can ride. This is what turns "20 free wagons, only 16 of
// them NW5" into an offer for 16 — the customer pays for 16 and the
// other 4 leave as the usual remainder booking, instead of paying for
// 20 and stalling at allocation on wagon 17.
const physical = wagonTypeIds.length
? c.stock?.availableFor(wagonTypeIds, leg)
: undefined;
const wagons =
physical == null ? room.wagons : Math.min(room.wagons, physical);
return { c, leg, room: { ...room, wagons } };
})
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
@@ -2047,6 +2118,7 @@ export class BookingBatchService implements OnModuleInit {
);
if (!offered) return false;
target.c.budget.subtract(offered, target.leg);
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
target.c.armed = true;
return true;
}
@@ -3204,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit {
}
}
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
private resortPoolByPriority(pool: Booking[]): void {
/**
* Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based
* booking-window cycle it arrived in: the last window whose open is at/before
* the timestamp (a timestamp in the doc-review/payment gap belongs to the
* cycle that just closed). The cycle grid comes from the schedule's frozen
* window-rule snapshot — the exact windows the cycle engine runs.
*/
private async windowCycleIndexer(
schedule: TrainSchedule,
): Promise<(ts: Date | null | undefined) => number> {
if (!schedule.scheduledDepartureDate) return () => 0;
let starts: number[];
try {
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const windows = listConfigBookingWindows(
schedule.direction,
schedule.scheduledDepartureDate,
{
...cfg,
reopenGapMinutes:
schedule.ruleReopenDelayMinutes ??
cfg.docReviewMinutes + cfg.paymentWindowMinutes,
},
);
starts = windows.map((w) => w.start.getTime());
} catch (err) {
// A failed cycle derivation must never block the batch — fall back to one
// flat cycle (pure priority order, the old behaviour).
this.logger.warn(
`Window-cycle derivation failed for schedule ${schedule.id}: ` +
`${(err as Error).message}`,
);
return () => 0;
}
return (ts) => {
if (!ts) return 0;
const ms = ts.getTime();
let idx = 0;
for (let i = 0; i < starts.length; i += 1) {
if (ms >= starts[i]) idx = i;
}
return idx;
};
}
/**
* Rank the batch pool: government first, then WINDOW CYCLE (bookings compete
* only within the cycle they arrived in — an earlier cycle's booking always
* outranks a later cycle's, whatever the scores), then priority score, then
* oldest. `cycleOf` comes from {@link windowCycleIndexer}.
*/
private resortPoolByPriority(
pool: Booking[],
cycleOf: (ts: Date | null | undefined) => number = () => 0,
): void {
pool.sort(
(a, b) =>
Number(b.isGovernment) - Number(a.isGovernment) ||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
(a.fullyExecutedAt?.getTime() ?? Infinity) -
(b.fullyExecutedAt?.getTime() ?? Infinity) ||
@@ -3468,6 +3595,131 @@ export class BookingBatchService implements OnModuleInit {
return dims.length ? dims : [fallback];
}
/**
* Physical wagon-type stock for one schedule, on the same corridor edges its
* {@link CorridorBudget} uses. Sourced from the scheduling service so the
* batch counts exactly the wagons the allocator will later plan against.
*/
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
);
}
/**
* Whether the train holds enough PHYSICAL wagons of the types this booking may
* ride. Unresolvable configuration (no allowed wagon type) returns true: the
* abstract budget still governs, and a mis-configured cargo type must not
* silently strand every booking that uses it.
*/
private hasWagonStock(
stock: WagonStockLedger,
wagonTypeIds: string[],
wagonsNeeded: number,
leg: CorridorLeg,
): boolean {
if (!wagonTypeIds.length) return true;
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
}
private allowedWagonTypeCache: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
expiresAt: number;
} | null = null;
/**
* Wagon-type ids each cargo / container type may ride, read straight from the
* join tables.
*
* The batch pool finders deliberately do NOT join `cargoType.wagonTypes` /
* `containerType.wagonTypes` — those many-to-many joins multiply rows badly on
* a hot path. So the pool's booking entities carry the type FK but not the
* allowed list, and resolving it per booking through the relation would come
* back empty. Two small lookups, cached for a minute like {@link loadWagonDims},
* give the same answer without touching the pool query.
*/
private async loadAllowedWagonTypeIds(): Promise<{
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
}> {
if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) {
return this.allowedWagonTypeCache;
}
// Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the
// scheduling service — the allocator will not plan against them either.
const [cargoRows, containerRows]: [
Array<{ typeId: string; wagonTypeId: string }>,
Array<{ typeId: string; wagonTypeId: string }>,
] = await Promise.all([
this.dataSource.query(
`SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.cargo_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
this.dataSource.query(
`SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
FROM freight.container_type_wagon_types ct
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
WHERE wt.is_active IS NOT FALSE`,
),
]);
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
const map = new Map<string, string[]>();
for (const row of rows) {
const list = map.get(row.typeId) ?? [];
list.push(row.wagonTypeId);
map.set(row.typeId, list);
}
return map;
};
const value = {
byCargoTypeId: collect(cargoRows),
byContainerTypeId: collect(containerRows),
};
this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
* Every wagon-type id this booking may ride. Empty means "unresolvable" — the
* caller must then skip the physical-stock gate rather than block the booking
* on missing configuration.
*/
private allowedWagonTypeIdsFor(
booking: Booking,
allowed: {
byCargoTypeId: Map<string, string[]>;
byContainerTypeId: Map<string, string[]>;
},
): string[] {
if (booking.freightType === "BULK") {
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id;
return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
}
const ids = new Set<string>();
for (const line of booking.bookingContainers ?? []) {
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
if (!containerTypeId) continue;
for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) {
ids.add(id);
}
}
return [...ids];
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.

View File

@@ -5,6 +5,7 @@ import {
NotFoundException,
Optional,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
@@ -48,6 +49,7 @@ export class BookingJourneyService {
@InjectDataSource() private readonly dataSource: DataSource,
private readonly yardFacilities: YardFacilitiesService,
private readonly facilityHandling: FacilityHandlingService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -145,6 +147,12 @@ export class BookingJourneyService {
});
});
// Intercity ends here — a ONE_TIME contract closes on its shipment being
// delivered (import/export emit this from booking-transition.complete).
if (nextStatus === 'COMPLETED') {
this.events.emit('booking.completed', { bookingId });
}
// Customer tracking: THIS booking arrived (train may still be rolling).
void this.completeMilestones(booking, [
...(booking.tradeDirection === 'IMPORT'
@@ -303,6 +311,12 @@ export class BookingJourneyService {
RETURNING b.id, b.trade_direction`,
[schedule.id, schedule.destinationStationId, now],
);
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
for (const row of rows) {
if (row.trade_direction === 'DOMESTIC') {
this.events.emit('booking.completed', { bookingId: row.id });
}
}
return rows.map((r) => r.id);
}
@@ -487,7 +501,13 @@ export class BookingJourneyService {
currentYardId: booking.destinationYardId,
currentTrainScheduleId: null,
trainSetWagonId: null,
status: Freight.WagonStatus.Available,
// A wagon that belongs to a built train stays coupled to it (ASSIGNED);
// only loose wagons return to the open AVAILABLE pool. Marking a
// coupled wagon AVAILABLE made it show up in the train-builder's
// "available wagons" picker, where attaching it always 409'd.
status: wagon.trainId
? Freight.WagonStatus.Assigned
: Freight.WagonStatus.Available,
});
}
}

View File

@@ -34,11 +34,11 @@ export class CreateContainerTrainScheduleDto {
type: [String],
format: 'uuid',
description:
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
'Hand-picked locomotives pulling the train (minimum 1). Ignored when trainId is provided.',
})
@IsOptional()
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds?: string[];

View File

@@ -48,10 +48,12 @@ export class FacilityHandlingService {
if (!facility?.hasFacility) return null;
const occurredAt = input.occurredAt ?? new Date();
// Mapped to the goods owner, same as every warehouse-raised GRN.
const grnNumber = generateGrnNumber(
booking.tradeDirection ?? 'DOMESTIC',
booking.id,
occurredAt,
booking.company?.name ?? null,
);
// Link the storage record when this facility keeps cargo — that link is

View File

@@ -5,7 +5,7 @@ import {
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
minLocomotiveLimits,
combinedLocomotiveLimits,
sizePartialOfferWagons,
trainSetLocomotiveLimits,
} from './train-capacity.util';
@@ -197,42 +197,75 @@ describe('train-capacity.util', () => {
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('takes the weakest locomotive across a multi-locomotive set', () => {
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => {
// Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(20);
expect(limits?.overageToleranceTons).toBe(180);
// A single locomotive is just its own limit — no doubling, no halving.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => {
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 },
]);
expect(limits?.maxTrainLengthMeters).toBe(700);
expect(limits?.overageToleranceMeters).toBe(5);
});
it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => {
// LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must
// keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train.
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null },
// keep the 90 rather than collapse to 0 — an unset value abstains.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null },
]);
expect(limits?.overageToleranceTons).toBe(90);
// All unconfigured → no tolerance.
const none = minLocomotiveLimits([
const none = combinedLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
]);
expect(none?.overageToleranceTons).toBe(0);
});
it('reports no pull limit when NO locomotive has one configured', () => {
// Summing must not turn "unset" into 0 and strand every booking; an
// all-unset set keeps the old "no opinion" behaviour.
const limits = combinedLocomotiveLimits([
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
]);
expect(limits?.maxPullWeightTons).toBe(Infinity);
// One configured, one not → only the configured one contributes.
expect(
combinedLocomotiveLimits([
{ maxPullWeightTons: 1750, maxTrainLengthMeters: 760 },
{ maxPullWeightTons: 0, maxTrainLengthMeters: 760 },
])?.maxPullWeightTons,
).toBe(1750);
});
it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => {
const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null };
const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 };
const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null };
expect(
trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }),
).toEqual({
maxPullWeightTons: 3500,
maxPullWeightTons: 3550,
maxTrainLengthMeters: 700,
overageToleranceTons: 90,
overageToleranceMeters: 0,
});
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500);
expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750);
expect(trainSetLocomotiveLimits(null)).toBeNull();
expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull();
});

View File

@@ -256,27 +256,40 @@ function round3(value: number): number {
}
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
* locomotive caps the train, so take the minimum pull weight and minimum length
* across all assigned locomotives. Returns null when no locomotives are given.
* Effective limits for a train set, per axis:
*
* - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull
* 3500T. Only CONFIGURED pull weights are summed; a set with none configured
* reports Infinity (no opinion), exactly as before.
* - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its
* own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0).
* - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not
* a haulage one: coupling a second locomotive does not lengthen the track, so
* the most restrictive locomotive still governs (and its tolerance with it).
*
* Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
export function combinedLocomotiveLimits(
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
const configuredPulls = locomotives
.map((l) => num(l.maxPullWeightTons))
.filter((v) => v > 0);
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
),
maxPullWeightTons: configuredPulls.length
? round3(configuredPulls.reduce((sum, v) => sum + v, 0))
: Infinity,
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
),
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
// tolerance set has no opinion, it does not zero out the others.
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)),
// Paired with the length axis, so it stays the weakest CONFIGURED value — a
// locomotive with no tolerance set has no opinion, it does not zero the others.
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
};
}
@@ -286,10 +299,16 @@ function minConfigured(values: Array<number | null | undefined>): number {
return configured.length ? Math.min(...configured) : 0;
}
function sumConfigured(values: Array<number | null | undefined>): number {
const configured = values.filter((v) => v != null).map((v) => num(v));
return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0;
}
/**
* Effective limits for a whole train set: min across its linked locomotives,
* falling back to the legacy single `locomotive` column for sets created
* before multi-loco support. Null when the set has no locomotive at all.
* Effective limits for a whole train set: {@link combinedLocomotiveLimits} over
* its linked locomotives, falling back to the legacy single `locomotive` column
* for sets created before multi-loco support. Null when the set has no
* locomotive at all.
*/
export function trainSetLocomotiveLimits(
trainSet?: {
@@ -306,7 +325,7 @@ export function trainSetLocomotiveLimits(
: trainSet.locomotive
? [trainSet.locomotive]
: [];
return minLocomotiveLimits(pool);
return combinedLocomotiveLimits(pool);
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */

View File

@@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
bookingCargoTons,
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
combinedLocomotiveLimits,
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
@@ -1300,9 +1300,9 @@ export class TrainSchedulingService {
.slice()
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotiveId);
if (locomotiveIds.length < 2) {
if (locomotiveIds.length < 1) {
throw new BadRequestException(
`Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`,
`Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`,
);
}
if (builtTrain.currentYardId !== route.originYardId) {
@@ -1323,8 +1323,8 @@ export class TrainSchedulingService {
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
}
@@ -1382,7 +1382,7 @@ export class TrainSchedulingService {
builtTrain?.id ?? null,
);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// Every schedule starts with a CLOSED customer window; the window engine opens
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
@@ -1572,7 +1572,7 @@ export class TrainSchedulingService {
};
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
// Callers that add bookings without hand-picking container slots (the
@@ -3971,36 +3971,12 @@ export class TrainSchedulingService {
const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);
const originYardId = dto.originStationId;
let stock: WagonStock;
if (builtTrainId) {
stock = await this.builtTrainStock(builtTrainId);
} else {
// Dynamic consist: a slot's physical wagon may ride from the train's origin
// OR already sit at the booking's own boarding yard and attach there — so
// the usable fleet is the union across the origin and every boarding yard.
const boardYardIds = [
...new Set(
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) =>
this.countFleetAvailability(yardId, targetScheduleId),
),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
const stock: WagonStock = await this.wagonStockForSchedule(
targetScheduleId,
originYardId,
bookings.map((b) => b.originYardId),
builtTrainId,
);
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
// so a ride-along on an empty leg never competes with cargo on a full one.
@@ -4129,7 +4105,7 @@ export class TrainSchedulingService {
// warning (it must arrive before dispatch), but a set too weak to pull the train
// is a hard violation.
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
const setLimits = combinedLocomotiveLimits(assignedLocomotives);
if (offYard) {
warnings.push(
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
@@ -4799,6 +4775,51 @@ export class TrainSchedulingService {
* type. This is the whole plannable pool for its schedules — the plan is
* full when every consist wagon is allocated.
*/
/**
* The physical wagons a schedule can actually plan against, by wagon type.
*
* A schedule built from a Train Builder train plans against ONLY that train's
* own consist. A legacy/dynamic-consist schedule plans against the boarding
* yards' loose pool: a slot's wagon may ride from the train's origin OR
* already sit at the booking's own boarding yard and attach there, so the
* usable fleet is the union across the origin and every boarding yard.
*
* Public because batch fill needs the SAME stock the allocator will later
* validate against — selecting a booking the allocator cannot place is how
* customers ended up paying for wagons that were never there.
*/
async wagonStockForSchedule(
scheduleId: string | undefined,
originYardId: string,
boardingYardIds: Array<string | null | undefined> = [],
preloadedBuiltTrainId?: string | null,
): Promise<WagonStock> {
const builtTrainId =
preloadedBuiltTrainId !== undefined
? preloadedBuiltTrainId
: await this.builtTrainIdOfSchedule(scheduleId);
if (builtTrainId) return this.builtTrainStock(builtTrainId);
const boardYardIds = [
...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))),
];
const fleetCountsByYard = await Promise.all(
boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)),
);
const remainingByTypeId = new Map<string, number>();
const codesByTypeId = new Map<string, string>();
for (const rows of fleetCountsByYard) {
for (const row of rows) {
remainingByTypeId.set(
row.wagonTypeId,
(remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
);
codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
}
}
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
}
private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
const wagons = await this.dataSource.getRepository(Wagon).find({
where: { trainId: builtTrainId },
@@ -5347,7 +5368,7 @@ export class TrainSchedulingService {
* schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
* philosophy: nothing serviceable is filtered out — staff see the status,
* whether the train sits at the origin yard yet, and its future schedules.
* Trains with fewer than two locomotives are omitted (never schedulable).
* Trains with no locomotive at all are omitted (never schedulable).
*/
async getAvailableTrainsForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
@@ -5385,7 +5406,7 @@ export class TrainSchedulingService {
const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));
return trains
.filter((train) => (train.locomotives ?? []).length >= 2)
.filter((train) => (train.locomotives ?? []).length >= 1)
.map((train) => {
const wagons = train.wagons ?? [];
return {
@@ -5417,7 +5438,15 @@ export class TrainSchedulingService {
totalLengthMeters: roundTons(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: roundTons(Number(train.capacityTons)),
// Live from the coupled set — `capacity_tons` still holds the old
// single-locomotive figure on trains built before pull weight summed.
maxPullWeightTons: roundTons(
combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
)?.maxPullWeightTons ?? Number(train.capacityTons),
),
atOriginYard: train.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(train.id) ?? 0,
};
@@ -5467,7 +5496,7 @@ export class TrainSchedulingService {
);
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
@@ -5590,7 +5619,7 @@ export class TrainSchedulingService {
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
.map((slot) => slot.physicalWagonId as string),
);
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
const pullCapTons = roundTons(
Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
);

View File

@@ -0,0 +1,70 @@
import { WagonStockLedger } from './wagon-stock-ledger.util';
const WHOLE = { fromEdge: 0, toEdge: 1 };
describe('WagonStockLedger', () => {
it('reports the wagons of a booking\'s OWN types, not the train total', () => {
// The reported case: 20 free wagons on the train, but only 16 of them NW5.
const ledger = new WagonStockLedger(
new Map([
['nw5', 16],
['pw2', 4],
]),
1,
);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16);
expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4);
// A cargo type mapped to both may ride either, so they add up.
expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20);
// Duplicates must not double-count.
expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16);
// An unconfigured type has no stock.
expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0);
});
it('consumes what it can and reports the shortfall', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1);
// A 20-wagon booking can only take 16 — the caller splits on that number.
expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0);
});
it('drains the deepest stock first across candidate types', () => {
const ledger = new WagonStockLedger(
new Map([
['nw5', 10],
['nw7', 3],
]),
1,
);
expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12);
// 10 from NW5 then 2 from NW7 — one NW7 left.
expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1);
expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0);
});
it('frees stock past an alight yard — disjoint legs never compete', () => {
// Three stops (A→B→C) = two edges. An intercity booking riding A→B must
// not consume the wagon on B→C.
const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2);
const firstLeg = { fromEdge: 0, toEdge: 1 };
const secondLeg = { fromEdge: 1, toEdge: 2 };
ledger.consume(['nw5'], 5, firstLeg);
expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0);
expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5);
// A whole-route booking sees the busiest edge it crosses, so it is blocked.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0);
});
it('counts the busiest edge within a leg, not the sum of edges', () => {
const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3);
ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 });
ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 });
// Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once.
expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4);
expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10);
});
});

View File

@@ -0,0 +1,86 @@
import type { CorridorLeg } from './corridor-capacity.util';
/**
* Physical wagon-type stock for one train, consumed per corridor edge.
*
* The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight,
* length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons"
* would admit a 20-wagon booking whose cargo only rides NW5 even when the yard
* holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20,
* and allocation then failed on wagon 17 with "No NW5 wagon available at the
* yard" — money taken for space that never existed.
*
* This ledger is the missing axis: how many wagons of the types a booking may
* actually ride are free. Batch fill consults it alongside the budget, so a
* booking is admitted whole only when both agree, and is otherwise offered a
* split sized to the wagons that genuinely exist.
*
* Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at
* an alight yard is available again downstream, so an intercity ride-along on
* Gelan→Adama never competes for stock with an export on Adama→Doraleh.
*/
export class WagonStockLedger {
private readonly usedPerEdge = new Map<string, number[]>();
constructor(
private readonly remainingByTypeId: Map<string, number>,
private readonly edgeCount: number,
) {}
/** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */
private availableForType(wagonTypeId: string, leg: CorridorLeg): number {
const total = this.remainingByTypeId.get(wagonTypeId) ?? 0;
const row = this.usedPerEdge.get(wagonTypeId);
if (!row) return total;
let busiest = 0;
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
busiest = Math.max(busiest, row[edge] ?? 0);
}
return Math.max(0, total - busiest);
}
/**
* Free wagons across every type a booking may ride. A cargo/container type
* mapped to several wagon types can use any of them, so they add up.
*/
availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number {
let total = 0;
for (const id of new Set(wagonTypeIds)) {
total += this.availableForType(id, leg);
}
return total;
}
/**
* Take `wagons` from the candidate types, deepest stock first so the consist
* drains evenly (same tie-break as the wagon planner). Returns how many were
* actually taken — less than asked when the stock is short.
*/
consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number {
let outstanding = Math.max(0, Math.floor(wagons));
const candidates = [...new Set(wagonTypeIds)];
let taken = 0;
while (outstanding > 0) {
const deepest = candidates
.map((id) => ({ id, free: this.availableForType(id, leg) }))
.filter((c) => c.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!deepest) break;
const take = Math.min(outstanding, deepest.free);
let row = this.usedPerEdge.get(deepest.id);
if (!row) {
row = new Array<number>(this.edgeCount).fill(0);
this.usedPerEdge.set(deepest.id, row);
}
for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) {
row[edge] = (row[edge] ?? 0) + take;
}
outstanding -= take;
taken += take;
}
return taken;
}
}

View File

@@ -6,7 +6,7 @@ import { TrainSet } from './train-set.entity';
/**
* Link row joining a train set to one of its locomotives. A train set must be
* pulled by at least two locomotives (front + back); `sequenceNo` is a plain
* pulled by at least one locomotive; `sequenceNo` is a plain
* order index — no front/rear semantics are modelled yet.
*/
@Entity({ schema: 'freight', name: 'train_set_locomotives' })

View File

@@ -29,7 +29,7 @@ export class TrainSet extends BaseEntity {
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
/** All locomotives pulling this train set (minimum 2). */
/** All locomotives pulling this train set (minimum 1). */
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];

View File

@@ -33,10 +33,10 @@ export class BuildTrainDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
description: 'Locomotives pulling the train (minimum 1), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];

View File

@@ -5,10 +5,10 @@ export class UpdateTrainLocomotivesDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Full replacement locomotive set (minimum 2), in consist order',
description: 'Full replacement locomotive set (minimum 1), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
}

View File

@@ -6,7 +6,7 @@ import { Train } from './train.entity';
/**
* Link row joining a built train to one of its locomotives. A train must be
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
* pulled by at least one locomotive; `sequenceNo` is the order
* in the consist — 0 is the lead locomotive.
*
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built

View File

@@ -80,7 +80,7 @@ export class Train extends BaseEntity {
@OneToMany(() => Wagon, (wagon) => wagon.train)
wagons!: Wagon[];
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
/** Locomotives pulling this train (minimum 1), ordered by sequenceNo. */
@OneToMany(() => TrainLocomotive, (link) => link.train)
locomotives?: TrainLocomotive[];
}

View File

@@ -62,7 +62,7 @@ export class TrainBuilderController {
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.update)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainLocomotivesDto,

View File

@@ -3,6 +3,7 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
@@ -10,7 +11,7 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -55,12 +56,14 @@ export interface ActiveScheduleRef {
*/
@Injectable()
export class TrainBuilderService {
private readonly logger = new Logger(TrainBuilderService.name);
constructor(private readonly dataSource: DataSource) {}
async buildTrain(dto: BuildTrainDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
const trainId = await this.dataSource.transaction(async (manager) => {
@@ -96,7 +99,7 @@ export class TrainBuilderService {
);
// Effective haul capacity is capped by the weakest locomotive in the set.
const limits = minLocomotiveLimits(locomotives);
const limits = combinedLocomotiveLimits(locomotives);
const train = await manager.getRepository(Train).save(
manager.getRepository(Train).create({
code,
@@ -283,7 +286,7 @@ export class TrainBuilderService {
: null,
}));
const limits = minLocomotiveLimits(
const limits = combinedLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
@@ -339,11 +342,11 @@ export class TrainBuilderService {
};
}
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
/** Replace the locomotive set (minimum 1, same-yard rule applies). */
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
if (locomotiveIds.length < 1) {
throw new BadRequestException('A train must be pulled by at least one locomotive');
}
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
@@ -360,7 +363,7 @@ export class TrainBuilderService {
train.id,
);
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
const limits = minLocomotiveLimits(locomotives);
const limits = combinedLocomotiveLimits(locomotives);
await manager
.getRepository(Train)
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
@@ -520,6 +523,28 @@ export class TrainBuilderService {
sequenceNumber: null,
status: WagonStatus.Maintenance,
});
// Audit row: which train it came off and when. The wagon does not change
// yard here, so from/to are the same — the ledger is the wagon's history
// surface, and a maintenance detach has to be in it.
const yardId = wagon.currentYardId ?? train.currentYardId ?? null;
if (yardId) {
await manager.getRepository(WagonMovement).save(
manager.getRepository(WagonMovement).create({
wagonId: wagon.id,
fromYardId: yardId,
toYardId: yardId,
kind: WagonMovementKind.Maintenance,
note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`,
occurredAt: new Date(),
}),
);
} else {
// to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance,
// it just cannot carry a ledger row.
this.logger.warn(
`Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`,
);
}
await this.resequenceWagons(manager, train.id);
});
return this.getComposition(id);
@@ -743,7 +768,16 @@ export class TrainBuilderService {
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: round(train.capacityTons),
// Derived live from the coupled set, NOT from the stored capacity_tons.
// That column is written at build/re-couple time, so every train built
// before pull weight became additive still holds the old single-locomotive
// figure. Computing it here keeps the board honest without a backfill;
// the column self-heals the next time the locomotive set is saved.
maxPullWeightTons: round(
combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ??
Number(train.capacityTons) ??
0,
),
};
}
@@ -878,7 +912,7 @@ export class TrainBuilderService {
where: { trainId: train.id },
relations: { locomotive: true },
});
const limits = minLocomotiveLimits(
const limits = combinedLocomotiveLimits(
links
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
};
export class CreateTransitAgentDto {
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
@IsString()
@MaxLength(150)
name!: string;
@ApiProperty({ example: '2026-01-01' })
@IsDateString()
validFrom!: string;
@ApiProperty({ example: '2026-12-31' })
@IsDateString()
validTo!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTransitAgentDto } from './create-transit-agent.dto';
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}

View File

@@ -0,0 +1,24 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/**
* Djibouti transit officer GL Djibouti may assign against a shipment's
* transit-assignee handshake. Admin-managed so the roster and each officer's
* validity window arrive without a code change; `isActive` is the manual
* suspend/reactivate switch, independent of the validity window.
*/
@Entity({ schema: 'freight', name: 'transit_agents' })
@Index(['isActive'])
export class TransitAgent extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'valid_from', type: 'date' })
validFrom!: string;
@Column({ name: 'valid_to', type: 'date' })
validTo!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,87 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgentsService } from './transit-agents.service';
@ApiTags('transit-agents')
@Controller('transit-agents')
@ApiBearerAuth()
export class TransitAgentsController {
constructor(private readonly transitAgentsService: TransitAgentsService) {}
@Get()
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents' })
findAll(@Query() query: Record<string, string | undefined>) {
return this.transitAgentsService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: undefined,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
@Get('assignable')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
findAssignable() {
return this.transitAgentsService.findAssignable();
}
@Get(':id')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'Get a transit agent by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.findById(id);
}
@Post()
@RuleEngineCreate('transit-agents')
@ApiOperation({ summary: 'Create a transit agent' })
create(@Body() dto: CreateTransitAgentDto) {
return this.transitAgentsService.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('transit-agents')
@ApiOperation({ summary: 'Update a transit agent' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
return this.transitAgentsService.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('transit-agents')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a transit agent' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsController } from './transit-agents.controller';
import { TransitAgentsRepository } from './transit-agents.repository';
import { TransitAgentsService } from './transit-agents.service';
@Module({
imports: [TypeOrmModule.forFeature([TransitAgent])],
controllers: [TransitAgentsController],
providers: [TransitAgentsRepository, TransitAgentsService],
exports: [TransitAgentsRepository, TransitAgentsService],
})
export class TransitAgentsModule {}

View File

@@ -0,0 +1,28 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
@Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
constructor(
@InjectRepository(TransitAgent)
repository: Repository<TransitAgent>,
) {
super(repository);
}
/** Active AND currently inside its validity window (today's date, server-side). */
findAssignable(today: string): Promise<TransitAgent[]> {
return this.repository.find({
where: {
isActive: true,
validFrom: LessThanOrEqual(today),
validTo: MoreThanOrEqual(today),
},
order: { name: 'ASC' },
});
}
}

View File

@@ -0,0 +1,138 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsRepository } from './transit-agents.repository';
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus;
};
type TransitAgentListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
const today = todayISODate();
if (today < agent.validFrom) return 'NOT_STARTED';
if (today > agent.validTo) return 'EXPIRED';
return 'VALID';
}
function withValidityStatus(agent: TransitAgent): TransitAgentView {
return { ...agent, validityStatus: validityStatus(agent) };
}
@Injectable()
export class TransitAgentsService {
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
async findAll(filter: TransitAgentListFilter = {}): Promise<{
data: TransitAgentView[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
? (filter.sortBy as keyof TransitAgent)
: 'name';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const [data, total] = await this.transitAgentsRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<TransitAgent>,
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data: data.map(withValidityStatus),
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
/** Active and currently inside its validity window — the DJ assignment dropdown. */
async findAssignable(): Promise<TransitAgent[]> {
return this.transitAgentsRepository.findAssignable(todayISODate());
}
async findById(id: string): Promise<TransitAgentView> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(agent);
}
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new BadRequestException('Selected transit officer was not found.');
}
if (!agent.isActive) {
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
}
if (validityStatus(agent) !== 'VALID') {
throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
);
}
return agent;
}
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
if (dto.validTo < dto.validFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const agent = await this.transitAgentsRepository.create({
name: dto.name.trim(),
validFrom: dto.validFrom,
validTo: dto.validTo,
isActive: dto.isActive ?? true,
});
return withValidityStatus(agent);
}
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
const current = await this.findById(id);
const nextValidFrom = dto.validFrom ?? current.validFrom;
const nextValidTo = dto.validTo ?? current.validTo;
if (nextValidTo < nextValidFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const updated = await this.transitAgentsRepository.update(id, {
...dto,
...(dto.name ? { name: dto.name.trim() } : {}),
});
if (!updated) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(updated);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.transitAgentsRepository.softDelete(id);
}
}

View File

@@ -1,7 +1,17 @@
import { WagonStatus } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
export class ListWagonsQueryDto {
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
@@ -29,6 +39,15 @@ export class ListWagonsQueryDto {
@IsUUID()
trainId?: string;
@ApiPropertyOptional({
description:
'Only loose wagons (not coupled to a built train) — what a picker can actually take.',
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === true || value === 'true')
@IsBoolean()
unassigned?: boolean;
@ApiPropertyOptional({
description: 'Filter by run number — matches export OR import run (e.g. 8001).',
})
@@ -53,11 +72,21 @@ export class ListWagonsQueryDto {
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
@Max(100)
pageSize?: number;
@ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' })
@IsOptional()
@IsDateString()
createdTo?: string;
}

View File

@@ -232,4 +232,29 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('listRequests', () => {
// TypeORM paginates a joined query through a DISTINCT subquery and resolves
// every orderBy criterion against entity metadata — a DB column name there
// (`r.created_at`) makes it read `.databaseName` of undefined → 500.
it('sorts by the entity property path, not the DB column', async () => {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
requestRepo.createQueryBuilder.mockReturnValue(qb);
await service.listRequests({
status: 'PENDING,PARTIALLY_FULFILLED',
page: 1,
pageSize: 10,
});
expect(qb.orderBy).toHaveBeenCalledWith('r.createdAt', 'DESC');
});
});
});

View File

@@ -114,6 +114,8 @@ export class WagonTransferRequestsService {
currentYardId: yardId,
wagonTypeId,
status: WagonStatus.Available,
// Coupled to a built train = not movable; bulkTransfer rejects it too.
trainId: IsNull(),
},
});
}
@@ -159,7 +161,7 @@ export class WagonTransferRequestsService {
? 'r.quantity'
: query.sortBy === 'status'
? 'r.status'
: 'r.created_at';
: 'r.createdAt';
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
@@ -399,6 +401,7 @@ export class WagonTransferRequestsService {
currentYardId: request.fromYardId,
wagonTypeId: request.wagonTypeId,
status: WagonStatus.Available,
trainId: IsNull(),
},
order: { wagonNumber: 'ASC' },
take: remaining,

View File

@@ -39,7 +39,9 @@ export class WagonsController {
@Get()
@StaffReference()
@ApiOperation({ summary: 'List all wagons' })
@ApiOperation({
summary: 'List wagons, paginated ({items, meta}) — 10 per page by default',
})
findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query);
}

View File

@@ -1,4 +1,4 @@
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
Injectable,
@@ -6,7 +6,8 @@ import {
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In } from 'typeorm';
import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -42,7 +43,8 @@ export class WagonsService {
return this.wagonRepo.save(wagon);
}
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder<Wagon> {
const search = query.search?.trim();
const trainId = query.trainId?.trim();
const wagonTypeId = query.wagonTypeId?.trim();
@@ -61,6 +63,9 @@ export class WagonsService {
if (query.currentYardId)
qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
// Pickers (train-builder, transfer fulfilment) can only take a wagon that is
// not already coupled to a built train — never offer one the API will reject.
if (query.unassigned) qb.andWhere('w.trainId IS NULL');
if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
// Filter by run: the odd export run identifies the pair, so match either
@@ -72,6 +77,18 @@ export class WagonsService {
);
}
// Registration-day range, both ends inclusive (the UI picks whole days).
if (query.createdFrom) {
qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', {
createdFrom: query.createdFrom,
});
}
if (query.createdTo) {
qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
createdTo: query.createdTo,
});
}
// Search matches the wagon number or either run number.
if (search) {
qb.andWhere(
@@ -95,12 +112,16 @@ export class WagonsService {
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
qb.orderBy(`w.${sortBy}`, sortOrder);
if (query.page && query.limit) {
qb.skip((Number(query.page) - 1) * Number(query.limit));
}
if (query.limit) qb.take(Number(query.limit));
return qb;
}
return qb.getMany();
/**
* The wagon list is always a page. Callers that genuinely need every row
* (yard workspace, coupling pickers) walk the pages client-side — see
* `wagonService.listAll` in the backoffice.
*/
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
}
async findById(id: string): Promise<Wagon> {

View File

@@ -1,7 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
import {
WAREHOUSE_YARD_DIRECTIONS,
WAREHOUSE_YARD_TYPES,
WarehouseYardDirection,
WarehouseYardType,
} from '../entities/warehouse-yard.entity';
export class CreateWarehouseYardDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@@ -46,4 +51,22 @@ export class CreateWarehouseYardDto {
@IsNumber()
@Min(0)
maxVolume?: number;
@ApiPropertyOptional({
enum: WAREHOUSE_YARD_DIRECTIONS,
description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.',
})
@IsOptional()
@IsEnum(WAREHOUSE_YARD_DIRECTIONS)
direction?: WarehouseYardDirection;
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.',
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
cargoTypeIds?: string[];
}

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Warehouse } from './warehouse.entity';
import { WarehouseZone } from './warehouse-zone.entity';
@@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
/**
* Which trade direction this yard serves. Only meaningful for CONTAINER_YARD,
* where import and export stacks are physically separate areas (e.g. Indode's
* Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo
* either way, so BOTH/null is the right default there.
*/
export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number];
@Entity({ schema: 'freight', name: 'warehouse_yards' })
@Index(['warehouseId'])
@Index(['type'])
@@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity {
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
/** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
direction?: WarehouseYardDirection | null;
/**
* Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck,
* Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any
* cargo type of the yard's structural `type` (the pre-existing behavior),
* so this is additive and never blocks a yard that hasn't been configured.
*/
@ManyToMany(() => CargoType)
@JoinTable({
name: 'warehouse_yard_cargo_types',
schema: 'freight',
joinColumn: { name: 'yard_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
})
cargoTypes?: CargoType[];
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
zones?: WarehouseZone[];
}

View File

@@ -0,0 +1,82 @@
import { WarehouseFeeService } from './warehouse-fee.service';
/**
* Detention is per truck: two trucks on the same delivery with different
* windows must produce different chargeable days and amounts (the old
* leg-level clock billed them identically).
*/
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const svc = Object.create(WarehouseFeeService.prototype) as {
computeTruckDetention: (
rule: Record<string, unknown> | null,
row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number },
now: Date,
billingCurrency: string,
) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>;
normalizeCurrency: (c?: string | null) => string;
convertAmount: (a: number, from: string, to: string) => Promise<number>;
calculateTieredAmount: unknown;
};
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
svc.convertAmount = async (a) => a;
// 3h grace, 50/truck/day, no tiers.
const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] };
const now = new Date('2026-07-25T12:00:00Z');
describe('per-truck detention', () => {
it('bills each truck on its own window', async () => {
// Truck A: out ~1 day past grace. Truck B: out ~3 days past grace.
const a = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
const b = await svc.computeTruckDetention(
rule,
{
arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR),
deliveredAt: now,
truckCount: 1,
},
now,
'USD',
);
expect(a.chargeableDays).toBe(2);
expect(b.chargeableDays).toBe(4);
expect(a.amount).toBe(100);
expect(b.amount).toBe(200);
// The whole point: same delivery, different bills.
expect(a.amount).not.toBe(b.amount);
});
it('charges nothing inside the grace window', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 },
now,
'USD',
);
expect(out.chargeableDays).toBe(0);
expect(out.amount).toBe(0);
});
it('keeps accruing against now when a truck has not returned', async () => {
const out = await svc.computeTruckDetention(
rule,
{ arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 },
now,
'USD',
);
expect(out.endIsOpen).toBe(true);
expect(out.chargeableDays).toBe(2);
});
});

View File

@@ -17,6 +17,8 @@ export interface ImportTrainRow {
route: string | null;
origin: string | null;
destination: string | null;
/** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */
destinationStationId: string | null;
arrivalTime: string | null;
totalBookings: number;
totalContainers: number;
@@ -38,6 +40,8 @@ export interface ImportTrainItemRow {
freightType: string | null;
containerNumber: string | null;
cargoType: string | null;
/** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */
cargoTypeCode: string | null;
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
@@ -205,6 +209,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.id AS "destinationStationId",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
@@ -280,6 +285,7 @@ export class SchedulingReadFacade {
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
cgt.code AS "cargoTypeCode",
b.cargo_total_weight_vgm AS "weight",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
COALESCE(inv.status, b.status) AS "currentStatus",
@@ -376,6 +382,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.id AS "destinationStationId",
dy.label AS "destinationName",
oy.country AS "originCountry",
dy.country AS "destinationCountry",

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