This commit is contained in:
Stephanos A
2026-06-25 20:10:54 +03:00
274 changed files with 16658 additions and 5469 deletions

View File

View File

@@ -0,0 +1 @@
{"dependencies":{"pnpm":"11.1.1"}}

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@@ -52,3 +52,10 @@ MINIO_SECRET_KEY=
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
# --- Notification broker (RabbitMQ) ---------------------------------------------
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
RABBITMQ_ENABLED=false
RABBITMQ_URL=amqp://localhost:5672
SMS_QUEUE=sms_queue

View File

@@ -17,6 +17,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
},
@@ -37,7 +38,7 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",

View File

@@ -61,12 +61,12 @@ import { ContainersModule } from './modules/container-management/containers.modu
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module';
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
@Module({
imports: [
@@ -123,13 +123,13 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
ContainersModule,
CargoesModule,
RoutesModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
DriversModule,
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -7,13 +7,13 @@ export function deriveTradeDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
const originCountry = originYard.country?.trim().toLowerCase();
const destinationCountry = destinationYard.country?.trim().toLowerCase();
if (originCountry === 'Djibouti') {
if (originCountry === 'djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddVehicleCodeAndPlates1810000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const hasCode = await queryRunner.hasColumn('freight.vehicles', 'code');
if (!hasCode) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'code', type: 'varchar', isNullable: true }),
);
}
const hasPower = await queryRunner.hasColumn('freight.vehicles', 'power_plate_no');
if (!hasPower) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'power_plate_no', type: 'varchar', isNullable: true }),
);
}
const hasTrailer = await queryRunner.hasColumn('freight.vehicles', 'trailer_plate_no');
if (!hasTrailer) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'trailer_plate_no', type: 'varchar', isNullable: true }),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('freight.vehicles', 'trailer_plate_no');
await queryRunner.dropColumn('freight.vehicles', 'power_plate_no');
await queryRunner.dropColumn('freight.vehicles', 'code');
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Company-profile references are now minted only when a profile is approved
* (status → Active); pending profiles carry NULL. Drop the NOT NULL constraint
* on freight.company_profiles.reference. The existing unique index is kept —
* Postgres treats NULLs as distinct, so multiple pending (NULL) profiles don't
* collide.
*/
export class MakeCompanyProfileReferenceNullable1810000000002
implements MigrationInterface
{
name = "MakeCompanyProfileReferenceNullable1810000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" DROP NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Reinstating NOT NULL requires every row to have a reference; any pending
// (NULL) profiles get a placeholder so the constraint can be re-applied.
await queryRunner.query(
`UPDATE "freight"."company_profiles" SET "reference" = 'PENDING-' || left(replace("id"::text, '-', ''), 12) WHERE "reference" IS NULL`,
);
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" SET NOT NULL`,
);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner, Table } from "typeorm";
/**
* Create the public.otp_verifications table backing the OTP module
* (OtpVerification entity). One row per phone, holding the latest server-issued
* code and whether that phone has been verified.
*/
export class CreateOtpVerifications1810000000003
implements MigrationInterface
{
name = "CreateOtpVerifications1810000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable("otp_verifications");
if (exists) return;
await queryRunner.createTable(
new Table({
name: "otp_verifications",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
default: "gen_random_uuid()",
},
{ name: "phone", type: "varchar", isUnique: true },
{ name: "otp", type: "varchar" },
{ name: "verified", type: "boolean", default: false },
{ name: "created_at", type: "timestamptz", default: "now()" },
{ name: "updated_at", type: "timestamptz", default: "now()" },
{ name: "deleted_at", type: "timestamptz", isNullable: true },
],
}),
true,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("otp_verifications", true);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Multi-locomotive train sets: a train set is now pulled by 2+ locomotives.
*
* Adds the `freight.train_set_locomotives` link table (train set ⇄ locomotive,
* with an order index) and backfills one row per existing train set from its
* current `locomotive_id`, so existing read paths keep resolving locomotives.
* The `train_sets.locomotive_id` column is retained as the "primary" locomotive.
*
* NOTE: the shared dev DB has no applied migration history, so this is also
* hand-applied there. IF NOT EXISTS keeps that idempotent.
*/
export class AddTrainSetLocomotives1820000000011 implements MigrationInterface {
name = 'AddTrainSetLocomotives1820000000011';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_set_locomotives (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
train_set_id uuid NOT NULL,
locomotive_id uuid NOT NULL,
sequence_no int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT "PK_train_set_locomotives" PRIMARY KEY (id),
CONSTRAINT "FK_train_set_locomotives_train_set" FOREIGN KEY (train_set_id)
REFERENCES freight.train_sets (id) ON DELETE CASCADE,
CONSTRAINT "FK_train_set_locomotives_locomotive" FOREIGN KEY (locomotive_id)
REFERENCES freight.locomotives (id)
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_set_locomotives_set_loco"
ON freight.train_set_locomotives (train_set_id, locomotive_id);
`);
// Backfill: one link row per existing train set, from its current primary loco.
await queryRunner.query(`
INSERT INTO freight.train_set_locomotives (train_set_id, locomotive_id, sequence_no)
SELECT ts.id, ts.locomotive_id, 0
FROM freight.train_sets ts
WHERE ts.locomotive_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM freight.train_set_locomotives tsl
WHERE tsl.train_set_id = ts.id AND tsl.locomotive_id = ts.locomotive_id
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_train_set_locomotives_set_loco";`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_locomotives;`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Contact email/phone for an external profile is sourced from IAM (the user's
* identity) and from the company record, so the duplicated `email`/`phone`
* columns on external_profiles are redundant and are dropped. Dropping `email`
* also removes its UNIQUE constraint.
*/
export class DropEmailPhoneFromExternalProfiles1820000000011
implements MigrationInterface
{
name = 'DropEmailPhoneFromExternalProfiles1820000000011';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS email;`,
);
await queryRunner.query(
`ALTER TABLE freight.external_profiles DROP COLUMN IF EXISTS phone;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Re-added as nullable (the original email was UNIQUE NOT NULL) since the
// dropped values cannot be recovered to satisfy those constraints.
await queryRunner.query(
`ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS email varchar(150);`,
);
await queryRunner.query(
`ALTER TABLE freight.external_profiles ADD COLUMN IF NOT EXISTS phone varchar(20);`,
);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* The booking wizard now captures a NON-BINDING estimated shipment date instead
* of the binding scheduledDate. The binding scheduledDate (validated against
* open train departures) is set later, at the operation-request step.
*/
export class AddEstimatedShipmentDate1820000000012
implements MigrationInterface
{
name = 'AddEstimatedShipmentDate1820000000012';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS estimated_shipment_date;
`);
}
}

View File

@@ -0,0 +1,109 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
export class CreateInterchangeDocuments1821000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'interchange_documents',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'document_no', type: 'varchar', length: '40', isUnique: true },
{ name: 'direction', type: 'varchar', length: '10' },
{ name: 'schedule_id', type: 'uuid', isNullable: true },
{ name: 'train_no', type: 'varchar', length: '40', isNullable: true },
{ name: 'route_id', type: 'uuid', isNullable: true },
{ name: 'origin_facility_id', type: 'uuid', isNullable: true },
{ name: 'destination_facility_id', type: 'uuid', isNullable: true },
{ name: 'handover_location', type: 'varchar', length: '255' },
{ name: 'handover_from', type: 'varchar', length: '255' },
{ name: 'handover_to', type: 'varchar', length: '255' },
{ name: 'operator_name', type: 'varchar', length: '255', isNullable: true },
{ name: 'port_operator_name', type: 'varchar', length: '255', isNullable: true },
{ name: 'shipping_line_name', type: 'varchar', length: '255', isNullable: true },
{ name: 'customs_reference', type: 'varchar', length: '120', isNullable: true },
{ name: 'manifest_reference', type: 'varchar', length: '120', isNullable: true },
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
{ name: 'generated_at', type: 'timestamptz', isNullable: true },
{ name: 'acknowledged_at', type: 'timestamptz', isNullable: true },
{ name: 'generated_by', type: 'varchar', length: '120', isNullable: true },
{ name: 'acknowledged_by', type: 'varchar', length: '120', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
indices: [
{ name: 'idx_interchange_documents_direction', columnNames: ['direction'] },
{ name: 'idx_interchange_documents_status', columnNames: ['status'] },
{ name: 'idx_interchange_documents_schedule', columnNames: ['schedule_id'] },
],
}),
true,
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'interchange_document_items',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
{ name: 'interchange_document_id', type: 'uuid' },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'booking_reference', type: 'varchar', length: '64', isNullable: true },
{ name: 'item_type', type: 'varchar', length: '20' },
{ name: 'booking_container_id', type: 'uuid', isNullable: true },
{ name: 'booking_cargo_id', type: 'uuid', isNullable: true },
{ name: 'container_number', type: 'varchar', length: '64', isNullable: true },
{ name: 'seal_number', type: 'varchar', length: '100', isNullable: true },
{ name: 'cargo_id', type: 'uuid', isNullable: true },
{ name: 'cargo_type', type: 'varchar', length: '255', isNullable: true },
{ name: 'cargo_description', type: 'text', isNullable: true },
{ name: 'weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
{ name: 'quantity', type: 'numeric', precision: 12, scale: 3, isNullable: true },
{ name: 'package_count', type: 'int', isNullable: true },
{ name: 'wagon_number', type: 'varchar', length: '80', isNullable: true },
{ name: 'condition_status', type: 'varchar', length: '20', default: "'GOOD'" },
{ name: 'damage_description', type: 'text', isNullable: true },
{ name: 'remarks', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['interchange_document_id'],
referencedSchema: 'freight',
referencedTableName: 'interchange_documents',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
{
columnNames: ['booking_id'],
referencedSchema: 'freight',
referencedTableName: 'bookings',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
},
],
indices: [
{ name: 'idx_interchange_items_document', columnNames: ['interchange_document_id'] },
{ name: 'idx_interchange_items_booking', columnNames: ['booking_id'] },
],
}),
true,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_interchange_active_schedule_direction
ON freight.interchange_documents(schedule_id, direction)
WHERE schedule_id IS NOT NULL AND status <> 'CANCELLED' AND deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX IF EXISTS freight.uq_interchange_active_schedule_direction;');
await queryRunner.dropTable('freight.interchange_document_items', true);
await queryRunner.dropTable('freight.interchange_documents', true);
}
}

View File

@@ -41,12 +41,54 @@ export class BookingOrdersService {
) {}
/** Orders placed against a contract, with their lines and child booking. */
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.ordersRepository.findByContract(contractBookingId);
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
const orders = await this.ordersRepository.findByContract(contractBookingId);
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
return orders;
}
findById(id: string): Promise<BookingOrder | null> {
return this.ordersRepository.findById(id);
async findById(id: string): Promise<BookingOrder | null> {
const order = await this.ordersRepository.findById(id);
if (order) await this.syncOrderFromChild(order);
return order;
}
/**
* The order is a ledger row; the spawned child ONE_TIME booking is what
* actually moves through the workflow (clearance → marketing/ops accept →
* pay → allocate), exactly like a one-time booking. Nothing writes the order
* row after creation, so its stored status would stay 'PENDING' forever.
*
* Mirror the child onto the order whenever it is read: copy the child's
* status, schedulingStatus and trainScheduleId onto the order (mutating the
* in-memory instance the caller gets back), and persist that snapshot when it
* has drifted so list/detail views and any stored reporting stay in sync.
*/
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
const child = order.booking;
if (!child) return;
const nextStatus = child.status;
const nextScheduling = child.schedulingStatus;
const nextTrainScheduleId = child.trainScheduleId ?? null;
const drifted =
order.status !== nextStatus ||
order.schedulingStatus !== nextScheduling ||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
// Reflect the child onto the instance returned to the caller.
order.status = nextStatus;
order.schedulingStatus = nextScheduling;
order.trainScheduleId = nextTrainScheduleId;
if (drifted) {
await this.ordersRepository.update(order.id, {
status: nextStatus,
schedulingStatus: nextScheduling,
trainScheduleId: nextTrainScheduleId,
});
}
}
/**
@@ -125,7 +167,6 @@ export class BookingOrdersService {
}
const isContainer = contract.freightType === 'CONTAINER';
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
// Hazardous/reefer counts the customer entered cannot exceed the line they
// belong to. Validated for every order regardless of routing.
@@ -142,43 +183,31 @@ export class BookingOrdersService {
}
}
if (routeLineId) {
// Multi-route: validate against the chosen route line's remaining pool.
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
// The contract has a single shared drawdown pool (per container type for
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
// only fixed origin/destination/km above — so every order, routed or not,
// validates each line against the same shared pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!;
if (orderTotal > chosen.remainingQuantity) {
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
} else {
// Single-route: validate each line against the per-container-type pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
}

View File

@@ -22,7 +22,13 @@ export class ContractQuantityLineView {
remainingQuantity!: number;
}
/** A contracted/ordered/remaining pool line for one route of a general contract. */
/**
* A contracted route (lane) of a general contract. Routes are pure
* origin→destination lanes the contract covers; they carry NO quantity. The
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
* and an order picks one lane (for scheduling/billing) while drawing from that
* shared pool.
*/
export class ContractRouteLineView {
@ApiProperty({ description: 'Contract route line id' })
routeLineId!: string;
@@ -39,21 +45,6 @@ export class ContractRouteLineView {
@ApiProperty({ nullable: true })
destinationYardName!: string | null;
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
km!: number | null;
}

View File

@@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { BookingOrder } from './booking-order.entity';
/**
* Postgres `numeric` columns are serialized to JS strings by the driver. This
* transformer hydrates them back into real numbers so consumers (and the
* `quantity: number` API type) don't have to coerce on every read.
*/
const numericColumn = {
to: (value: number) => value,
from: (value: string | null) => (value == null ? value : Number(value)),
};
/**
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
@@ -25,7 +35,7 @@ export class BookingOrderLine extends BaseEntity {
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
quantity!: number;
/**
@@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity {
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
*/
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
@Column({
name: 'hazardous_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
hazardousQuantity!: number;
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
@Column({
name: 'reefer_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
reeferQuantity!: number;
}

View File

@@ -125,10 +125,12 @@ export class GeneralContractService {
}
/**
* Per-route drawdown pool for a multi-route general contract: contracted vs.
* ordered vs. remaining, one entry per contracted route line. Returns [] for
* single-route contracts (no route lines) — callers fall back to
* {@link getQuantityLines}.
* The contracted routes (lanes) of a multi-route general contract — pure
* origin→destination pairs the contract covers. Routes carry NO quantity; the
* contract draws from a single shared pool ({@link getQuantityLines}). An order
* picks one lane (for scheduling + road billing) and draws from that pool.
* Returns [] for single-route contracts (no route lines) — callers then use the
* contract's own origin/destination.
*/
async getRouteLines(
contractBookingId: string,
@@ -140,52 +142,18 @@ export class GeneralContractService {
relations: {
originYard: true,
destinationYard: true,
containerType: true,
},
order: { createdAt: 'ASC' },
});
if (routeLines.length === 0) return [];
const ordered = await this.orderedByRouteLine(contractBookingId);
return routeLines.map((rl) => {
const orderedQty = ordered.get(rl.id) ?? 0;
const contracted = Number(rl.quantity);
return {
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
containerTypeId: rl.containerTypeId ?? null,
containerTypeName: rl.containerType?.label ?? null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
km: rl.km != null ? Number(rl.km) : null,
};
});
}
/** Sum of non-cancelled order quantities, keyed by route_line_id. */
private async orderedByRouteLine(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('o.route_line_id', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere('o.route_line_id IS NOT NULL')
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.groupBy('o.route_line_id')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) if (row.key) map.set(row.key, Number(row.total));
return map;
return routeLines.map((rl) => ({
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
km: rl.km != null ? Number(rl.km) : null,
}));
}
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
@@ -221,14 +189,12 @@ export class GeneralContractService {
return line?.remainingQuantity ?? 0;
}
/** True once every contracted line is fully drawn down. */
/**
* True once the contract's shared pool is fully drawn down. Routes are pure
* lanes with no quantity, so exhaustion is purely a function of the shared
* per-container-type (or bulk) pool, regardless of how many routes exist.
*/
async isExhausted(contractBookingId: string): Promise<boolean> {
// Multi-route contracts are exhausted when every route line is drawn down;
// single-route contracts fall back to the per-container-type pool.
const routeLines = await this.getRouteLines(contractBookingId);
if (routeLines.length > 0) {
return routeLines.every((l) => l.remainingQuantity <= 0);
}
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}

View File

@@ -276,6 +276,12 @@ export class BookingPricingService {
allowConsolidation,
shippingLineId: booking.shippingLineId,
totalWagons,
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
// Container freight carries 0 here — its surcharges scale by container count.
bulkTons:
booking.freightType === 'BULK'
? Number(booking.cargoTotalWeightVgm ?? 0)
: 0,
containers,
};
}

View File

@@ -63,7 +63,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => {
const { service, bookingsRepository } = makeService([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
@@ -75,3 +75,165 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
);
});
});
/**
* Customs bookings additionally require the GL output documents before
* finalizing — they are cleared by Global Logistics, not the customer alone.
*/
describe('BookingTransitionService — finalizeClearance customs output gate', () => {
const customsBooking = {
id: 'b-2',
status: 'DOCUMENTS_UNDER_REVIEW',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: true }, // input + output sets apply
};
const inputSetting = {
code: 'clearance_import_container_with_customs',
fields: [{ fileKey: 'commercial_invoice', isRequired: true }],
};
const outputSetting = {
code: 'clearance_output_import_container',
fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }],
};
function makeCustomsService(uploadedOutputCodes: string[]) {
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
]),
update: jest.fn().mockResolvedValue({ id: 'b-2' }),
};
const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) };
const fileUploadSettingsService = {
getByCode: jest.fn((code: string) =>
Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting),
),
};
const filesService = {
findByResource: jest
.fn()
.mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
filesService as never,
fileUploadSettingsService as never,
{} as never,
bookingsService as never,
);
return { service, bookingsRepository };
}
it('rejects when required customs output documents are missing', async () => {
const { service } = makeCustomsService([]); // no output uploaded
await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => {
const { service, bookingsRepository } = makeCustomsService(['im4']);
await service.finalizeClearance('b-2');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-2',
expect.objectContaining({ status: 'CLEARANCE_READY' }),
);
});
});
/**
* The first clearance submission (AWAITING_DOCUMENTS) must include every
* required input document; subsequent re-uploads during review only need the
* specific files being fixed, so already-uploaded required docs stay in place.
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
],
};
function makeService(status: string, existingCodes: string[]) {
const bookingsRepository = {
upsertDocumentReviewPending: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue({ id: 'b-3' }),
};
const booking = {
id: 'b-3',
status,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: false },
};
const bookingsService = { findById: jest.fn().mockResolvedValue(booking) };
const fileUploadSettingsService = {
getByCode: jest.fn().mockResolvedValue(inputSetting),
};
const filesService = {
findByResource: jest
.fn()
.mockResolvedValue(existingCodes.map((code) => ({ code }))),
upsertByCode: jest.fn().mockResolvedValue({ id: 'file-rec' }),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
filesService as never,
fileUploadSettingsService as never,
{} as never,
bookingsService as never,
);
return { service, bookingsRepository, filesService };
}
function fakeFile(fieldname: string): Express.Multer.File {
return { fieldname, originalname: `${fieldname}.pdf` } as Express.Multer.File;
}
it('rejects the first submission when a required document is missing', async () => {
const { service } = makeService('AWAITING_DOCUMENTS', []);
await expect(
service.submitClearanceDocuments('b-3', [fakeFile('commercial_invoice')]),
).rejects.toBeInstanceOf(BadRequestException);
});
it('accepts the first submission when every required document is provided', async () => {
const { service, bookingsRepository } = makeService('AWAITING_DOCUMENTS', []);
await service.submitClearanceDocuments('b-3', [
fakeFile('commercial_invoice'),
fakeFile('packing_list'),
]);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-3',
expect.objectContaining({ status: 'DOCUMENTS_UNDER_REVIEW' }),
);
});
it('allows re-uploading a single queried document during review without re-sending the rest', async () => {
// packing_list was already uploaded in the first round; the customer is now
// only re-uploading the queried commercial_invoice.
const { service, bookingsRepository } = makeService(
'DOCUMENTS_UNDER_REVIEW',
['packing_list'],
);
await service.submitClearanceDocuments('b-3', [
fakeFile('commercial_invoice'),
]);
// Only the re-uploaded doc is touched — no full re-gate, no rework on the rest.
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledTimes(1);
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledWith(
expect.objectContaining({ fileKey: 'commercial_invoice' }),
);
});
});

View File

@@ -645,6 +645,14 @@ export class BookingTransitionService {
throw new BadRequestException('No documents uploaded');
}
// First submission (nothing in review yet): every required input field must
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
// is only fixing queried/pending docs, so the already-uploaded required docs
// stay in place and we don't re-gate on the full required set.
if (booking.status === 'AWAITING_DOCUMENTS') {
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: bookingId,
@@ -670,6 +678,41 @@ export class BookingTransitionService {
return this.bookingsService.findById(bookingId);
}
/**
* Guard for the first clearance submission: every required field of the
* booking's customer-input set must be covered, either by a file already on
* the booking or by one in this upload batch. Keeps the customer from starting
* review with required documents missing.
*/
private async assertRequiredInputsPresent(
bookingId: string,
inputCode: string,
files: Express.Multer.File[],
): Promise<void> {
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return; // setting not seeded — nothing to enforce
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(bookingId, 'bookings');
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
]);
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
}
}
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
async reviewDocument(
bookingId: string,
@@ -795,6 +838,20 @@ export class BookingTransitionService {
throw new BadRequestException('A valid schedule date is required');
}
// The binding shipment day must have at least one OPEN departure on the
// route — only schedule-backed days are selectable. The batch engine
// assigns the specific train within that (route, day) pool later.
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
booking.originYardId,
booking.destinationYardId,
eatDay(date),
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING',
scheduledDate: date,

View File

@@ -134,6 +134,12 @@ export class BookingsController {
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general
// booking-request list.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
return this.bookingsService.findClearanceQueue(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId =
@@ -236,8 +242,12 @@ export class BookingsController {
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; customers only their own company's.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
// Staff see any booking; Global Logistics (clearance:view) may inspect any
// booking for the clearance gate; customers only their own company's.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,

View File

@@ -39,6 +39,7 @@ export interface BookingListFilterOptions {
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
consolidationPaired?: string;
@@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
excludePaymentStatus: options.excludePaymentStatus,
});
}
if (options.customsClearingEnabled !== undefined) {
qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {

View File

@@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
@@ -119,6 +120,18 @@ export class BookingsService {
}
/** Build evaluation input from booking freight shape. */
/**
* Whether a service type bundles customs clearance. This is the single source
* of truth for a booking's `customsClearingEnabled` — the customer cannot
* diverge from it, and it decides who clears the documents (GL vs Marketing).
*/
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
return serviceType?.includesCustoms ?? false;
}
private async buildEvalInput(dto: {
freightType: FreightType;
cargoTypeId?: string | null;
@@ -126,8 +139,10 @@ export class BookingsService {
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
isReefer?: boolean;
isGovernment?: boolean;
shippingLineId?: string | null;
bulkTons?: number;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
const containerLines =
@@ -166,10 +181,14 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer comes from the customer toggle; container reefer is derived
// from the container type and ORed in by the engine.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation,
shippingLineId: dto.shippingLineId,
totalWagons,
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
containers,
};
}
@@ -317,12 +336,14 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else if (!isGeneralContract) {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later. General contracts skip this — they have no shipment date at
// creation; each drawdown order validates its own day.
const day = eatDay(new Date(dto.scheduledDate!));
} else if (dto.scheduledDate) {
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
// directly). Require that the route has at least one OPEN departure on
// that EAT day. The booking wizard does NOT send scheduledDate at creation
// — it captures a non-binding estimatedShipmentDate instead, and the
// binding day is chosen later at the operation-request step. General
// contracts also skip this (each drawdown order validates its own day).
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
@@ -371,6 +392,17 @@ export class BookingsService {
tradeDirection,
fallbackType,
);
// A customer booking under their own account may only do so once the
// resolved operational profile has been approved by the backoffice. Staff-
// and government-initiated bookings (companyId supplied explicitly) bypass
// this gate.
const customerSelfBooking = !dto.companyId && !!userId;
if (customerSelfBooking && companyProfileId) {
await this.companiesService.assertCompanyProfileApprovedForBooking(
companyProfileId,
);
}
}
const needsConsolidation =
@@ -385,8 +417,10 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous,
isReefer: dto.isReefer,
isGovernment,
shippingLineId: dto.shippingLineId,
bulkTons: dto.cargoTotalWeightVgm,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
@@ -394,6 +428,11 @@ export class BookingsService {
warnings.push(...ruleResult.warnings);
// Customs clearing is owned by the service type, not the customer: when the
// service includes customs, EDR/GL clears it (no external agent); otherwise
// the customer clears it themselves and may name their broker.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
@@ -411,8 +450,8 @@ export class BookingsService {
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
customsClearingEnabled: dto.customsClearingEnabled ?? false,
customsClearingAgent: dto.customsClearingAgent ?? null,
customsClearingEnabled: includesCustoms,
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
@@ -423,11 +462,18 @@ export class BookingsService {
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer is the customer's toggle; container reefer is derived from
// the container type at pricing time, so the booking-level flag stays off
// for container freight to avoid double-counting.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
estimatedShipmentDate: dto.estimatedShipmentDate
? new Date(dto.estimatedShipmentDate)
: null,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
@@ -450,8 +496,11 @@ export class BookingsService {
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
// Multi-route general contracts: persist the contracted routes + quantities.
// Each drawdown order later draws from one of these route lines.
// Multi-route general contracts: persist the contracted routes (lanes). Routes
// carry NO quantity — the contract has a single shared pool (the cargo-step
// total / container quantities). Each drawdown order picks one lane for
// scheduling + road billing and draws from that shared pool. `quantity` on the
// route line is retained for legacy rows but is no longer meaningful (0).
if (isGeneralContract && dto.routes?.length) {
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
await routeRepo.save(
@@ -460,9 +509,8 @@ export class BookingsService {
contractBookingId: booking.id,
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
containerTypeId:
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
quantity: r.quantity,
containerTypeId: null,
quantity: 0,
km: r.km ?? null,
}),
),
@@ -480,7 +528,11 @@ export class BookingsService {
// Reuse the booking profile's onboarding documents instead of asking the
// customer to re-upload. Snapshot them onto the booking now (by reference),
// so a later active-profile switch never changes this booking's documents.
if (companyProfileId) {
//
// Skip this when the customer uploaded documents for this booking — those
// per-booking files take precedence, so auto-attaching the profile snapshots
// would create duplicates.
if (companyProfileId && files.length === 0) {
try {
const onboardingFiles =
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
@@ -577,7 +629,9 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
containers,
});
@@ -597,6 +651,12 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Booking-level reefer is only meaningful for bulk; container reefer is
// derived from the container type at pricing time.
isReefer:
freightType === 'BULK'
? (dto.isReefer ?? existing.isReefer ?? false)
: false,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
@@ -617,10 +677,22 @@ export class BookingsService {
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.estimatedShipmentDate)
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
// Customs clearing always mirrors the (possibly changed) service type — never
// the client payload — so it can't diverge from the service's customs scope.
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
await this.bookingsRepository.update(id, updates);
if (freightType === 'CONTAINER' && dto.containers) {
@@ -692,6 +764,23 @@ export class BookingsService {
}
/** Return a paginated list of bookings matching the filter. */
/**
* Whether a route has at least one OPEN train departure on the given EAT day.
* Used to validate the binding shipment day chosen at the operation-request
* step (only days with a schedule are selectable).
*/
async hasOpenDepartureOnDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<boolean> {
return this.trainSchedulingService.existsOpenScheduleOnRouteDay(
originYardId,
destinationYardId,
day,
);
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -738,6 +827,48 @@ export class BookingsService {
'AWAITING_PAYMENT',
];
/**
* Booking statuses that belong to the customs document-clearance queue. The
* Global Logistics role is scoped to ONLY these — it never sees the general
* booking-request list.
*/
private static readonly CLEARANCE_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
];
/**
* List bookings in the customs document-clearance queue. Used by Global
* Logistics (clearance:view) which has no general bookings:view — so the
* status set is force-scoped to clearance statuses and can't be widened to
* arbitrary bookings by a caller-supplied status filter.
*/
async findClearanceQueue(
filter: FilterBookingDto,
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 100;
// Honour a caller status filter only if it's within the clearance set;
// otherwise fall back to the full clearance status list.
const requested = filter.status;
const statuses =
requested && BookingsService.CLEARANCE_STATUSES.includes(requested)
? [requested]
: BookingsService.CLEARANCE_STATUSES;
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
statuses,
// Global Logistics only clears customs bookings; non-customs clearance is
// reviewed by Marketing from the booking detail, not this queue.
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* List the current customer's bookings that are ready for payment:
* payable status AND not yet PAID. Company scope is derived from the

View File

@@ -55,6 +55,12 @@ export class CreateBookingContainerDto {
vgmPerUnitTons!: number;
}
/**
* A contracted route (lane) of a general contract — a pure origin→destination
* pair the contract covers. Routes carry NO quantity; the contract draws from a
* single shared pool (the container quantities / bulk total on the booking). An
* order picks one lane (for scheduling + road billing) and draws from that pool.
*/
export class CreateContractRouteDto {
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
@@ -64,20 +70,6 @@ export class CreateContractRouteDto {
@IsUUID()
destinationYardId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for CONTAINER contracts; omit for BULK',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'Road distance (km) for this route; used to bill road orders.',
minimum: 0,
@@ -155,14 +147,24 @@ export class CreateBookingDto {
bookingType?: string;
/**
* The day the customer wants to ship (the pool day key). Required for one-time
* bookings; omitted for general contracts, which pick the date per order.
* The BINDING shipment day (the pool day key), validated against open train
* departures. Set later at the operation-request step — NOT at booking
* creation. Optional here; staff may still pin it directly.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
@IsOptional()
@IsDateString()
scheduledDate?: string;
/**
* Non-binding shipment-date estimate captured in the booking wizard. Purely
* informational — NOT validated against train departures.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@IsOptional()
@IsDateString()
estimatedShipmentDate?: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@@ -296,6 +298,17 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
/**
* Booking-level refrigerated flag. For bulk freight this is the customer's
* reefer choice (containers derive reefer from the container type instead).
* ORed with per-container reefer when the REEFER surcharge is evaluated.
*/
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;

View File

@@ -155,10 +155,23 @@ export class Booking extends BaseEntity {
/**
* Nullable: general contracts have no shipment date at creation — the date is
* chosen per drawdown order. One-time bookings always set this (the pool day key).
*
* NOTE: this is the BINDING shipment day, validated against actual open train
* departures. It is set later, when the customer requests the operation — NOT
* at booking creation. See estimatedShipmentDate for the non-binding estimate
* captured in the booking wizard.
*/
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
scheduledDate?: Date | null;
/**
* Non-binding shipment-date estimate captured in the booking wizard. Purely
* informational — NOT validated against train departures. The binding
* scheduledDate is chosen later at the operation-request step.
*/
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
estimatedShipmentDate?: Date | null;
/**
* General contracts only: when the ordering window closes, computed from the
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time

View File

@@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -226,6 +227,17 @@ export class CompaniesController {
await this.companiesService.setOnboardingStep(user.id, dto.step);
}
@Get("onboarding/requirements")
@ApiOperation({
summary:
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
})
async getOnboardingRequirements(
@CurrentUser() user: CurrentIamUser,
): Promise<OnboardingRequirementsResponseDto> {
return this.companiesService.getOnboardingRequirements(user.id);
}
@Post("onboarding/complete")
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding(

View File

@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { MinioModule } from "../minio/minio.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
HttpModule,
FilesModule,
FileUploadSettingsModule,
MinioModule,
],
controllers: [CompaniesController],

View File

@@ -3,6 +3,7 @@ import {
NotFoundException,
ConflictException,
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
@@ -12,7 +13,10 @@ import {
DashboardScope,
} from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.service";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
@@ -53,9 +57,67 @@ export class CompaniesService {
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
) { }
/**
* Required company-information fields that must be filled before onboarding can
* be submitted. The backend owns this list so the portal never has to know
* which fields are mandatory — it just renders what's reported outstanding.
* `get` reads the value from the company (some live in the attributes blob).
*/
private readonly REQUIRED_COMPANY_INFO: {
key: string;
label: string;
get: (company: Company) => unknown;
}[] = [
{
key: "tinNumber",
label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
},
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{
key: "contactPersonName",
label: "Contact person name",
get: (c) => c.attributes?.contactPersonName,
},
{
key: "contactPersonPhone",
label: "Contact person phone",
get: (c) => c.attributes?.contactPersonPhone,
},
{
key: "generalManagerName",
label: "General manager name",
get: (c) => c.attributes?.generalManagerName,
},
{
key: "generalManagerEmail",
label: "General manager email",
get: (c) => c.attributes?.generalManagerEmail,
},
{
key: "generalManagerPhone",
label: "General manager phone",
get: (c) => c.attributes?.generalManagerPhone,
},
];
/** The nationality-based document setting code for a company. */
private documentSettingCodeFor(
nationality: CompanyNationality | null | undefined,
): string {
return nationality === CompanyNationality.Foreign
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
@@ -77,10 +139,12 @@ export class CompaniesService {
}
}
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
const existingProfile = await this.profilesRepo.findByUserId(
identity.userId,
);
if (existingProfile) {
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
`Profile for user ${identity.userId} already exists`,
);
}
@@ -114,8 +178,6 @@ export class CompaniesService {
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: normalizeE164(identity.phone) ?? identity.phone,
jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true,
activeProfileType,
@@ -134,15 +196,13 @@ export class CompaniesService {
input.type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(
input.type,
);
// No reference yet — these profiles await backoffice approval, which
// is when the reference is minted (see setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
reference,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
@@ -191,15 +251,6 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(identity.userId);
}
// A profile may exist for the same email under a different IAM id — block
// duplicates as the final create does.
const byEmail = await this.profilesRepo.findByEmail(identity.email);
if (byEmail) {
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
);
}
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const activeProfileType =
@@ -224,8 +275,6 @@ export class CompaniesService {
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: normalizeE164(identity.phone) ?? identity.phone,
isPrimaryContact: true,
activeProfileType,
onboardingStep: "company",
@@ -251,12 +300,11 @@ export class CompaniesService {
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
}
@@ -527,6 +575,8 @@ export class CompaniesService {
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
@@ -576,10 +626,10 @@ export class CompaniesService {
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
await this.findCompanyById(dto.companyId);
const existing = await this.profilesRepo.findByEmail(dto.email);
const existing = await this.profilesRepo.findByUserId(dto.userId);
if (existing) {
throw new ConflictException(
`Profile with email ${dto.email} already exists`,
`Profile for user ${dto.userId} already exists`,
);
}
@@ -622,12 +672,33 @@ export class CompaniesService {
profileId: string,
status: ProfileStatus,
): Promise<CompanyProfile> {
const updated = await this.companyProfilesRepo.updateStatus(
profileId,
status,
);
const existing = await this.companyProfilesRepo.findById(profileId);
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
if (status === ProfileStatus.Active && !existing.reference) {
patch.reference = await this.companyProfilesRepo.generateReference(
existing.type,
);
}
const updated = await this.companyProfilesRepo.update(profileId, patch);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
// Approving any profile promotes a pending company to active, so the
// customer can start working as soon as their first profile is cleared.
if (status === ProfileStatus.Active) {
const company = await this.companiesRepo.findById(updated.companyId);
if (company && company.status === CompanyStatus.Pending) {
await this.companiesRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
}
}
return updated;
}
@@ -649,7 +720,7 @@ export class CompaniesService {
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) {
throw new ConflictException(
`Company already has a ${type} profile (${existing.reference})`,
`Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
);
}
@@ -813,6 +884,100 @@ export class CompaniesService {
await this.profilesRepo.update(profile.id, { onboardingStep: step });
}
/**
* Server-driven onboarding requirements for the current user's company.
*
* The backend resolves the nationality-based document set, checks which
* company documents and per-profile licenses are already uploaded, and reports
* exactly what is still outstanding. The portal renders this list verbatim and
* relies on `isComplete` to decide when to auto-finish — it never decides for
* itself which documents apply or which fields are mandatory.
*/
async getOnboardingRequirements(
userId: string,
): Promise<OnboardingRequirementsResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
// 1. Required company-information fields.
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => !f.get(company),
).map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
.getByCode(documentSettingCode)
.catch(() => null),
this.filesService.findByResource(company.id, "companies"),
]);
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
const documents = (setting?.fields ?? [])
.slice()
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((f) => ({
fileKey: f.fileKey,
fileLabel: f.fileLabel,
helpText: f.helpText ?? null,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.maxFiles,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
displayOrder: f.displayOrder,
uploaded: uploadedCodes.has(f.fileKey),
}));
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
// 3. Per-operational-profile business licenses.
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
profileId: p.id,
type: p.type,
reference: p.reference ?? "",
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
}));
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
...missingLicenses.map(
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents and one license per operational profile.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredDocCount +
licenseProfiles.length;
const completed =
total -
(missingInfo.length + missingDocs.length + missingLicenses.length);
return new OnboardingRequirementsResponseDto({
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
documents,
licenseProfiles,
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
outstanding,
});
}
/**
* Submit onboarding for review. Validation is delegated entirely to
* getOnboardingRequirements (the same source of truth the portal renders), so
* the gate can never drift from what the UI shows. On success the company and
* all its operational profiles move to PENDING — the backoffice approves each
* profile before it can be used (see setCompanyProfileStatus).
*/
async markOnboardingComplete(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
@@ -821,23 +986,21 @@ export class CompaniesService {
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
// Guard against finishing on a still-draft company (TIN never filled in).
if (!company.tin || company.tin.startsWith("D")) {
const requirements = await this.getOnboardingRequirements(userId);
if (!requirements.isComplete) {
throw new BadRequestException(
"Company information is incomplete — please fill in your company details before finishing.",
requirements.outstanding[0] ??
"Your onboarding is incomplete. Please complete all required steps before submitting.",
);
}
// Every operational profile must have at least one business-license file
// (stored directly on the profile).
// Send every operational profile in for approval; the company itself becomes
// active once the backoffice approves at least one profile.
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) {
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
throw new BadRequestException(
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
);
if (cp.status !== ProfileStatus.Pending) {
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
}
}
@@ -852,6 +1015,25 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(userId);
}
/**
* Block a customer from booking under a profile that isn't approved yet.
* Called from the booking-create path for self-service bookings; staff- and
* government-initiated bookings bypass this. No-op when the profile can't be
* found (defensive — resolution is best-effort upstream).
*/
async assertCompanyProfileApprovedForBooking(
companyProfileId: string,
): Promise<void> {
const profile = await this.companyProfilesRepo.findById(companyProfileId);
if (!profile) return;
if (profile.status !== ProfileStatus.Active) {
const role = profile.type.replace(/_/g, " ");
throw new ForbiddenException(
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
);
}
}
/**
* Authorize and resolve a company_profile that must belong to the current
* user's company — used before accepting/returning its license files.

View File

@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
}
async generateReference(type: ProfileType): Promise<string> {
const seqName = SEQUENCE_MAP[type];
// The sequences live in the same schema as the entity (e.g. "freight"), but
// the connection's search_path is "public" — so the sequence MUST be
// schema-qualified or `nextval` fails with "relation does not exist".
const schema = this.repository.metadata.schema ?? "public";
const seqName = `"${schema}".${SEQUENCE_MAP[type]}`;
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);

View File

@@ -1,5 +1,4 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator';
export class CreateExternalProfileDto {
@IsUUID()
@@ -20,16 +19,6 @@ export class CreateExternalProfileDto {
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
phone?: string;
@IsOptional()
@IsString()
@MaxLength(50)

View File

@@ -0,0 +1,78 @@
/**
* Server-driven description of what a company still needs to finish onboarding.
*
* The portal renders this verbatim instead of deciding for itself which
* documents apply or which fields are mandatory: the backend resolves the
* nationality-based document set, checks which files are already uploaded, and
* reports exactly what is outstanding. `isComplete` is the single source of
* truth the wizard uses to auto-finish.
*/
export interface OnboardingInfoField {
key: string;
label: string;
}
export interface OnboardingDocumentField {
fileKey: string;
fileLabel: string;
helpText: string | null;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
/** True when a file with this code is already stored for the company. */
uploaded: boolean;
}
export interface OnboardingLicenseProfile {
profileId: string;
type: string;
reference: string;
/** True when at least one business-license file is stored on the profile. */
uploaded: boolean;
}
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
nationality: string;
/** Required company-information fields and whether each is filled. */
companyInfo: {
complete: boolean;
missingFields: OnboardingInfoField[];
};
/** The document fields the portal should render, with upload state. */
documents: OnboardingDocumentField[];
/** Per-operational-profile business-license requirements. */
licenseProfiles: OnboardingLicenseProfile[];
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
/** True once every required field, document and license is satisfied. */
isComplete: boolean;
/** Whether the user has already submitted onboarding (awaiting approval). */
onboardingCompleted: boolean;
/** Human-readable list of everything still outstanding (empty when complete). */
outstanding: string[];
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode;
this.nationality = init.nationality;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;
this.outstanding = init.outstanding;
}
}

View File

@@ -34,6 +34,8 @@ export class ProfileResponseDto {
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
contactVerifiedPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null;

View File

@@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto {
this.id = profile.id;
this.companyId = profile.companyId;
this.type = profile.type;
this.reference = profile.reference;
this.reference = profile.reference ?? '';
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? [];

View File

@@ -10,8 +10,6 @@ export class ResponseExternalProfileDto {
companyId: string;
firstName: string;
lastName: string;
email: string;
phone?: string | null;
nationalId?: string | null;
jobTitle?: string | null;
isPrimaryContact: boolean;
@@ -34,8 +32,6 @@ export class ResponseExternalProfileDto {
this.companyId = profile.companyId;
this.firstName = profile.firstName;
this.lastName = profile.lastName;
this.email = profile.email;
this.phone = profile.phone;
this.nationalId = profile.nationalId;
this.jobTitle = profile.jobTitle;
this.isPrimaryContact = profile.isPrimaryContact;

View File

@@ -67,6 +67,16 @@ export class UpdateProfileDto {
@IsValidPhone()
contactPersonPhone?: string;
/**
* The contact-person phone that completed SMS OTP verification. Persisted so
* the onboarding "verify" step can resume its "done" state after a refresh
* (compared against the current contactPersonPhone on the client).
*/
@IsOptional()
@IsString()
@IsValidPhone()
contactVerifiedPhone?: string;
@IsOptional()
@IsString()
generalManagerName?: string;

View File

@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
type!: ProfileType;
/**
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
* is approved (status → Active); pending/unapproved profiles carry NULL.
* The unique index tolerates this because Postgres treats NULLs as distinct.
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
*/
@Column({
name: "reference",
type: "varchar",
length: 20,
nullable: false,
unique: true,
nullable: true,
})
reference!: string;
reference!: string | null;
@Column({
name: "status",

View File

@@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity {
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
email!: string;
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
phone?: string | null;
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
nationalId?: string | null;

View File

@@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
return this.repository.find({ where: { companyId } as any });
}
async findByEmail(email: string): Promise<ExternalProfile | null> {
return this.repository.findOne({ where: { email } as any });
}
}

View File

@@ -59,7 +59,7 @@ export class FirstMileController {
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.firstMileService.acceptBooking(reference);
return this.firstMileService.acceptBookingByReference(reference);
}
@Post()

View File

@@ -1,14 +1,23 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule],
imports: [
TypeOrmModule.forFeature([FirstMile]),
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],

View File

@@ -1,7 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
@Injectable()
export class FirstMileService {
private readonly logger = new Logger(FirstMileService.name);
constructor(
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
) {}
/**
@@ -36,8 +44,8 @@ export class FirstMileService {
* paid before any first-mile work proceeds. Throws if the reference is
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
return null;
@@ -53,6 +61,22 @@ export class FirstMileService {
});
}
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
return null;
}
if (booking.paymentStatus !== 'PAID') {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
}
async findAll(filter: FirstMileListFilter = {}): Promise<{
data: FirstMile[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
@@ -109,7 +133,7 @@ export class FirstMileService {
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
return this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'PAYMENT_PENDING',
status: dto.status ?? 'READY_TO_TRANSIT',
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
@@ -119,7 +143,7 @@ export class FirstMileService {
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
await this.findById(id);
const existing = await this.findById(id);
const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -135,9 +159,45 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${id} not found`);
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
return;
}
const driver = await this.driversService.findById(vehicle.assignedDriverId);
if (!driver.phoneNumber) {
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
return;
}
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.firstMileRepository.softDelete(id);

View File

@@ -0,0 +1,3 @@
import { GenerateFromScheduleDto } from './generate-from-schedule.dto';
export class CreateInterchangeDocumentDto extends GenerateFromScheduleDto {}

View File

@@ -0,0 +1,57 @@
import { IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { INTERCHANGE_DIRECTIONS, InterchangeDirection } from '../entities/interchange-document.entity';
export class GenerateFromScheduleDto {
@IsUUID()
scheduleId!: string;
@IsIn(INTERCHANGE_DIRECTIONS)
direction!: InterchangeDirection;
@IsString()
@MaxLength(255)
handoverLocation!: string;
@IsString()
@MaxLength(255)
handoverFrom!: string;
@IsString()
@MaxLength(255)
handoverTo!: string;
@IsOptional()
@IsString()
@MaxLength(255)
operatorName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
portOperatorName?: string;
@IsOptional()
@IsString()
@MaxLength(255)
shippingLineName?: string;
@IsOptional()
@IsString()
@MaxLength(120)
customsReference?: string;
@IsOptional()
@IsString()
@MaxLength(120)
manifestReference?: string;
@IsOptional()
@IsString()
@MaxLength(120)
generatedBy?: string;
@IsOptional()
@IsString()
remarks?: string;
}

View File

@@ -0,0 +1,38 @@
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import {
INTERCHANGE_DIRECTIONS,
INTERCHANGE_DOCUMENT_STATUSES,
InterchangeDirection,
InterchangeDocumentStatus,
} from '../entities/interchange-document.entity';
export class InterchangeDocumentQueryDto {
@IsOptional()
@IsIn(INTERCHANGE_DIRECTIONS)
direction?: InterchangeDirection;
@IsOptional()
@IsIn(INTERCHANGE_DOCUMENT_STATUSES)
status?: InterchangeDocumentStatus;
@IsOptional()
@IsUUID()
scheduleId?: string;
@IsOptional()
@IsString()
documentNo?: string;
@IsOptional()
@IsString()
dateFrom?: string;
@IsOptional()
@IsString()
dateTo?: string;
@IsOptional()
@IsString()
search?: string;
}

View File

@@ -0,0 +1,16 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class AcknowledgeInterchangeDocumentDto {
@IsString()
@MaxLength(120)
acknowledgedBy!: string;
@IsOptional()
@IsString()
remarks?: string;
}
export class DisputeInterchangeDocumentDto {
@IsString()
remarks!: string;
}

View File

@@ -0,0 +1,85 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { InterchangeDocument } from './interchange-document.entity';
export const INTERCHANGE_ITEM_TYPES = ['CONTAINER', 'CARGO'] as const;
export type InterchangeItemType = (typeof INTERCHANGE_ITEM_TYPES)[number];
export const INTERCHANGE_CONDITION_STATUSES = [
'GOOD',
'DAMAGED',
'SHORTAGE',
'EXCESS',
'HOLD',
'UNKNOWN',
] as const;
export type InterchangeConditionStatus = (typeof INTERCHANGE_CONDITION_STATUSES)[number];
@Entity({ schema: 'freight', name: 'interchange_document_items' })
@Index(['interchangeDocumentId'])
@Index(['bookingId'])
export class InterchangeDocumentItem extends BaseEntity {
@Column({ name: 'interchange_document_id', type: 'uuid' })
interchangeDocumentId!: string;
@ManyToOne(() => InterchangeDocument, (document) => document.items, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'interchange_document_id' })
document?: InterchangeDocument;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
@Column({ name: 'booking_reference', type: 'varchar', length: 64, nullable: true })
bookingReference?: string | null;
@Column({ name: 'item_type', type: 'varchar', length: 20 })
itemType!: InterchangeItemType;
@Column({ name: 'booking_container_id', type: 'uuid', nullable: true })
bookingContainerId?: string | null;
@Column({ name: 'booking_cargo_id', type: 'uuid', nullable: true })
bookingCargoId?: string | null;
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
containerNumber?: string | null;
@Column({ name: 'seal_number', type: 'varchar', length: 100, nullable: true })
sealNumber?: string | null;
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
cargoId?: string | null;
@Column({ name: 'cargo_type', type: 'varchar', length: 255, nullable: true })
cargoType?: string | null;
@Column({ name: 'cargo_description', type: 'text', nullable: true })
cargoDescription?: string | null;
@Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
weight?: number | null;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, nullable: true })
quantity?: number | null;
@Column({ name: 'package_count', type: 'int', nullable: true })
packageCount?: number | null;
@Column({ name: 'wagon_number', type: 'varchar', length: 80, nullable: true })
wagonNumber?: string | null;
@Column({ name: 'condition_status', type: 'varchar', length: 20, default: 'GOOD' })
conditionStatus!: InterchangeConditionStatus;
@Column({ name: 'damage_description', type: 'text', nullable: true })
damageDescription?: string | null;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
}

View File

@@ -0,0 +1,89 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { InterchangeDocumentItem } from './interchange-document-item.entity';
export const INTERCHANGE_DIRECTIONS = ['IMPORT', 'EXPORT'] as const;
export type InterchangeDirection = (typeof INTERCHANGE_DIRECTIONS)[number];
export const INTERCHANGE_DOCUMENT_STATUSES = [
'DRAFT',
'GENERATED',
'ACKNOWLEDGED',
'DISPUTED',
'CANCELLED',
] as const;
export type InterchangeDocumentStatus = (typeof INTERCHANGE_DOCUMENT_STATUSES)[number];
@Entity({ schema: 'freight', name: 'interchange_documents' })
@Index(['documentNo'], { unique: true })
@Index(['direction'])
@Index(['status'])
@Index(['scheduleId'])
export class InterchangeDocument extends BaseEntity {
@Column({ name: 'document_no', type: 'varchar', length: 40, unique: true })
documentNo!: string;
@Column({ name: 'direction', type: 'varchar', length: 10 })
direction!: InterchangeDirection;
@Column({ name: 'schedule_id', type: 'uuid', nullable: true })
scheduleId?: string | null;
@Column({ name: 'train_no', type: 'varchar', length: 40, nullable: true })
trainNo?: string | null;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string | null;
@Column({ name: 'origin_facility_id', type: 'uuid', nullable: true })
originFacilityId?: string | null;
@Column({ name: 'destination_facility_id', type: 'uuid', nullable: true })
destinationFacilityId?: string | null;
@Column({ name: 'handover_location', type: 'varchar', length: 255 })
handoverLocation!: string;
@Column({ name: 'handover_from', type: 'varchar', length: 255 })
handoverFrom!: string;
@Column({ name: 'handover_to', type: 'varchar', length: 255 })
handoverTo!: string;
@Column({ name: 'operator_name', type: 'varchar', length: 255, nullable: true })
operatorName?: string | null;
@Column({ name: 'port_operator_name', type: 'varchar', length: 255, nullable: true })
portOperatorName?: string | null;
@Column({ name: 'shipping_line_name', type: 'varchar', length: 255, nullable: true })
shippingLineName?: string | null;
@Column({ name: 'customs_reference', type: 'varchar', length: 120, nullable: true })
customsReference?: string | null;
@Column({ name: 'manifest_reference', type: 'varchar', length: 120, nullable: true })
manifestReference?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: InterchangeDocumentStatus;
@Column({ name: 'generated_at', type: 'timestamptz', nullable: true })
generatedAt?: Date | null;
@Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true })
acknowledgedAt?: Date | null;
@Column({ name: 'generated_by', type: 'varchar', length: 120, nullable: true })
generatedBy?: string | null;
@Column({ name: 'acknowledged_by', type: 'varchar', length: 120, nullable: true })
acknowledgedBy?: string | null;
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string | null;
@OneToMany(() => InterchangeDocumentItem, (item) => item.document)
items?: InterchangeDocumentItem[];
}

View File

@@ -0,0 +1,56 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
import {
AcknowledgeInterchangeDocumentDto,
DisputeInterchangeDocumentDto,
} from './dto/update-interchange-document-status.dto';
import { InterchangeDocumentsService } from './interchange-documents.service';
@ApiTags('interchange-documents')
@ApiBearerAuth()
@Controller('interchange-documents')
export class InterchangeDocumentsController {
constructor(private readonly service: InterchangeDocumentsService) {}
@Get()
@ApiOperation({ summary: 'List interchange documents' })
findAll(@Query() query: InterchangeDocumentQueryDto) {
return this.service.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get interchange document detail' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findOne(id);
}
@Post('generate-from-schedule')
@ApiOperation({ summary: 'Generate interchange document from a train schedule handover' })
generateFromSchedule(@Body() dto: GenerateFromScheduleDto) {
return this.service.generateFromSchedule(dto);
}
@Patch(':id/acknowledge')
@ApiOperation({ summary: 'Acknowledge an interchange document' })
acknowledge(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcknowledgeInterchangeDocumentDto,
) {
return this.service.acknowledge(id, dto);
}
@Patch(':id/dispute')
@ApiOperation({ summary: 'Dispute an interchange document' })
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
return this.service.dispute(id, dto);
}
@Patch(':id/cancel')
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancel(id);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { InterchangeDocumentItem } from './entities/interchange-document-item.entity';
import { InterchangeDocument } from './entities/interchange-document.entity';
import { InterchangeDocumentsController } from './interchange-documents.controller';
import { InterchangeDocumentsRepository } from './interchange-documents.repository';
import { InterchangeDocumentsService } from './interchange-documents.service';
@Module({
imports: [TypeOrmModule.forFeature([InterchangeDocument, InterchangeDocumentItem])],
controllers: [InterchangeDocumentsController],
providers: [InterchangeDocumentsRepository, InterchangeDocumentsService],
exports: [InterchangeDocumentsService],
})
export class InterchangeDocumentsModule {}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InterchangeDocument } from './entities/interchange-document.entity';
@Injectable()
export class InterchangeDocumentsRepository extends BaseRepository<InterchangeDocument> {
constructor(@InjectRepository(InterchangeDocument) repository: Repository<InterchangeDocument>) {
super(repository);
}
}

View File

@@ -0,0 +1,392 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, FindOptionsWhere, ILike, Not } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
import {
AcknowledgeInterchangeDocumentDto,
DisputeInterchangeDocumentDto,
} from './dto/update-interchange-document-status.dto';
import { InterchangeDocumentItem } from './entities/interchange-document-item.entity';
import {
InterchangeDirection,
InterchangeDocument,
InterchangeDocumentStatus,
} from './entities/interchange-document.entity';
interface ScheduleSnapshot {
id: string;
status: string;
trainNo: string | null;
routeId: string | null;
originFacilityId: string | null;
destinationFacilityId: string | null;
originCountry: string | null;
destinationCountry: string | null;
}
interface InterchangeItemSnapshot {
bookingId: string;
bookingReference: string | null;
itemType: 'CONTAINER' | 'CARGO';
bookingContainerId: string | null;
bookingCargoId: string | null;
containerNumber: string | null;
sealNumber: string | null;
cargoId: string | null;
cargoType: string | null;
cargoDescription: string | null;
weight: string | number | null;
quantity: string | number | null;
packageCount: string | number | null;
wagonNumber: string | null;
hasDamage: boolean | null;
damageDescription: string | null;
hasWeightLoss: boolean | null;
hasMissingItems: boolean | null;
missingItemsDescription: string | null;
}
@Injectable()
export class InterchangeDocumentsService {
constructor(private readonly dataSource: DataSource) {}
async findAll(query: InterchangeDocumentQueryDto): Promise<InterchangeDocument[]> {
const where: FindOptionsWhere<InterchangeDocument>[] = [];
const base: FindOptionsWhere<InterchangeDocument> = {
...(query.direction ? { direction: query.direction } : {}),
...(query.status ? { status: query.status } : {}),
...(query.scheduleId ? { scheduleId: query.scheduleId } : {}),
...(query.documentNo ? { documentNo: ILike(`%${query.documentNo}%`) } : {}),
};
const search = query.search?.trim();
if (search) {
where.push(
{ ...base, documentNo: ILike(`%${search}%`) },
{ ...base, trainNo: ILike(`%${search}%`) },
{ ...base, handoverLocation: ILike(`%${search}%`) },
{ ...base, handoverFrom: ILike(`%${search}%`) },
{ ...base, handoverTo: ILike(`%${search}%`) },
);
}
const qb = this.dataSource
.getRepository(InterchangeDocument)
.createQueryBuilder('doc')
.leftJoinAndSelect('doc.items', 'items')
.where(where.length ? where : base)
.orderBy('doc.createdAt', 'DESC')
.addOrderBy('items.createdAt', 'ASC');
if (query.dateFrom) qb.andWhere('doc.created_at >= :dateFrom', { dateFrom: query.dateFrom });
if (query.dateTo) qb.andWhere('doc.created_at <= :dateTo', { dateTo: query.dateTo });
return qb.getMany();
}
async findOne(id: string): Promise<InterchangeDocument> {
const document = await this.dataSource.getRepository(InterchangeDocument).findOne({
where: { id },
relations: { items: true },
order: { items: { createdAt: 'ASC' } },
});
if (!document) throw new NotFoundException(`Interchange document ${id} not found`);
return document;
}
async generateFromSchedule(dto: GenerateFromScheduleDto): Promise<InterchangeDocument> {
const existing = await this.dataSource.getRepository(InterchangeDocument).findOne({
where: {
scheduleId: dto.scheduleId,
direction: dto.direction,
status: Not('CANCELLED') as unknown as InterchangeDocumentStatus,
},
relations: { items: true },
});
if (existing) return existing;
const schedule = await this.getScheduleSnapshot(dto.scheduleId);
const routeDirection = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
if (routeDirection !== dto.direction) {
throw new BadRequestException(`Train schedule route is ${routeDirection}, not ${dto.direction}`);
}
const itemSnapshots = await this.getScheduleItems(dto.scheduleId);
if (itemSnapshots.length === 0) {
throw new BadRequestException('No booking/container/cargo items found for this schedule');
}
return this.dataSource.transaction(async (manager) => {
const now = new Date();
const document = manager.getRepository(InterchangeDocument).create({
documentNo: await this.nextDocumentNo(dto.direction),
direction: dto.direction,
scheduleId: schedule.id,
trainNo: schedule.trainNo,
routeId: schedule.routeId,
originFacilityId: schedule.originFacilityId,
destinationFacilityId: schedule.destinationFacilityId,
handoverLocation: dto.handoverLocation.trim(),
handoverFrom: dto.handoverFrom.trim(),
handoverTo: dto.handoverTo.trim(),
operatorName: dto.operatorName?.trim() || null,
portOperatorName: dto.portOperatorName?.trim() || null,
shippingLineName: dto.shippingLineName?.trim() || null,
customsReference: dto.customsReference?.trim() || null,
manifestReference: dto.manifestReference?.trim() || null,
status: 'GENERATED',
generatedAt: now,
generatedBy: dto.generatedBy?.trim() || null,
remarks: dto.remarks?.trim() || null,
});
const saved = await manager.getRepository(InterchangeDocument).save(document);
const items = itemSnapshots.map((item) =>
manager.getRepository(InterchangeDocumentItem).create({
interchangeDocumentId: saved.id,
bookingId: item.bookingId,
bookingReference: item.bookingReference,
itemType: item.itemType,
bookingContainerId: item.bookingContainerId,
bookingCargoId: item.bookingCargoId,
containerNumber: item.containerNumber,
sealNumber: item.sealNumber,
cargoId: item.cargoId,
cargoType: item.cargoType,
cargoDescription: item.cargoDescription,
weight: item.weight === null ? null : Number(item.weight) || null,
quantity: item.quantity === null ? null : Number(item.quantity) || null,
packageCount: item.packageCount === null ? null : Number(item.packageCount) || null,
wagonNumber: item.wagonNumber,
conditionStatus: this.conditionFromInspection(item),
damageDescription:
item.damageDescription ?? item.missingItemsDescription ?? null,
remarks: null,
}),
);
await manager.getRepository(InterchangeDocumentItem).save(items);
return manager.getRepository(InterchangeDocument).findOneOrFail({
where: { id: saved.id },
relations: { items: true },
order: { items: { createdAt: 'ASC' } },
});
});
}
async acknowledge(
id: string,
dto: AcknowledgeInterchangeDocumentDto,
): Promise<InterchangeDocument> {
const document = await this.findOne(id);
if (document.status === 'CANCELLED') {
throw new BadRequestException('Cancelled interchange document cannot be acknowledged');
}
await this.dataSource.getRepository(InterchangeDocument).update(id, {
status: 'ACKNOWLEDGED',
acknowledgedAt: new Date(),
acknowledgedBy: dto.acknowledgedBy,
remarks: dto.remarks ?? document.remarks ?? null,
});
return this.findOne(id);
}
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
await this.findOne(id);
await this.dataSource.getRepository(InterchangeDocument).update(id, {
status: 'DISPUTED',
remarks: dto.remarks,
});
return this.findOne(id);
}
async cancel(id: string): Promise<InterchangeDocument> {
const document = await this.findOne(id);
if (!['DRAFT', 'GENERATED'].includes(document.status)) {
throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`);
}
await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' });
return this.findOne(id);
}
private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
const [schedule] = await this.dataSource.query(
`SELECT ts.id,
ts.status,
ts.train_number AS "trainNo",
ts.route_id AS "routeId",
ts.origin_station_id AS "originFacilityId",
ts.destination_station_id AS "destinationFacilityId",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
return schedule;
}
private async getScheduleItems(scheduleId: string): Promise<InterchangeItemSnapshot[]> {
return this.dataSource.query(
`WITH assigned AS (
SELECT b.id AS booking_id,
b.reference,
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS booking_cargo_type,
b.cargo_free_text,
b.cargo_total_weight_vgm,
(
SELECT string_agg(DISTINCT w.wagon_number, ', ' ORDER BY w.wagon_number)
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
WHERE wba.booking_id = b.id
) AS wagon_number,
bool_or(COALESCE(wir.has_damage, false)) AS has_damage,
bool_or(COALESCE(wir.has_weight_loss, false)) AS has_weight_loss,
bool_or(COALESCE(wir.has_missing_items, false)) AS has_missing_items,
string_agg(DISTINCT NULLIF(wir.damage_description, ''), '; ') AS damage_description,
string_agg(DISTINCT NULLIF(wir.missing_items_description, ''), '; ') AS missing_items_description
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.warehouse_inspection_reports wir ON wir.inventory_id = inv.id AND wir.deleted_at IS NULL
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
GROUP BY b.id, b.reference, cgt.cargo_type_name, b.cargo_free_text, b.cargo_total_weight_vgm
)
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
NULL AS "bookingCargoId",
COALESCE(c.container_number, bc.container_number) AS "containerNumber",
c.seal_number AS "sealNumber",
NULL AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
COALESCE(bc.quantity, 1) AS "quantity",
COALESCE(bc.quantity, 1) AS "packageCount",
a.wagon_number AS "wagonNumber",
a.has_damage AS "hasDamage",
a.damage_description AS "damageDescription",
a.has_weight_loss AS "hasWeightLoss",
a.has_missing_items AS "hasMissingItems",
a.missing_items_description AS "missingItemsDescription"
FROM assigned a
JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL
LEFT JOIN freight.booking_container bc ON bc.id = c.booking_container_id AND bc.deleted_at IS NULL
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
bc.id AS "bookingContainerId",
NULL AS "bookingCargoId",
bc.container_number AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
bc.quantity AS "quantity",
bc.quantity AS "packageCount",
a.wagon_number AS "wagonNumber",
a.has_damage AS "hasDamage",
a.damage_description AS "damageDescription",
a.has_weight_loss AS "hasWeightLoss",
a.has_missing_items AS "hasMissingItems",
a.missing_items_description AS "missingItemsDescription"
FROM assigned a
JOIN freight.booking_container bc ON bc.booking_id = a.booking_id AND bc.deleted_at IS NULL
WHERE NOT EXISTS (
SELECT 1 FROM freight.containers c
WHERE c.booking_container_id = bc.id AND c.deleted_at IS NULL
)
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CARGO' AS "itemType",
NULL AS "bookingContainerId",
cg.id AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
cg.id AS "cargoId",
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight",
cg.quantity AS "quantity",
cg.quantity AS "packageCount",
a.wagon_number AS "wagonNumber",
a.has_damage AS "hasDamage",
a.damage_description AS "damageDescription",
a.has_weight_loss AS "hasWeightLoss",
a.has_missing_items AS "hasMissingItems",
a.missing_items_description AS "missingItemsDescription"
FROM assigned a
JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
NULL AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
a.cargo_total_weight_vgm AS "weight",
1 AS "quantity",
1 AS "packageCount",
a.wagon_number AS "wagonNumber",
a.has_damage AS "hasDamage",
a.damage_description AS "damageDescription",
a.has_weight_loss AS "hasWeightLoss",
a.has_missing_items AS "hasMissingItems",
a.missing_items_description AS "missingItemsDescription"
FROM assigned a
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = a.booking_id AND bc.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC, "containerNumber" ASC NULLS LAST`,
[scheduleId],
);
}
private conditionFromInspection(item: InterchangeItemSnapshot) {
if (item.hasDamage) return 'DAMAGED';
if (item.hasWeightLoss || item.hasMissingItems) return 'SHORTAGE';
return 'GOOD';
}
private async nextDocumentNo(direction: InterchangeDirection): Promise<string> {
const prefix = `ICD-${direction === 'EXPORT' ? 'EXP' : 'IMP'}-${this.yyyymmdd(new Date())}`;
const [row] = await this.dataSource.query(
`SELECT document_no AS "documentNo"
FROM freight.interchange_documents
WHERE document_no LIKE $1
ORDER BY document_no DESC
LIMIT 1`,
[`${prefix}-%`],
);
const last = row?.documentNo ? Number(String(row.documentNo).split('-').pop()) || 0 : 0;
return `${prefix}-${String(last + 1).padStart(4, '0')}`;
}
private yyyymmdd(date: Date): string {
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yyyy}${mm}${dd}`;
}
}

View File

@@ -59,7 +59,7 @@ export class LastMileController {
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBooking(reference);
return this.lastMileService.acceptBookingByReference(reference);
}
@Post()

View File

@@ -2,13 +2,22 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
imports: [
TypeOrmModule.forFeature([LastMile]),
BookingsModule,
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],

View File

@@ -1,7 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
@@ -26,27 +29,47 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
@Injectable()
export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile> {
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
throw new NotFoundException(`Booking ${bookingReference} not found`);
return null;
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
);
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: booking.totalAmount,
advancedPayment: 0,
});
}
async acceptBookingByReference(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
return null;
}
if (booking.paymentStatus !== 'PAID') {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
}
@@ -106,7 +129,7 @@ export class LastMileService {
async create(dto: CreateLastMileDto): Promise<LastMile> {
return this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'PAYMENT_PENDING',
status: dto.status ?? 'READY_TO_TRANSIT',
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
@@ -116,7 +139,7 @@ export class LastMileService {
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
await this.findById(id);
const existing = await this.findById(id);
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -132,9 +155,50 @@ export class LastMileService {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
return;
}
const driver = await this.driversService.findById(vehicle.assignedDriverId);
if (!driver.phoneNumber) {
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
return;
}
type BookingWithYards = {
reference?: string;
lastMileDeliveryAddress?: string | null;
destinationYard?: { label?: string } | null;
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.lastMileRepository.softDelete(id);

View File

@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class SendMessage {
@ApiProperty()
@IsNotEmpty()
@IsString()
to!: string;
@ApiProperty()
@IsNotEmpty()
@IsString()
message!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
from?: string;
}
export class SingleMessageDto {
@ApiProperty({
description: 'Recipient phone number',
example: '+1234567890',
})
@IsString()
@IsNotEmpty()
to!: string;
@ApiProperty({
description: 'Message content',
example: 'Test Single SMS from',
})
@IsString()
@IsNotEmpty()
message!: string;
}
export class BulkMessagesDto {
@ApiProperty({ type: [SendMessage] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => SendMessage)
messages!: SendMessage[];
}

View File

@@ -1,14 +1,29 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ClientsModule, Transport } from "@nestjs/microservices";
import { NotificationsService } from "./notifications.service";
import { SmsClientService } from "./sms-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
import { HttpModule } from "@nestjs/axios";
@Module({
imports: [HttpModule],
imports: [
ConfigModule,
ClientsModule.register([
{
name: "SMS_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.SMS_QUEUE ?? "sms_queue",
queueOptions: { durable: true },
},
},
]),
],
controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
exports: [NotificationsService],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
exports: [NotificationsService, SmsClientService],
})
export class NotificationsModule { }
export class NotificationsModule {}

View File

@@ -27,9 +27,29 @@ export class NotificationsService {
if (!strategy) {
throw new NotFoundException();
}
const sent = await strategy.send(recipient, message)
this.logger.log(`is sent - ${sent}`)
const sent = await strategy.send(recipient, message);
this.logger.log(`is sent - ${sent}`);
}
async notifyDriverVehicleAssignment(params: {
driverPhone: string;
driverName: string;
vehiclePlateNumber: string;
bookingReference: string;
pickupAddress?: string | null;
destinationYard?: string | null;
}): Promise<void> {
const { driverPhone, driverName, vehiclePlateNumber, bookingReference, pickupAddress, destinationYard } = params;
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${bookingReference}. Vehicle: ${vehiclePlateNumber}. ` +
(pickupAddress ? `Pickup: ${pickupAddress}. ` : '') +
(destinationYard ? `Destination: ${destinationYard}.` : '');
try {
await this.directSend('sms', driverPhone, message);
} catch (err) {
this.logger.error(`Failed to notify driver ${driverName} (${driverPhone}): ${String(err)}`);
}
}
}

View File

@@ -0,0 +1,68 @@
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { BulkMessagesDto, SingleMessageDto } from "./dtos/sms.dto";
@Injectable()
export class SmsClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsClientService.name);
constructor(
@Inject("SMS_SERVICE")
private smsClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.smsClient
.connect()
.then(() => {
this.logger.log("connected to SMS service");
})
.catch((err) => {
console.error("Error happened at SMS service", err);
});
}
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
return { queued: false };
}
this.smsClient.emit("send-sms", {
to: dto.to,
text: dto.message,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
this.logger.log(
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
return { queued: true };
}
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
return { queued: false };
}
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
this.smsClient.emit("ozeking-bulk-sms", {
messages,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
this.logger.log(
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
);
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
return { queued: true };
}
}

View File

@@ -1,25 +1,57 @@
import { Injectable} from "@nestjs/common";
import { NotificationStrategy } from "./notification.strategy";
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from 'rxjs';
import axios, { isAxiosError } from "axios";
import { NotificationStrategy } from "./notification.strategy";
@Injectable()
export class SmsNotificationStrategy implements NotificationStrategy {
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { }
async send(recipient: string, message: string) {
const url = this.configService.get("OZIKING_SMS_URL")
const body = {
to: recipient,
text: message
}
const response = await firstValueFrom(
this.httpService.post(
url,
body,
),
);
private readonly logger = new Logger(SmsNotificationStrategy.name);
return response.status === 201;
constructor(private readonly configService: ConfigService) {}
async send(recipient: string, message: string): Promise<boolean> {
const url =
this.configService.get<string>("OZIKING_SMS_URL") ??
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
const appKey = this.configService.get<string>("OZIKING_APP_KEY") ?? "";
if (!appKey) {
this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API");
}
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
try {
const response = await axios.post(
url,
{
to: recipient,
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
appKey,
text: message,
callbackUrl: "",
},
{
headers: {
accept: "*/*",
"Content-Type": "application/json",
},
},
);
this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`);
return true;
} catch (err) {
if (isAxiosError(err)) {
this.logger.error(
`SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`,
);
} else {
this.logger.error(`SMS send failed: ${String(err)}`);
}
throw err;
}
}
}

View File

@@ -24,13 +24,9 @@ export class OtpController {
@Post("send")
async sendOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
phone: string
) {
return this.otpService.sendOtp(
phone,otp
);
return this.otpService.sendOtp(phone);
}
// ---------------------------------------------------------------------------

View File

@@ -12,11 +12,14 @@ import { OtpService } from "./otp.service";
import { OtpRepository } from "./otp.repository";
import { NotificationsModule } from "../notifications/notifications.module";
@Module({
imports: [
TypeOrmModule.forFeature([
OtpVerification,
]),
NotificationsModule,
],
controllers: [OtpController],

View File

@@ -5,14 +5,15 @@ import {
Injectable,
} from "@nestjs/common";
import axios from "axios";
import { OtpRepository } from "./otp.repository";
import { SmsClientService } from "../notifications/sms-client.service";
@Injectable()
export class OtpService {
constructor(
private readonly otpRepository: OtpRepository
private readonly otpRepository: OtpRepository,
private readonly smsClient: SmsClientService
) {}
// ---------------------------------------------------------------------------
@@ -29,11 +30,12 @@ export class OtpService {
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string, otp: string) {
async sendOtp(phone: string) {
try {
// generate otp
// const otp =
// this.generateOtp();
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
// recipient of the SMS.
const otp = this.generateOtp();
// find existing phone
const existingPhone =
@@ -55,33 +57,11 @@ export class OtpService {
);
}
// send sms
await axios.post(
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms",
{
to: phone,
sourceId: "EDR",
sourceName:
"EDR Freight",
appKey:
"YOUR_APP_KEY",
text: `Your verification code is ${otp}`,
callbackUrl: "",
},
{
headers: {
accept: "*/*",
"Content-Type":
"application/json",
},
}
);
// send sms (queued to RabbitMQ via the shared SMS service)
await this.smsClient.sendSms({
to: phone,
message: `Your verification code is ${otp}`,
});
return {
success: true,

View File

@@ -1,8 +1,8 @@
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
@@ -10,31 +10,31 @@ import {
PaymentService as PaymentServiceEnum,
paymentServiceBindingPattern,
} from "@edr/types";
import { PaymentService } from "./payment.service";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentEntity } from "./entities/payment.entity";
import { InternalPaymentController } from "./internal-payment.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentController } from "./payment.controller";
import { PaymentRepository } from "./payment.repository";
import { PaymentEventsConsumer } from "./payment-events.consumer";
import { InternalPaymentController } from "./internal-payment.controller";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentService } from "./payment.service";
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
function rabbitMQImport(): DynamicModule[] {
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
return [
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>("rabbitmq.url") as string,
uri: config.get<string>("rabbitmq.url") ?? process.env.PAYMENT_RABBITMQ_URL ?? "",
exchanges: [
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
@@ -51,6 +51,22 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
connectionInitOptions: { wait: false },
}),
}),
];
}
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([
PaymentEntity,
PaymentWebhookEventEntity,
PaymentRefundEntity,
]),
...rabbitMQImport(),
],
providers: [
PaymentRepository,
@@ -62,4 +78,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
controllers: [PaymentController, InternalPaymentController],
exports: [PaymentService],
})
export class PaymentModule { }
export class PaymentModule {}

View File

@@ -35,6 +35,7 @@ import {
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
import { FirstMileService } from "../first-mile/first-mile.service";
/** Setting code holding the global ordering window (months) for general contracts. */
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
@@ -60,6 +61,7 @@ export class PaymentService {
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService,
private readonly firstMileService: FirstMileService,
) { }
/** Configured general-contract ordering window in months (defaults to 3). */
@@ -342,6 +344,8 @@ export class PaymentService {
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
if (isGeneralContract) {

View File

@@ -57,6 +57,12 @@ export interface BookingEvaluationInput {
allowConsolidation?: boolean;
shippingLineId?: string | null;
totalWagons: number;
/**
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
* PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
* container freight, which is scaled by container count instead.
*/
bulkTons?: number;
containers: BookingContainerEvalInput[];
}
@@ -224,16 +230,46 @@ export class RuleEngineService {
});
if (!triggered) continue;
let triggerValue: number | null = null;
let calculatedAmount = Number(rate.rateValue);
// Surcharges scale by their own rateUnit, so the same trigger can bill the
// right way per freight shape — e.g. a PER_TON reefer rate multiplies the
// bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
// count. triggerValue records the quantity billed (shown on the breakdown).
const rateValue = Number(rate.rateValue);
const containerCount = input.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
0,
);
const overweightExcessTons = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
// Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons.
if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') {
triggerValue = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
calculatedAmount = triggerValue * Number(rate.rateValue);
let triggerValue: number | null = null;
let calculatedAmount: number;
switch (rate.rateUnit) {
case 'PER_TON':
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
// (e.g. bulk reefer) bills the full bulk tonnage.
triggerValue =
rate.trigger === 'OVERWEIGHT'
? overweightExcessTons
: Number(input.bulkTons ?? 0);
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_CONTAINER':
triggerValue = containerCount;
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_WAGON':
triggerValue = input.totalWagons;
calculatedAmount = triggerValue * rateValue;
break;
case 'FLAT':
default:
// FLAT (and any unknown unit) bills once.
calculatedAmount = rateValue;
break;
}
// Safety guard: never include a surcharge with a non-positive amount (a

View File

@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
route: true,
trainSet: {
locomotive: true,
locomotives: { locomotive: true },
wagons: {
wagonType: true,
physicalWagon: true,

View File

@@ -1,6 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
} from 'class-validator';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto {
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back)',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()

View File

@@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive(
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
/**
* 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.
*/
export function minLocomotiveLimits(
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
),
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
),
};
}
/** Per-booking train length from wagon count and freight-specific wagon type length. */
export function bookingTrainLengthMeters(
freightType: string | null | undefined,

View File

@@ -0,0 +1,52 @@
import {
BULK_IMPORT_NUMBERS,
CONTAINER_EXPORT_NUMBERS,
CONTAINER_IMPORT_NUMBERS,
pickLowestFreeNumber,
pickTrainNumberPool,
} from './train-number.util';
describe('train-number.util', () => {
describe('pickTrainNumberPool', () => {
it('picks container export (odd) when container wagons dominate and direction is EXPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'EXPORT');
expect(pool.cargo).toBe('CONTAINER');
expect(pool.direction).toBe('EXPORT');
expect(pool.numbers).toEqual(CONTAINER_EXPORT_NUMBERS);
});
it('picks container import (even) when container wagons dominate and direction is IMPORT', () => {
const pool = pickTrainNumberPool(5, 2, 'IMPORT');
expect(pool.numbers).toEqual(CONTAINER_IMPORT_NUMBERS);
});
it('picks bulk when bulk wagons dominate', () => {
const pool = pickTrainNumberPool(1, 9, 'IMPORT');
expect(pool.cargo).toBe('BULK');
expect(pool.numbers).toEqual(BULK_IMPORT_NUMBERS);
});
it('treats a tie as container', () => {
expect(pickTrainNumberPool(3, 3, 'EXPORT').cargo).toBe('CONTAINER');
});
it('defaults DOMESTIC to the export/odd pool', () => {
expect(pickTrainNumberPool(5, 0, 'DOMESTIC').direction).toBe('EXPORT');
expect(pickTrainNumberPool(5, 0, null).direction).toBe('EXPORT');
});
});
describe('pickLowestFreeNumber', () => {
it('returns the lowest unused number', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, ['8001'])).toBe('8101');
});
it('returns the first number when none are used', () => {
expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, [])).toBe('8001');
});
it('returns null when the pool is exhausted', () => {
expect(pickLowestFreeNumber(BULK_IMPORT_NUMBERS, [...BULK_IMPORT_NUMBERS])).toBeNull();
});
});
});

View File

@@ -0,0 +1,68 @@
/**
* Fixed train-number pools assigned to a train on dispatch.
*
* The prefix encodes cargo type (8 = container, 1 = bulk) and the parity encodes
* trade direction (odd = export, even = import). Numbers are finite and recycle:
* a number is "in use" only while its train is DISPATCHED and not yet ARRIVED.
*/
export const CONTAINER_EXPORT_NUMBERS = [
'8001', '8101', '8201', '8301', '8401', '8501', '8601', '8701', '8801', '8901',
] as const;
export const CONTAINER_IMPORT_NUMBERS = [
'8002', '8102', '8202', '8302', '8402', '8502', '8602', '8702', '8802', '8902',
] as const;
export const BULK_EXPORT_NUMBERS = ['1101', '1103', '1105', '1107'] as const;
export const BULK_IMPORT_NUMBERS = ['1002', '1004', '1006', '1008'] as const;
export type CargoKind = 'CONTAINER' | 'BULK';
export type PoolDirection = 'IMPORT' | 'EXPORT';
export interface TrainNumberPool {
cargo: CargoKind;
/** EXPORT = odd numbers, IMPORT = even numbers. */
direction: PoolDirection;
numbers: readonly string[];
}
/**
* Resolve which fixed pool a train draws from.
*
* - Cargo: container vs bulk by dominant wagon count; ties resolve to container.
* - Direction: EXPORT → odd pool, IMPORT → even pool. DOMESTIC (neither end is
* Djibouti) has no dedicated pool, so it defaults to the export/odd pool.
*/
export function pickTrainNumberPool(
containerWagons: number,
bulkWagons: number,
direction: 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null | undefined,
): TrainNumberPool {
const cargo: CargoKind = bulkWagons > containerWagons ? 'BULK' : 'CONTAINER';
const poolDirection: PoolDirection = direction === 'IMPORT' ? 'IMPORT' : 'EXPORT';
const numbers =
cargo === 'CONTAINER'
? poolDirection === 'IMPORT'
? CONTAINER_IMPORT_NUMBERS
: CONTAINER_EXPORT_NUMBERS
: poolDirection === 'IMPORT'
? BULK_IMPORT_NUMBERS
: BULK_EXPORT_NUMBERS;
return { cargo, direction: poolDirection, numbers };
}
/** Lowest pool number not currently in use, or null when the pool is exhausted. */
export function pickLowestFreeNumber(
pool: readonly string[],
usedNumbers: Iterable<string>,
): string | null {
const used = new Set(usedNumbers);
for (const number of pool) {
if (!used.has(number)) return number;
}
return null;
}

View File

@@ -436,7 +436,7 @@ export class TrainSchedulingController {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
@Post("bulk/schedules/:id/cancel")
@Post('bulk/schedules/:id/cancel')
@TrainSchedulingManage()
@ApiOperation({ summary: "Cancel bulk train schedule" })
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -7,6 +7,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
@@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
WagonType,
TrainSet,
TrainSetWagon,
TrainSetLocomotive,
Route,
Wagon,
Container,

View File

@@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => {
isActive: true,
};
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
const lockedLocomotiveRepo = {
findOne: jest.fn().mockResolvedValue(locomotive),
findOne: jest
.fn()
.mockResolvedValueOnce(locomotive)
.mockResolvedValueOnce(locomotive2),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
@@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const trainSetLocomotiveRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
@@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => {
return trainScheduleRepo;
case 'TrainSet':
return trainSetRepo;
case 'TrainSetLocomotive':
return trainSetLocomotiveRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
@@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => {
const result = await service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
{ status: 'ASSIGNED' },
);
expect(result.id).toBe('schedule-1');
});
@@ -508,7 +521,6 @@ describe('TrainSchedulingService', () => {
})),
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
@@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => {
service.createContainerTrainSchedule({
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
locomotiveId: 'loc-1',
locomotiveIds: ['loc-1', 'loc-2'],
}),
).rejects.toBeInstanceOf(ConflictException);
});

View File

@@ -1,4 +1,4 @@
import {
import {
AllocationLoadType,
SchedulingStatus,
TrainCheckpointKind,
@@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
@@ -79,8 +80,10 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
deriveTrainCapacityFromLocomotive,
minLocomotiveLimits,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import {
@@ -288,31 +291,42 @@ export class TrainSchedulingService {
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
where: { id: locomotive.id },
lock: { mode: 'pessimistic_write' },
});
if (!lockedLocomotive) {
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
}
if (lockedLocomotive.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
const lockedLocomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
where: { id: locomotiveId },
lock: { mode: 'pessimistic_write' },
});
if (!locked) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locked.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${locked.code} is not available`);
}
if (locked.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
lockedLocomotives.push(locked);
}
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
if (lockedLocomotive.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
@@ -322,11 +336,14 @@ export class TrainSchedulingService {
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
await this.resolveTrainLimitConfig(dto, limitLoco)
).maxWagonsPerTrain,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
await manager.getRepository(Locomotive).update(
{ id: In(lockedLocomotives.map((l) => l.id)) },
{ status: 'ASSIGNED' },
);
return saved.id;
});
@@ -375,8 +392,9 @@ export class TrainSchedulingService {
maxWagonsPerTrain: dto.maxWagonsPerTrain,
};
const locomotive = schedule.trainSet.locomotive;
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -408,17 +426,17 @@ export class TrainSchedulingService {
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
if (!locomotive) {
throw new BadRequestException('Schedule train set has no locomotive');
if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives');
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
if (limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
`Train set locomotives cannot pull ${totalWeightTons}T`,
);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
`Train set locomotives cannot support ${totalLengthMeters}m`,
);
}
@@ -681,10 +699,12 @@ export class TrainSchedulingService {
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{ actualDepartureAt: now },
{ actualDepartureAt: now, trainNumber },
manager,
);
if (schedule.trainSetId) {
@@ -718,6 +738,60 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId);
}
/**
* Assign a fixed train number on dispatch. The number is drawn from the pool
* for the train's dominant cargo type (container vs bulk) and trade direction
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
* concurrent dispatches can't grab the same number. Throws when the pool is
* exhausted. Idempotent: returns the existing number if already assigned.
*/
private async assignTrainNumber(
manager: EntityManager,
schedule: TrainSchedule,
): Promise<string> {
if (schedule.trainNumber) return schedule.trainNumber;
// Count container vs bulk wagons from the planned allocations.
let containerWagons = 0;
let bulkWagons = 0;
for (const wagon of schedule.trainSet?.wagons ?? []) {
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
if (isBulk) bulkWagons += 1;
else containerWagons += 1;
}
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
// Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number.
const activeNumbered = await manager
.getRepository(TrainSchedule)
.createQueryBuilder('schedule')
.setLock('pessimistic_write')
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
.andWhere('schedule.train_number IS NOT NULL')
.getMany();
const usedNumbers = activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n));
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
throw new ConflictException(
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
);
}
return number;
}
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
await this.dataSource
@@ -931,6 +1005,19 @@ export class TrainSchedulingService {
});
}
await manager.query(
`UPDATE freight.bookings b
SET status = $2,
scheduling_status = $3
FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = b.id
AND tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL
AND b.deleted_at IS NULL
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
);
if (schedule.trainSet?.locomotiveId) {
const loco = await manager
.getRepository(Locomotive)
@@ -982,7 +1069,7 @@ export class TrainSchedulingService {
async getContainerTrainSchedules() {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: true,
originStation: true,
destinationStation: true,
@@ -1013,10 +1100,11 @@ export class TrainSchedulingService {
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
}
if (schedule.trainSet?.locomotiveId) {
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
status: 'AVAILABLE',
});
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (cancelledLocoIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
@@ -1251,24 +1339,29 @@ export class TrainSchedulingService {
}
}
let assignedLocomotive: Locomotive | null = null;
let assignedLocomotives: Locomotive[] = [];
if (targetScheduleId) {
const targetSchedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
}
if (assignedLocomotive) {
if (assignedLocomotive.currentYardId !== originYardId) {
if (assignedLocomotives.length) {
// Every locomotive of the set must sit at the origin yard, and the weakest
// one must still be able to pull the train (min limits across the set).
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
if (offYard) {
violations.push(
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
`Locomotive ${offYard.code} is not at the schedule origin yard`,
);
} else if (
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
setLimits &&
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
) {
violations.push(
'Assigned locomotive cannot support the total train weight and length',
'Assigned locomotives cannot support the total train weight and length',
);
}
} else {
@@ -1818,6 +1911,22 @@ export class TrainSchedulingService {
}
}
/**
* All locomotives attached to a loaded train set. Prefers the `locomotives`
* link rows; falls back to the legacy single `locomotive` for train sets
* created before multi-loco support.
*/
private locomotivesOfTrainSet(
trainSet: TrainSet | null | undefined,
): Locomotive[] {
if (!trainSet) return [];
const linked = (trainSet.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
if (linked.length) return linked;
return trainSet.locomotive ? [trainSet.locomotive] : [];
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
@@ -1841,15 +1950,28 @@ export class TrainSchedulingService {
return locomotive;
}
private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) {
private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) {
const [primary] = locomotives;
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
locomotiveId: primary.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
const saved = await manager.getRepository(TrainSet).save(trainSet);
const links = locomotives.map((loco, index) =>
manager.getRepository(TrainSetLocomotive).create({
trainSetId: saved.id,
locomotiveId: loco.id,
sequenceNo: index,
}),
);
await manager.getRepository(TrainSetLocomotive).save(links);
return saved;
}
private async getActiveRoute(routeId: string) {
@@ -1915,6 +2037,12 @@ export class TrainSchedulingService {
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
currentYardId: loco.currentYardId ?? null,
})),
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
@@ -1952,7 +2080,7 @@ export class TrainSchedulingService {
bookingWindowStatus: 'OPEN',
},
relations: {
trainSet: { locomotive: true },
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: { milestones: true },
originStation: true,
destinationStation: true,
@@ -2099,6 +2227,15 @@ export class TrainSchedulingService {
),
}
: null,
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
id: loco.id,
code: loco.code,
name: loco.name ?? null,
status: loco.status,
currentYardId: loco.currentYardId ?? null,
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({

View File

@@ -0,0 +1,31 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
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
* order index — no front/rear semantics are modelled yet.
*/
@Entity({ schema: 'freight', name: 'train_set_locomotives' })
@Index(['trainSetId', 'locomotiveId'], { unique: true })
export class TrainSetLocomotive extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid' })
trainSetId!: string;
@ManyToOne(() => TrainSet, (trainSet) => trainSet.locomotives, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'sequence_no', type: 'int', default: 0 })
sequenceNo!: number;
}

View File

@@ -3,6 +3,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetLocomotive } from './train-set-locomotive.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
export const TRAIN_SET_STATUSES = [
@@ -19,6 +20,7 @@ export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
@Index(['locomotiveId'])
@Index(['status'])
export class TrainSet extends BaseEntity {
/** Primary locomotive (first of the set). Kept for back-compat with single-loco read paths. */
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@@ -26,6 +28,10 @@ export class TrainSet extends BaseEntity {
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
/** All locomotives pulling this train set (minimum 2). */
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
locomotives?: TrainSetLocomotive[];
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;

View File

@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSet } from './entities/train-set.entity';
import { TrainSetLocomotive } from './entities/train-set-locomotive.entity';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
import { TrainSetsRepository } from './train-sets.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon, TrainSetLocomotive])],
providers: [TrainSetsRepository, TrainSetWagonsRepository],
exports: [TrainSetsRepository, TrainSetWagonsRepository],
})

View File

@@ -37,4 +37,16 @@ export class CreateVehicleDto {
@IsOptional()
@IsString()
assignedDriverName?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
powerPlateNo?: string;
@IsOptional()
@IsString()
trailerPlateNo?: string;
}

View File

@@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
};
}
async createVehicle(vehicleData: any): Promise<Vehicle> {
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
const vehicle = this.repository.create(vehicleData);
const vehicles = await this.repository.save(vehicle);
return vehicles?.[0] as Vehicle;
return this.repository.save(vehicle);
}
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
return (await this.repository.save(vehicle)) as Vehicle;
return this.repository.save(vehicle);
}
}

View File

@@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
export const WAGON_STATUSES = [
WagonStatus.Available,
WagonStatus.Assigned,
WagonStatus.ImportReady,
WagonStatus.ExportReady,
WagonStatus.Maintenance,
WagonStatus.Retired,
] as const;
@@ -65,7 +67,7 @@ export class Wagon extends BaseEntity {
@JoinColumn({ name: 'current_train_schedule_id' })
currentTrainSchedule?: TrainSchedule | null;
/** Fleet master consist grouping separate from operational train_schedules. */
/** Fleet master consist grouping — separate from operational train_schedules. */
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'train_id' })
train!: Train | null;

View File

@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateFrom?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateTo?: string;
}

View File

@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
bookingReference?: string;
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
@IsOptional()
@IsString()
bookingNumber?: string;
@ApiPropertyOptional()

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class GenerateInvoiceDto {
@ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' })
@@ -11,6 +11,11 @@ export class GenerateInvoiceDto {
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' })
@IsOptional()
@IsIn(['ETB', 'USD'])
billingCurrency?: 'ETB' | 'USD';
}
export class PayInvoiceBodyDto {

View File

@@ -15,6 +15,7 @@ import { WarehouseZone } from './warehouse-zone.entity';
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
export const WAREHOUSE_INVENTORY_STATUSES = [
'UNLOADED',
'UNLOADED_AT_DJIBOUTI_PORT',
'RECEIVED',
'STORED',
'RESERVED',
@@ -31,12 +32,13 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
// UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected.
// Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection.
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
UNLOADED_AT_DJIBOUTI_PORT: [],
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
STORED: ['RESERVED'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],
DISPATCHED: [],
DISPATCHED: ['UNLOADED_AT_DJIBOUTI_PORT'],
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
// out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects
// it / customs or inspection hold / operator chooses to store.

View File

@@ -37,6 +37,36 @@ export interface ImportTrainItemRow {
lastMileRequested: boolean;
pickupOption: string;
}
export interface ExportDjiboutiQueueFilter {
scheduleId?: string;
destination?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
}
export interface ExportTrainRow extends ImportTrainRow {
departureTime: string | null;
}
export interface ExportTrainItemRow {
bookingId: string;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
itemType: 'CONTAINER' | 'CARGO';
itemId: string | null;
inventoryId: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
origin: string | null;
destination: string | null;
trainSchedule: string | null;
arrivalTime: string | null;
currentStatus: string | null;
}
export interface WagonView {
id: string;
wagonNumber: string;
@@ -67,6 +97,13 @@ export interface BookingScheduleView {
export class SchedulingReadFacade {
constructor(private readonly dataSource: DataSource) {}
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
const normalized = (value ?? '').toUpperCase();
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
normalized.includes(token),
);
}
/** Look up a single physical wagon. Returns null if it does not exist. */
async findWagon(wagonId: string): Promise<WagonView | null> {
const rows = await this.dataSource.query(
@@ -220,4 +257,187 @@ export class SchedulingReadFacade {
);
return rows;
}
/**
* ARRIVED export train schedules at Djibouti-side destinations, with assigned item counts.
* Read-only: this only selects from scheduling/booking/inventory tables.
*/
async exportDjiboutiArrivalQueue(
filter: ExportDjiboutiQueueFilter = {},
): Promise<ExportTrainRow[]> {
const params: unknown[] = [];
const where = [
'ts.deleted_at IS NULL',
"ts.status = ANY($1)",
`EXISTS (
SELECT 1
FROM freight.train_schedule_bookings tsb_exists
JOIN freight.bookings b_exists ON b_exists.id = tsb_exists.booking_id AND b_exists.deleted_at IS NULL
LEFT JOIN freight.warehouse_inventory inv_exists ON inv_exists.booking_id = b_exists.id AND inv_exists.deleted_at IS NULL
WHERE tsb_exists.train_schedule_id = ts.id
AND tsb_exists.deleted_at IS NULL
AND (inv_exists.status = ANY($2) OR b_exists.status = ANY($2))
)`,
];
params.push(
filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'],
['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'],
);
if (filter.scheduleId) {
params.push(filter.scheduleId);
where.push(`ts.id = $${params.length}`);
}
if (filter.destination) {
params.push(`%${filter.destination}%`);
where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`);
}
if (filter.dateFrom) {
params.push(filter.dateFrom);
where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) >= $${params.length}`);
}
if (filter.dateTo) {
params.push(filter.dateTo);
where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) <= $${params.length}`);
}
const rows: Array<
ExportTrainRow & {
originCountry: string | null;
destinationCountry: string | null;
destinationName: string | null;
}
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
dy.name AS "destinationName",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
ts.scheduled_departure_date AS "departureTime",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
ts.status,
(SELECT count(*) FROM freight.train_schedule_bookings tsb
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings",
(SELECT count(*) FROM freight.containers c
JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
(SELECT count(*) FROM freight.cargoes cg
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ${where.join(' AND ')}
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`,
params,
);
return rows
.filter((r) => {
const direction = deriveTradeDirection(
{ country: r.originCountry },
{ country: r.destinationCountry },
);
return (
direction === 'EXPORT' &&
this.isDjiboutiPortDestination(`${r.destination ?? ''} ${r.destinationName ?? ''}`)
);
})
.map(({ originCountry: _oc, destinationCountry: _dc, destinationName: _dn, ...rest }) => ({
...rest,
totalBookings: Number(rest.totalBookings) || 0,
totalContainers: Number(rest.totalContainers) || 0,
totalCargoes: Number(rest.totalCargoes) || 0,
route: rest.origin || rest.destination ? `${rest.origin ?? '?'} -> ${rest.destination ?? '?'}` : null,
}));
}
/** Assigned export booking items for an arrived Djibouti-side export train. Read-only. */
async exportDjiboutiTrainDetail(scheduleId: string): Promise<ExportTrainItemRow[]> {
const rows: ExportTrainItemRow[] = await this.dataSource.query(
`WITH assigned AS (
SELECT b.id AS booking_id,
b.reference,
b.company_id,
company.name AS customer_name,
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
b.cargo_total_weight_vgm AS booking_weight,
oy.code AS origin,
dy.code AS destination,
ts.train_number,
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS arrival_time,
inv.id AS inventory_id,
COALESCE(inv.status, b.status) AS current_status
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
)
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
'CONTAINER' AS "itemType",
c.id AS "itemId",
a.inventory_id AS "inventoryId",
c.container_number AS "containerNumber",
a.cargo_type AS "cargoType",
a.booking_weight AS "weight",
a.origin,
a.destination,
a.train_number AS "trainSchedule",
a.arrival_time AS "arrivalTime",
a.current_status AS "currentStatus"
FROM assigned a
JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
'CARGO' AS "itemType",
cg.id AS "itemId",
a.inventory_id AS "inventoryId",
NULL AS "containerNumber",
COALESCE(cgt.cargo_type_name, a.cargo_type) AS "cargoType",
COALESCE(cg.weight, a.booking_weight) AS "weight",
a.origin,
a.destination,
a.train_number AS "trainSchedule",
a.arrival_time AS "arrivalTime",
a.current_status AS "currentStatus"
FROM assigned a
JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
a.company_id AS "customerId",
a.customer_name AS "customerName",
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
a.inventory_id AS "itemId",
a.inventory_id AS "inventoryId",
NULL AS "containerNumber",
a.cargo_type AS "cargoType",
a.booking_weight AS "weight",
a.origin,
a.destination,
a.train_number AS "trainSchedule",
a.arrival_time AS "arrivalTime",
a.current_status AS "currentStatus"
FROM assigned a
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
[scheduleId],
);
return rows;
}
}

View File

@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
const rule = await this.findMatchingRule(criteria);
const yardCode = rule?.targetYardCode;
if (!rule) return null;
// Resolve yard (by rule code, else first available yard with a zone).
// Resolve yard by rule code.
const [yard] = await this.dataSource.query(
yardCode
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
yardCode ? [yardCode] : [],
`SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`,
[rule.targetYardCode],
);
if (!yard) return null;

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm';
import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from 'typeorm';
import { Warehouse } from './entities/warehouse.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
@@ -26,6 +26,29 @@ export interface WarehouseDashboard {
export class WarehouseDashboardService {
constructor(private readonly dataSource: DataSource) {}
private async safeCount<T extends ObjectLiteral>(
repo: Repository<T>,
options?: FindManyOptions<T>,
): Promise<number> {
try {
return await repo.count(options);
} catch {
return 0;
}
}
private async safeReceivedToday(startOfToday: Date): Promise<number> {
try {
return await this.dataSource
.getRepository(WarehouseInventory)
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount();
} catch {
return 0;
}
}
async getDashboard(): Promise<WarehouseDashboard> {
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
@@ -47,21 +70,18 @@ export class WarehouseDashboardService {
delivered,
receivedToday,
] = await Promise.all([
warehouseRepo.count(),
inventoryRepo.count(),
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
inventoryRepo.count({ where: { status: 'STORED' } }),
inventoryRepo.count({ where: { status: 'RESERVED' } }),
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
inventoryRepo.count({ where: { status: 'LOADED' } }),
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }),
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
inventoryRepo
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount(),
this.safeCount(warehouseRepo),
this.safeCount(inventoryRepo),
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
this.safeReceivedToday(startOfToday),
]);
return {

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
@@ -13,6 +14,8 @@ interface ItemAttributes {
tradeDirection: string | null;
cargoTypeCode: string | null;
containerTypeCode: string | null;
inventoryQuantity: number;
bookingContainerCount: number;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -26,11 +29,15 @@ export interface FeePreview {
freeDays: number;
ratePerDay: number;
currency: string;
ruleCurrency: string | null;
billingCurrency: string;
startDate: string | null;
endDate: string;
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
elapsedDays: number;
chargeableDays: number;
containerCount: number;
billableUnits: number;
amount: number;
}
@@ -41,6 +48,7 @@ export class WarehouseFeeService {
constructor(
private readonly dataSource: DataSource,
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
private readonly exchangeService: ExchangeService,
) {}
// ── Rule CRUD ──────────────────────────────────────────────────────────────
@@ -67,6 +75,7 @@ export class WarehouseFeeService {
`SELECT inv.arrived_at AS "arrivedAt",
inv.gate_cleared_at AS "gateClearedAt",
inv.release_date AS "releaseDate",
inv.quantity AS "inventoryQuantity",
inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId",
inv.zone_id AS "zoneId",
@@ -74,7 +83,8 @@ export class WarehouseFeeService {
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode",
ctt.code AS "containerTypeCode"
ctt.code AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -82,6 +92,12 @@ export class WarehouseFeeService {
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
FROM freight.booking_container bc
WHERE bc.booking_id = inv.booking_id
AND bc.deleted_at IS NULL
) container_lines ON true
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
[inventoryId],
);
@@ -125,44 +141,86 @@ export class WarehouseFeeService {
return best;
}
private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview {
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
return currency === 'ETB' ? 'ETB' : 'USD';
}
private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
const from = this.normalizeCurrency(fromCurrency);
const to = this.normalizeCurrency(toCurrency);
if (from === to) return Math.round(amount * 100) / 100;
const rate = await this.exchangeService.getRate(from, to);
return Math.round(amount * rate * 100) / 100;
}
private async compute(
ruleType: FeeRuleType,
rule: WarehouseFeeRule | null,
item: ItemAttributes,
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
const freeDays = rule?.freeDays ?? 0;
const ratePerDay = Number(rule?.ratePerDay ?? 0);
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
const targetCurrency = this.normalizeCurrency(billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
: 0;
const chargeableDays = Math.max(0, elapsedDays - freeDays);
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
const billableUnits = chargeableDays * containerCount;
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const convertedRatePerDay = ruleCurrency
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency)
: 0;
return {
ruleType,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays,
ratePerDay,
currency: rule?.currency ?? 'USD',
ratePerDay: convertedRatePerDay,
currency: targetCurrency,
ruleCurrency,
billingCurrency: targetCurrency,
startDate: start ? start.toISOString() : null,
endDate: new Date(endDate).toISOString(),
endIsOpen,
elapsedDays,
chargeableDays,
containerCount,
billableUnits,
amount,
};
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string): Promise<FeePreview[]> {
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date();
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
return byType.map((type) =>
this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now),
return Promise.all(
byType.map((type) =>
this.compute(
type,
this.bestRule(rules.filter((r) => r.ruleType === type), item),
item,
now,
billingCurrency,
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service';
import { LastMileService } from '../last-mile/last-mile.service';
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
@@ -16,9 +17,10 @@ export class WarehouseInspectionService {
private readonly dataSource: DataSource,
private readonly inspectionRepository: WarehouseInspectionRepository,
private readonly filesService: FilesService,
private readonly lastMileService: LastMileService,
) {}
/** Create an inspection report for an inventory item and sync its inspectionStatus. */
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
@@ -29,8 +31,9 @@ export class WarehouseInspectionService {
const expected = dto.expectedWeight ?? null;
const actual = dto.actualWeight ?? null;
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
const inspectedAt = new Date();
const report = await this.inspectionRepository.create({
const payload = {
inventoryId,
bookingId: inventory.bookingId ?? null,
reportType: dto.reportType,
@@ -46,18 +49,60 @@ export class WarehouseInspectionService {
missingItemsDescription: dto.missingItemsDescription ?? null,
remarks: dto.remarks ?? null,
inspectedById: dto.inspectedById ?? null,
inspectedAt: new Date(),
inspectedAt,
};
const [existingReport] = await this.inspectionRepository.findAll({
where: { inventoryId },
order: { createdAt: 'DESC' },
take: 1,
});
let report: WarehouseInspectionReport;
if (existingReport) {
await this.inspectionRepository.update(existingReport.id, payload);
report = await this.findById(existingReport.id);
} else {
report = await this.inspectionRepository.create(payload);
}
// Mirror the latest outcome onto the inventory item so loading rules can read it.
await inventoryRepo.update(inventoryId, {
inspectionStatus: dto.inspectionStatus,
inspectedAt: new Date(),
inspectedAt,
});
if (dto.inspectionStatus === 'PASSED') {
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
}
return report;
}
private async markImportPickupReadyAndAcceptLastMile(inventoryId: string): Promise<void> {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
b.trade_direction AS "tradeDirection",
b.last_mile_delivery_address AS "lastMileDeliveryAddress"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
);
if ((row?.tradeDirection ?? '').toUpperCase() !== 'IMPORT') return;
await this.dataSource.getRepository(WarehouseInventory).update(inventoryId, {
status: 'READY_FOR_PICKUP',
readyForPickupAt: new Date(),
});
if (row.bookingReference && row.lastMileDeliveryAddress) {
await this.lastMileService.acceptBooking(row.bookingReference);
}
}
async findByInventory(inventoryId: string): Promise<WarehouseInspectionReport[]> {
return this.inspectionRepository.findAll({
where: { inventoryId },
@@ -98,6 +143,9 @@ export class WarehouseInspectionService {
await this.dataSource
.getRepository(WarehouseInventory)
.update(report.inventoryId, { inspectionStatus: dto.inspectionStatus });
if (dto.inspectionStatus === 'PASSED') {
await this.markImportPickupReadyAndAcceptLastMile(report.inventoryId);
}
}
return this.findById(id);

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -142,6 +143,36 @@ export class WarehouseInventoryController {
return this.inventoryService.importUnloadedQueue();
}
@Get('export/djibouti-arrival-queue')
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
exportDjiboutiArrivalQueue(
@Query('scheduleId') scheduleId?: string,
@Query('destination') destination?: string,
@Query('status') status?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
return this.scheduling.exportDjiboutiArrivalQueue({
scheduleId,
destination,
status,
dateFrom,
dateTo,
});
}
@Get('export/djibouti-trains/:scheduleId/items')
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
}
@Post('export/auto-unload-at-djibouti')
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
}
@Get('import/pickup-ready-queue')
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
importPickupReadyQueue() {
@@ -226,6 +257,16 @@ export class WarehouseInventoryController {
return this.inventoryService.release(id, dto);
}
@Get(':id/release-document')
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -14,6 +14,7 @@ import { WarehouseFeeService } from './warehouse-fee.service';
interface GenerateOptions {
confirmZero?: boolean;
performedBy?: string;
billingCurrency?: 'ETB' | 'USD';
}
export interface PayInvoiceDto {
@@ -58,7 +59,8 @@ export class WarehouseInvoiceService {
);
}
const previews = await this.feeService.previewForInventory(inventoryId);
const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD';
const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const items = previews
@@ -75,9 +77,9 @@ export class WarehouseInvoiceService {
feeType,
description:
p.ruleType === 'STORAGE_FEE'
? `Storage fee ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
quantity: p.chargeableDays,
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
currency: p.currency,
@@ -98,7 +100,7 @@ export class WarehouseInvoiceService {
const invoiceType: WarehouseInvoiceType =
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
const currency = items[0]?.currency ?? 'USD';
const currency = billingCurrency;
const now = new Date();
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
@@ -79,7 +79,10 @@ export class WarehouseRulesController {
@Get('warehouse-inventory/:id/fee-preview')
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview(@Param('id', ParseUUIDPipe) id: string) {
return this.feeService.previewForInventory(id);
feePreview(
@Param('id', ParseUUIDPipe) id: string,
@Query('billingCurrency') billingCurrency?: string,
) {
return this.feeService.previewForInventory(id, billingCurrency);
}
}

View File

@@ -15,6 +15,12 @@ export class WarehouseYardsController {
private readonly zonesService: WarehouseZonesService,
) {}
@Get()
@ApiOperation({ summary: 'List all warehouse yards' })
findAll() {
return this.yardsService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get warehouse yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -13,6 +13,13 @@ export class WarehouseYardsService {
private readonly warehousesService: WarehousesService,
) {}
findAll(): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({
relations: { warehouse: true, zones: true },
order: { code: 'ASC' },
});
}
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
return this.yardsRepository.findAll({
where: { warehouseId },

View File

@@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service';
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}
@Get()
@ApiOperation({ summary: 'List all warehouse zones' })
findAll() {
return this.zonesService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get warehouse zone by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -13,6 +13,13 @@ export class WarehouseZonesService {
private readonly yardsService: WarehouseYardsService,
) {}
findAll(): Promise<WarehouseZone[]> {
return this.zonesRepository.findAll({
relations: { yard: { warehouse: true } },
order: { code: 'ASC' },
});
}
findByYard(yardId: string): Promise<WarehouseZone[]> {
return this.zonesRepository.findAll({
where: { yardId },

View File

@@ -1,7 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@@ -64,6 +69,13 @@ import { WarehousesService } from './warehouses.service';
WarehouseFeeInvoiceItem,
]),
FilesModule,
InterchangeDocumentsModule,
LastMileModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [
WarehousesController,
@@ -100,6 +112,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInvoiceService,
WarehouseSchedulingAdapterService,
SchedulingReadFacade,
ContractPdfService,
],
exports: [
WarehousesService,

View File

@@ -0,0 +1,186 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { AppModule } from '../app.module';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01';
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'];
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const dataSource = app.get(DataSource);
const yardRepo = dataSource.getRepository(Yard);
const serviceTypeRepo = dataSource.getRepository(ServiceType);
const cargoTypeRepo = dataSource.getRepository(CargoType);
const warehouseRepo = dataSource.getRepository(Warehouse);
const warehouseYardRepo = dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = dataSource.getRepository(WarehouseZone);
const bookingRepo = dataSource.getRepository(Booking);
const inventoryRepo = dataSource.getRepository(WarehouseInventory);
const locomotiveRepo = dataSource.getRepository(Locomotive);
const trainSetRepo = dataSource.getRepository(TrainSet);
const scheduleRepo = dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
if (existingSchedule) {
console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${existingSchedule.id}`);
return;
}
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const destinationYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const warehouseYard = warehouse
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
: null;
const warehouseZone = warehouseYard
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null;
const missing = [
!originYard ? 'Ethiopian origin yard' : '',
!destinationYard ? 'Djibouti destination yard' : '',
!serviceType ? 'service type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
].filter(Boolean);
if (missing.length) {
throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
}
const now = Date.now();
const departure = new Date(now - 6 * 60 * 60 * 1000);
const arrival = new Date(now - 60 * 60 * 1000);
const locomotive =
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
(await locomotiveRepo.save(
locomotiveRepo.create({
code: 'ICD-DEMO-LOCO',
name: 'Interchange Demo Locomotive',
maxPullWeightTons: 4000,
}),
));
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: 700,
totalLengthMeters: 360,
wagonCount: 12,
status: 'COMPLETED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard!.id,
destinationStationId: destinationYard!.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualArrivalAt: arrival,
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: TRAIN_NUMBER,
}),
);
for (const [index, reference] of BOOKING_REFS.entries()) {
const weight = 5200 + index * 800;
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`,
cargoTotalWeightVgm: weight,
}),
);
await inventoryRepo.save(
inventoryRepo.create({
warehouseId: warehouse!.id,
yardId: warehouseYard!.id,
zoneId: warehouseZone!.id,
bookingId: booking.id,
quantity: 1,
weight,
status: 'DISPATCHED',
inspectionStatus: 'PASSED',
arrivedAt: new Date(now - 4 * 60 * 60 * 1000),
inspectedAt: new Date(now - 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000),
loadedAt: new Date(now - 90 * 60 * 1000),
dispatchedAt: new Date(now - 70 * 60 * 1000),
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
console.log('Export Djibouti interchange demo seeded.');
console.log(`Train number: ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${schedule.id}`);
console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
} finally {
await app.close();
}
}
main().catch((error) => {
console.error('Export Djibouti interchange demo seed failed:', error);
process.exit(1);
});

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