mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
0
.pnpm-store/v11/.pnpm-needs-build-marker
Normal file
0
.pnpm-store/v11/.pnpm-needs-build-marker
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"dependencies":{"pnpm":"11.1.1"}}
|
||||||
BIN
.pnpm-store/v11/index.db
Normal file
BIN
.pnpm-store/v11/index.db
Normal file
Binary file not shown.
@@ -52,3 +52,10 @@ MINIO_SECRET_KEY=
|
|||||||
# Redis
|
# Redis
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"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: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: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"
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||||
},
|
},
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
"@nestjs/swagger": "^11.4.2",
|
"@nestjs/swagger": "^11.4.2",
|
||||||
"@nestjs/typeorm": "^11.0.1",
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
"@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",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ import { ContainersModule } from './modules/container-management/containers.modu
|
|||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
|
||||||
import { OverviewModule } from './modules/overview/overview.module';
|
import { OverviewModule } from './modules/overview/overview.module';
|
||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||||
import { DriversModule } from './modules/drivers/drivers.module';
|
import { DriversModule } from './modules/drivers/drivers.module';
|
||||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||||
|
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -123,13 +123,13 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
|
|||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
RoutesModule,
|
RoutesModule,
|
||||||
FacilitiesModule,
|
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
OverviewModule,
|
OverviewModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
FirstMileModule,
|
FirstMileModule,
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
|
InterchangeDocumentsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ export function deriveTradeDirection(
|
|||||||
originYard: YardLike,
|
originYard: YardLike,
|
||||||
destinationYard: YardLike,
|
destinationYard: YardLike,
|
||||||
): ScheduleTradeDirection {
|
): ScheduleTradeDirection {
|
||||||
const originCountry = originYard.country?.trim();
|
const originCountry = originYard.country?.trim().toLowerCase();
|
||||||
const destinationCountry = destinationYard.country?.trim();
|
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
||||||
|
|
||||||
if (originCountry === 'Djibouti') {
|
if (originCountry === 'djibouti') {
|
||||||
return 'IMPORT';
|
return 'IMPORT';
|
||||||
}
|
}
|
||||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
||||||
return 'EXPORT';
|
return 'EXPORT';
|
||||||
}
|
}
|
||||||
return 'DOMESTIC';
|
return 'DOMESTIC';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,12 +41,54 @@ export class BookingOrdersService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Orders placed against a contract, with their lines and child booking. */
|
/** Orders placed against a contract, with their lines and child booking. */
|
||||||
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||||
return this.ordersRepository.findByContract(contractBookingId);
|
const orders = await this.ordersRepository.findByContract(contractBookingId);
|
||||||
|
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
|
||||||
|
return orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
findById(id: string): Promise<BookingOrder | null> {
|
async findById(id: string): Promise<BookingOrder | null> {
|
||||||
return this.ordersRepository.findById(id);
|
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 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
|
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
||||||
// belong to. Validated for every order regardless of routing.
|
// belong to. Validated for every order regardless of routing.
|
||||||
@@ -142,43 +183,31 @@ export class BookingOrdersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (routeLineId) {
|
// The contract has a single shared drawdown pool (per container type for
|
||||||
// Multi-route: validate against the chosen route line's remaining pool.
|
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
|
||||||
for (const line of dto.lines) {
|
// only fixed origin/destination/km above — so every order, routed or not,
|
||||||
if (line.quantity <= 0) {
|
// validates each line against the same shared pool.
|
||||||
throw new BadRequestException('Order quantities must be greater than zero');
|
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)!;
|
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||||
if (orderTotal > chosen.remainingQuantity) {
|
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||||
|
if (!poolLine) {
|
||||||
throw new BadRequestException(
|
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 {
|
if (line.quantity > poolLine.remainingQuantity) {
|
||||||
// Single-route: validate each line against the per-container-type pool.
|
throw new BadRequestException(
|
||||||
const poolLines = await this.generalContractService.getQuantityLines(
|
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||||
contract.id,
|
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||||
);
|
);
|
||||||
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}` : ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ export class ContractQuantityLineView {
|
|||||||
remainingQuantity!: number;
|
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 {
|
export class ContractRouteLineView {
|
||||||
@ApiProperty({ description: 'Contract route line id' })
|
@ApiProperty({ description: 'Contract route line id' })
|
||||||
routeLineId!: string;
|
routeLineId!: string;
|
||||||
@@ -39,21 +45,6 @@ export class ContractRouteLineView {
|
|||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
destinationYardName!: string | null;
|
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' })
|
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
||||||
km!: number | null;
|
km!: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
|||||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||||
import { BookingOrder } from './booking-order.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
|
* 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
|
* 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;
|
containerType?: ContainerType | null;
|
||||||
|
|
||||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
/** 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;
|
quantity!: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity {
|
|||||||
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
||||||
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
* 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;
|
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;
|
reeferQuantity!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,10 +125,12 @@ export class GeneralContractService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-route drawdown pool for a multi-route general contract: contracted vs.
|
* The contracted routes (lanes) of a multi-route general contract — pure
|
||||||
* ordered vs. remaining, one entry per contracted route line. Returns [] for
|
* origin→destination pairs the contract covers. Routes carry NO quantity; the
|
||||||
* single-route contracts (no route lines) — callers fall back to
|
* contract draws from a single shared pool ({@link getQuantityLines}). An order
|
||||||
* {@link getQuantityLines}.
|
* 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(
|
async getRouteLines(
|
||||||
contractBookingId: string,
|
contractBookingId: string,
|
||||||
@@ -140,52 +142,18 @@ export class GeneralContractService {
|
|||||||
relations: {
|
relations: {
|
||||||
originYard: true,
|
originYard: true,
|
||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
containerType: true,
|
|
||||||
},
|
},
|
||||||
order: { createdAt: 'ASC' },
|
order: { createdAt: 'ASC' },
|
||||||
});
|
});
|
||||||
if (routeLines.length === 0) return [];
|
|
||||||
|
|
||||||
const ordered = await this.orderedByRouteLine(contractBookingId);
|
return routeLines.map((rl) => ({
|
||||||
|
routeLineId: rl.id,
|
||||||
return routeLines.map((rl) => {
|
originYardId: rl.originYardId,
|
||||||
const orderedQty = ordered.get(rl.id) ?? 0;
|
originYardName: rl.originYard?.label ?? null,
|
||||||
const contracted = Number(rl.quantity);
|
destinationYardId: rl.destinationYardId,
|
||||||
return {
|
destinationYardName: rl.destinationYard?.label ?? null,
|
||||||
routeLineId: rl.id,
|
km: rl.km != null ? Number(rl.km) : null,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||||
@@ -221,14 +189,12 @@ export class GeneralContractService {
|
|||||||
return line?.remainingQuantity ?? 0;
|
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> {
|
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);
|
const lines = await this.getQuantityLines(contractBookingId);
|
||||||
return lines.every((l) => l.remainingQuantity <= 0);
|
return lines.every((l) => l.remainingQuantity <= 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,6 +276,12 @@ export class BookingPricingService {
|
|||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
totalWagons,
|
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,
|
containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
const { service, bookingsRepository } = makeService([
|
||||||
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
||||||
{ settingCode: inputSetting.code, fileKey: 'packing_list', 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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -645,6 +645,14 @@ export class BookingTransitionService {
|
|||||||
throw new BadRequestException('No documents uploaded');
|
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) {
|
for (const file of files) {
|
||||||
const record = await this.filesService.upsertByCode({
|
const record = await this.filesService.upsertByCode({
|
||||||
resourceId: bookingId,
|
resourceId: bookingId,
|
||||||
@@ -670,6 +678,41 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(bookingId);
|
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). */
|
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
|
||||||
async reviewDocument(
|
async reviewDocument(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
@@ -795,6 +838,20 @@ export class BookingTransitionService {
|
|||||||
throw new BadRequestException('A valid schedule date is required');
|
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, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ export class BookingsController {
|
|||||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
return this.bookingsService.findAll(filter);
|
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;
|
const userId = user?.id;
|
||||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||||
const companyId =
|
const companyId =
|
||||||
@@ -236,8 +242,12 @@ export class BookingsController {
|
|||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.findById(id);
|
const booking = await this.bookingsService.findById(id);
|
||||||
// Staff see any booking; customers only their own company's.
|
// Staff see any booking; Global Logistics (clearance:view) may inspect any
|
||||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
// 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(
|
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||||
user?.id,
|
user?.id,
|
||||||
booking,
|
booking,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface BookingListFilterOptions {
|
|||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
excludePaymentStatus?: string;
|
excludePaymentStatus?: string;
|
||||||
|
customsClearingEnabled?: boolean;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
consolidationPaired?: string;
|
consolidationPaired?: string;
|
||||||
@@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
excludePaymentStatus: options.excludePaymentStatus,
|
excludePaymentStatus: options.excludePaymentStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.customsClearingEnabled !== undefined) {
|
||||||
|
qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
|
||||||
|
customsClearingEnabled: options.customsClearingEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.consolidationPaired === 'true') {
|
if (options.consolidationPaired === 'true') {
|
||||||
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
||||||
} else if (options.consolidationPaired === 'false') {
|
} else if (options.consolidationPaired === 'false') {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm';
|
|||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
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 { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
@@ -119,6 +120,18 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build evaluation input from booking freight shape. */
|
/** 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: {
|
private async buildEvalInput(dto: {
|
||||||
freightType: FreightType;
|
freightType: FreightType;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
@@ -126,8 +139,10 @@ export class BookingsService {
|
|||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
|
isReefer?: boolean;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
|
bulkTons?: number;
|
||||||
containers: CreateBookingContainerDto[];
|
containers: CreateBookingContainerDto[];
|
||||||
}): Promise<BookingEvaluationInput> {
|
}): Promise<BookingEvaluationInput> {
|
||||||
const containerLines =
|
const containerLines =
|
||||||
@@ -166,10 +181,14 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
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,
|
isGovernment: dto.isGovernment ?? false,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
|
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||||
containers,
|
containers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -317,12 +336,14 @@ export class BookingsService {
|
|||||||
) {
|
) {
|
||||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||||
}
|
}
|
||||||
} else if (!isGeneralContract) {
|
} else if (dto.scheduledDate) {
|
||||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
||||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
// directly). Require that the route has at least one OPEN departure on
|
||||||
// train later. General contracts skip this — they have no shipment date at
|
// that EAT day. The booking wizard does NOT send scheduledDate at creation
|
||||||
// creation; each drawdown order validates its own day.
|
// — it captures a non-binding estimatedShipmentDate instead, and the
|
||||||
const day = eatDay(new Date(dto.scheduledDate!));
|
// 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 =
|
const hasDeparture =
|
||||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
dto.originYardId,
|
dto.originYardId,
|
||||||
@@ -371,6 +392,17 @@ export class BookingsService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
fallbackType,
|
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 =
|
const needsConsolidation =
|
||||||
@@ -385,8 +417,10 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isHazardous: dto.isHazardous,
|
isHazardous: dto.isHazardous,
|
||||||
|
isReefer: dto.isReefer,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
|
bulkTons: dto.cargoTotalWeightVgm,
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
@@ -394,6 +428,11 @@ export class BookingsService {
|
|||||||
|
|
||||||
warnings.push(...ruleResult.warnings);
|
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({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId: companyId ?? null,
|
companyId: companyId ?? null,
|
||||||
@@ -411,8 +450,8 @@ export class BookingsService {
|
|||||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
||||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||||
customsClearingEnabled: dto.customsClearingEnabled ?? false,
|
customsClearingEnabled: includesCustoms,
|
||||||
customsClearingAgent: dto.customsClearingAgent ?? null,
|
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||||
equipmentReturn: dto.equipmentReturn,
|
equipmentReturn: dto.equipmentReturn,
|
||||||
originYardId: dto.originYardId,
|
originYardId: dto.originYardId,
|
||||||
destinationYardId: dto.destinationYardId,
|
destinationYardId: dto.destinationYardId,
|
||||||
@@ -423,11 +462,18 @@ export class BookingsService {
|
|||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
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,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
pnrCode: dto.pnrCode,
|
pnrCode: dto.pnrCode,
|
||||||
financialTerms: dto.financialTerms,
|
financialTerms: dto.financialTerms,
|
||||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
|
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||||
|
? new Date(dto.estimatedShipmentDate)
|
||||||
|
: null,
|
||||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
@@ -450,8 +496,11 @@ export class BookingsService {
|
|||||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-route general contracts: persist the contracted routes + quantities.
|
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
||||||
// Each drawdown order later draws from one of these route lines.
|
// 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) {
|
if (isGeneralContract && dto.routes?.length) {
|
||||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||||
await routeRepo.save(
|
await routeRepo.save(
|
||||||
@@ -460,9 +509,8 @@ export class BookingsService {
|
|||||||
contractBookingId: booking.id,
|
contractBookingId: booking.id,
|
||||||
originYardId: r.originYardId,
|
originYardId: r.originYardId,
|
||||||
destinationYardId: r.destinationYardId,
|
destinationYardId: r.destinationYardId,
|
||||||
containerTypeId:
|
containerTypeId: null,
|
||||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
quantity: 0,
|
||||||
quantity: r.quantity,
|
|
||||||
km: r.km ?? null,
|
km: r.km ?? null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -480,7 +528,11 @@ export class BookingsService {
|
|||||||
// Reuse the booking profile's onboarding documents instead of asking the
|
// Reuse the booking profile's onboarding documents instead of asking the
|
||||||
// customer to re-upload. Snapshot them onto the booking now (by reference),
|
// customer to re-upload. Snapshot them onto the booking now (by reference),
|
||||||
// so a later active-profile switch never changes this booking's documents.
|
// 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 {
|
try {
|
||||||
const onboardingFiles =
|
const onboardingFiles =
|
||||||
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
|
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
|
||||||
@@ -577,7 +629,9 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
|
isReefer: dto.isReefer ?? existing.isReefer,
|
||||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||||
|
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -597,6 +651,12 @@ export class BookingsService {
|
|||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
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,
|
priorityScore: ruleResult.priorityScore,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
};
|
};
|
||||||
@@ -617,10 +677,22 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
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.startDate) updates.startDate = new Date(dto.startDate);
|
||||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||||
delete updates.containers;
|
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);
|
await this.bookingsRepository.update(id, updates);
|
||||||
|
|
||||||
if (freightType === 'CONTAINER' && dto.containers) {
|
if (freightType === 'CONTAINER' && dto.containers) {
|
||||||
@@ -692,6 +764,23 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Return a paginated list of bookings matching the filter. */
|
/** 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(
|
async findAll(
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
forceCompanyId?: string,
|
forceCompanyId?: string,
|
||||||
@@ -738,6 +827,48 @@ export class BookingsService {
|
|||||||
'AWAITING_PAYMENT',
|
'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:
|
* List the current customer's bookings that are ready for payment:
|
||||||
* payable status AND not yet PAID. Company scope is derived from the
|
* payable status AND not yet PAID. Company scope is derived from the
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export class CreateBookingContainerDto {
|
|||||||
vgmPerUnitTons!: number;
|
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 {
|
export class CreateContractRouteDto {
|
||||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -64,20 +70,6 @@ export class CreateContractRouteDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
destinationYardId!: string;
|
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({
|
@ApiPropertyOptional({
|
||||||
description: 'Road distance (km) for this route; used to bill road orders.',
|
description: 'Road distance (km) for this route; used to bill road orders.',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
@@ -155,14 +147,24 @@ export class CreateBookingDto {
|
|||||||
bookingType?: string;
|
bookingType?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The day the customer wants to ship (the pool day key). Required for one-time
|
* The BINDING shipment day (the pool day key), validated against open train
|
||||||
* bookings; omitted for general contracts, which pick the date per order.
|
* 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' })
|
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||||
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate?: string;
|
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 })
|
@ApiProperty({ enum: CONTRACT_TYPES })
|
||||||
@IsIn([...CONTRACT_TYPES])
|
@IsIn([...CONTRACT_TYPES])
|
||||||
contractType!: string;
|
contractType!: string;
|
||||||
@@ -296,6 +298,17 @@ export class CreateBookingDto {
|
|||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isHazardous?: boolean;
|
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 })
|
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||||
@IsIn([...PAYMENT_CURRENCIES])
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
paymentCurrency!: string;
|
paymentCurrency!: string;
|
||||||
|
|||||||
@@ -155,10 +155,23 @@ export class Booking extends BaseEntity {
|
|||||||
/**
|
/**
|
||||||
* Nullable: general contracts have no shipment date at creation — the date is
|
* 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).
|
* 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 })
|
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||||
scheduledDate?: Date | null;
|
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
|
* General contracts only: when the ordering window closes, computed from the
|
||||||
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto";
|
|||||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||||
import { CompanyStatsResponseDto } from "./dto/company-stats-response.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 { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
@@ -226,6 +227,17 @@ export class CompaniesController {
|
|||||||
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
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")
|
@Post("onboarding/complete")
|
||||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||||
async completeOnboarding(
|
async completeOnboarding(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { FilesModule } from "../files/files.module";
|
import { FilesModule } from "../files/files.module";
|
||||||
|
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||||
import { MinioModule } from "../minio/minio.module";
|
import { MinioModule } from "../minio/minio.module";
|
||||||
import { CompaniesController } from "./companies.controller";
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||||
HttpModule,
|
HttpModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
FileUploadSettingsModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
@@ -12,7 +13,10 @@ import {
|
|||||||
DashboardScope,
|
DashboardScope,
|
||||||
} from "./company-dashboard.repository";
|
} from "./company-dashboard.repository";
|
||||||
import { MinioService } from "../minio/minio.service";
|
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 { ETradeService } from "./services/etrade.service";
|
||||||
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
@@ -53,9 +57,67 @@ export class CompaniesService {
|
|||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly etradeService: ETradeService,
|
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> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
if (exists) {
|
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) {
|
if (existingProfile) {
|
||||||
throw new ConflictException(
|
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,
|
companyId: company.id,
|
||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
email: identity.email,
|
|
||||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
|
||||||
jobTitle: dto.jobTitle ?? null,
|
jobTitle: dto.jobTitle ?? null,
|
||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
activeProfileType,
|
activeProfileType,
|
||||||
@@ -134,15 +196,13 @@ export class CompaniesService {
|
|||||||
input.type,
|
input.type,
|
||||||
);
|
);
|
||||||
if (existing) continue;
|
if (existing) continue;
|
||||||
const reference = await this.companyProfilesRepo.generateReference(
|
// No reference yet — these profiles await backoffice approval, which
|
||||||
input.type,
|
// is when the reference is minted (see setCompanyProfileStatus).
|
||||||
);
|
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
reference,
|
|
||||||
businessLicense: input.businessLicense ?? null,
|
businessLicense: input.businessLicense ?? null,
|
||||||
status: ProfileStatus.Active,
|
status: ProfileStatus.Pending,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||||
@@ -191,15 +251,6 @@ export class CompaniesService {
|
|||||||
return this.getCompanyInfoByUserId(identity.userId);
|
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 allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
const activeProfileType =
|
const activeProfileType =
|
||||||
@@ -224,8 +275,6 @@ export class CompaniesService {
|
|||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
email: identity.email,
|
|
||||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
|
||||||
isPrimaryContact: true,
|
isPrimaryContact: true,
|
||||||
activeProfileType,
|
activeProfileType,
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
@@ -251,12 +300,11 @@ export class CompaniesService {
|
|||||||
type,
|
type,
|
||||||
);
|
);
|
||||||
if (existing) continue;
|
if (existing) continue;
|
||||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||||
await this.companyProfilesRepo.create({
|
await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
reference,
|
status: ProfileStatus.Pending,
|
||||||
status: ProfileStatus.Active,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,6 +575,8 @@ export class CompaniesService {
|
|||||||
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
||||||
if (dto.contactPersonPhone !== undefined)
|
if (dto.contactPersonPhone !== undefined)
|
||||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||||
|
if (dto.contactVerifiedPhone !== undefined)
|
||||||
|
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
|
||||||
if (dto.generalManagerName !== undefined)
|
if (dto.generalManagerName !== undefined)
|
||||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||||
if (dto.generalManagerEmail !== undefined)
|
if (dto.generalManagerEmail !== undefined)
|
||||||
@@ -576,10 +626,10 @@ export class CompaniesService {
|
|||||||
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
||||||
await this.findCompanyById(dto.companyId);
|
await this.findCompanyById(dto.companyId);
|
||||||
|
|
||||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
const existing = await this.profilesRepo.findByUserId(dto.userId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(
|
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,
|
profileId: string,
|
||||||
status: ProfileStatus,
|
status: ProfileStatus,
|
||||||
): Promise<CompanyProfile> {
|
): Promise<CompanyProfile> {
|
||||||
const updated = await this.companyProfilesRepo.updateStatus(
|
const existing = await this.companyProfilesRepo.findById(profileId);
|
||||||
profileId,
|
if (!existing)
|
||||||
status,
|
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)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +720,7 @@ export class CompaniesService {
|
|||||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException(
|
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 });
|
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(
|
async markOnboardingComplete(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
@@ -821,23 +986,21 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
const companyId = profile.company?.id ?? profile.companyId;
|
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).
|
const requirements = await this.getOnboardingRequirements(userId);
|
||||||
if (!company.tin || company.tin.startsWith("D")) {
|
if (!requirements.isComplete) {
|
||||||
throw new BadRequestException(
|
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
|
// Send every operational profile in for approval; the company itself becomes
|
||||||
// (stored directly on the profile).
|
// active once the backoffice approves at least one profile.
|
||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
for (const cp of profiles) {
|
for (const cp of profiles) {
|
||||||
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
|
if (cp.status !== ProfileStatus.Pending) {
|
||||||
throw new BadRequestException(
|
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
|
||||||
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,6 +1015,25 @@ export class CompaniesService {
|
|||||||
return this.getCompanyInfoByUserId(userId);
|
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
|
* Authorize and resolve a company_profile that must belong to the current
|
||||||
* user's company — used before accepting/returning its license files.
|
* user's company — used before accepting/returning its license files.
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateReference(type: ProfileType): Promise<string> {
|
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(
|
const result = await this.repository.query(
|
||||||
`SELECT nextval('${seqName}') AS next_id`,
|
`SELECT nextval('${seqName}') AS next_id`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
|
||||||
|
|
||||||
export class CreateExternalProfileDto {
|
export class CreateExternalProfileDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -20,16 +19,6 @@ export class CreateExternalProfileDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lastName!: string;
|
lastName!: string;
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
@IsNotEmpty()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
@IsValidPhone()
|
|
||||||
phone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ export class ProfileResponseDto {
|
|||||||
contactPersonPosition: string | null;
|
contactPersonPosition: string | null;
|
||||||
contactPersonEmail: string | null;
|
contactPersonEmail: string | null;
|
||||||
contactPersonPhone: string | null;
|
contactPersonPhone: string | null;
|
||||||
|
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
|
||||||
|
contactVerifiedPhone: string | null;
|
||||||
generalManagerName: string | null;
|
generalManagerName: string | null;
|
||||||
generalManagerEmail: string | null;
|
generalManagerEmail: string | null;
|
||||||
generalManagerPhone: string | null;
|
generalManagerPhone: string | null;
|
||||||
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
|
|||||||
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
||||||
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||||
|
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
|
||||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto {
|
|||||||
this.id = profile.id;
|
this.id = profile.id;
|
||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
this.type = profile.type;
|
this.type = profile.type;
|
||||||
this.reference = profile.reference;
|
this.reference = profile.reference ?? '';
|
||||||
this.status = profile.status;
|
this.status = profile.status;
|
||||||
this.businessLicense = profile.businessLicense;
|
this.businessLicense = profile.businessLicense;
|
||||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export class ResponseExternalProfileDto {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
|
||||||
phone?: string | null;
|
|
||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
jobTitle?: string | null;
|
jobTitle?: string | null;
|
||||||
isPrimaryContact: boolean;
|
isPrimaryContact: boolean;
|
||||||
@@ -34,8 +32,6 @@ export class ResponseExternalProfileDto {
|
|||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
this.firstName = profile.firstName;
|
this.firstName = profile.firstName;
|
||||||
this.lastName = profile.lastName;
|
this.lastName = profile.lastName;
|
||||||
this.email = profile.email;
|
|
||||||
this.phone = profile.phone;
|
|
||||||
this.nationalId = profile.nationalId;
|
this.nationalId = profile.nationalId;
|
||||||
this.jobTitle = profile.jobTitle;
|
this.jobTitle = profile.jobTitle;
|
||||||
this.isPrimaryContact = profile.isPrimaryContact;
|
this.isPrimaryContact = profile.isPrimaryContact;
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export class UpdateProfileDto {
|
|||||||
@IsValidPhone()
|
@IsValidPhone()
|
||||||
contactPersonPhone?: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
generalManagerName?: string;
|
generalManagerName?: string;
|
||||||
|
|||||||
@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
||||||
type!: 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({
|
@Column({
|
||||||
name: "reference",
|
name: "reference",
|
||||||
type: "varchar",
|
type: "varchar",
|
||||||
length: 20,
|
length: 20,
|
||||||
nullable: false,
|
nullable: true,
|
||||||
unique: true,
|
|
||||||
})
|
})
|
||||||
reference!: string;
|
reference!: string | null;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity {
|
|||||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||||
lastName!: string;
|
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 })
|
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
|
|||||||
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
||||||
return this.repository.find({ where: { companyId } as any });
|
return this.repository.find({ where: { companyId } as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByEmail(email: string): Promise<ExternalProfile | null> {
|
|
||||||
return this.repository.findOne({ where: { email } as any });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class FirstMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
||||||
acceptBooking(@Param('reference') reference: string) {
|
acceptBooking(@Param('reference') reference: string) {
|
||||||
return this.firstMileService.acceptBooking(reference);
|
return this.firstMileService.acceptBookingByReference(reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
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 { FirstMile } from './entities/first-mile.entity';
|
||||||
import { FirstMileController } from './first-mile.controller';
|
import { FirstMileController } from './first-mile.controller';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([FirstMile]),
|
||||||
|
forwardRef(() => BookingsModule),
|
||||||
|
VehiclesModule,
|
||||||
|
DriversModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
controllers: [FirstMileController],
|
controllers: [FirstMileController],
|
||||||
providers: [FirstMileRepository, FirstMileService],
|
providers: [FirstMileRepository, FirstMileService],
|
||||||
exports: [FirstMileRepository, FirstMileService],
|
exports: [FirstMileRepository, FirstMileService],
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
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 { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||||
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FirstMileService {
|
export class FirstMileService {
|
||||||
|
private readonly logger = new Logger(FirstMileService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly firstMileRepository: FirstMileRepository,
|
private readonly firstMileRepository: FirstMileRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
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
|
* paid before any first-mile work proceeds. Throws if the reference is
|
||||||
* unknown or the booking has not reached PAID status.
|
* unknown or the booking has not reached PAID status.
|
||||||
*/
|
*/
|
||||||
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
|
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
return null;
|
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<{
|
async findAll(filter: FirstMileListFilter = {}): Promise<{
|
||||||
data: FirstMile[];
|
data: FirstMile[];
|
||||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
@@ -109,7 +133,7 @@ export class FirstMileService {
|
|||||||
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
|
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
|
||||||
return this.firstMileRepository.create({
|
return this.firstMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'PAYMENT_PENDING',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
remainingPayment: dto.remainingPayment ?? 0,
|
remainingPayment: dto.remainingPayment ?? 0,
|
||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? null,
|
||||||
@@ -119,7 +143,7 @@ export class FirstMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
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, {
|
const updated = await this.firstMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -135,9 +159,45 @@ export class FirstMileService {
|
|||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
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;
|
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> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.firstMileRepository.softDelete(id);
|
await this.firstMileRepository.softDelete(id);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { GenerateFromScheduleDto } from './generate-from-schedule.dto';
|
||||||
|
|
||||||
|
export class CreateInterchangeDocumentDto extends GenerateFromScheduleDto {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ export class LastMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||||
acceptBooking(@Param('reference') reference: string) {
|
acceptBooking(@Param('reference') reference: string) {
|
||||||
return this.lastMileService.acceptBooking(reference);
|
return this.lastMileService.acceptBookingByReference(reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
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 { LastMile } from './entities/last-mile.entity';
|
||||||
import { LastMileController } from './last-mile.controller';
|
import { LastMileController } from './last-mile.controller';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([LastMile]),
|
||||||
|
BookingsModule,
|
||||||
|
VehiclesModule,
|
||||||
|
DriversModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
controllers: [LastMileController],
|
controllers: [LastMileController],
|
||||||
providers: [LastMileRepository, LastMileService],
|
providers: [LastMileRepository, LastMileService],
|
||||||
exports: [LastMileRepository, LastMileService],
|
exports: [LastMileRepository, LastMileService],
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere } from 'typeorm';
|
import { FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
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 { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
@@ -26,27 +29,47 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LastMileService {
|
export class LastMileService {
|
||||||
|
private readonly logger = new Logger(LastMileService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly lastMileRepository: LastMileRepository,
|
private readonly lastMileRepository: LastMileRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
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);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.paymentStatus !== 'PAID') {
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
throw new BadRequestException(
|
return null;
|
||||||
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.create({
|
return this.create({
|
||||||
bookingId: booking.id,
|
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> {
|
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||||
return this.lastMileRepository.create({
|
return this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'PAYMENT_PENDING',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
remainingPayment: dto.remainingPayment ?? 0,
|
remainingPayment: dto.remainingPayment ?? 0,
|
||||||
estimatedKm: dto.estimatedKm ?? null,
|
estimatedKm: dto.estimatedKm ?? null,
|
||||||
@@ -116,7 +139,7 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
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, {
|
const updated = await this.lastMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -132,9 +155,50 @@ export class LastMileService {
|
|||||||
throw new NotFoundException(`Last-mile record ${id} not found`);
|
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;
|
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> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.lastMileRepository.softDelete(id);
|
await this.lastMileRepository.softDelete(id);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { ClientsModule, Transport } from "@nestjs/microservices";
|
||||||
|
|
||||||
import { NotificationsService } from "./notifications.service";
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
import { SmsClientService } from "./sms-client.service";
|
||||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
|
||||||
|
|
||||||
@Module({
|
@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: [],
|
controllers: [],
|
||||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
|
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
|
||||||
exports: [NotificationsService],
|
exports: [NotificationsService, SmsClientService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule { }
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -27,9 +27,29 @@ export class NotificationsService {
|
|||||||
if (!strategy) {
|
if (!strategy) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const sent = await strategy.send(recipient, message)
|
const sent = await strategy.send(recipient, message);
|
||||||
this.logger.log(`is sent - ${sent}`)
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,57 @@
|
|||||||
import { Injectable} from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { NotificationStrategy } from "./notification.strategy";
|
|
||||||
import { HttpService } from '@nestjs/axios';
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { firstValueFrom } from 'rxjs';
|
import axios, { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
import { NotificationStrategy } from "./notification.strategy";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||||
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { }
|
private readonly logger = new Logger(SmsNotificationStrategy.name);
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,9 @@ export class OtpController {
|
|||||||
@Post("send")
|
@Post("send")
|
||||||
async sendOtp(
|
async sendOtp(
|
||||||
@Body("phone")
|
@Body("phone")
|
||||||
phone: string,
|
phone: string
|
||||||
@Body("otp")
|
|
||||||
otp: string
|
|
||||||
) {
|
) {
|
||||||
return this.otpService.sendOtp(
|
return this.otpService.sendOtp(phone);
|
||||||
phone,otp
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import { OtpService } from "./otp.service";
|
|||||||
|
|
||||||
import { OtpRepository } from "./otp.repository";
|
import { OtpRepository } from "./otp.repository";
|
||||||
|
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
OtpVerification,
|
OtpVerification,
|
||||||
]),
|
]),
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
|
|
||||||
controllers: [OtpController],
|
controllers: [OtpController],
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
import { OtpRepository } from "./otp.repository";
|
import { OtpRepository } from "./otp.repository";
|
||||||
|
|
||||||
|
import { SmsClientService } from "../notifications/sms-client.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OtpService {
|
export class OtpService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly otpRepository: OtpRepository
|
private readonly otpRepository: OtpRepository,
|
||||||
|
private readonly smsClient: SmsClientService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -29,11 +30,12 @@ export class OtpService {
|
|||||||
// Send OTP
|
// Send OTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async sendOtp(phone: string, otp: string) {
|
async sendOtp(phone: string) {
|
||||||
try {
|
try {
|
||||||
// generate otp
|
// The verification code is generated server-side — never supplied by the
|
||||||
// const otp =
|
// caller — so the OTP stays a secret known only to the server and the
|
||||||
// this.generateOtp();
|
// recipient of the SMS.
|
||||||
|
const otp = this.generateOtp();
|
||||||
|
|
||||||
// find existing phone
|
// find existing phone
|
||||||
const existingPhone =
|
const existingPhone =
|
||||||
@@ -55,33 +57,11 @@ export class OtpService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// send sms
|
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||||
await axios.post(
|
await this.smsClient.sendSms({
|
||||||
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms",
|
to: phone,
|
||||||
{
|
message: `Your verification code is ${otp}`,
|
||||||
to: phone,
|
});
|
||||||
|
|
||||||
sourceId: "EDR",
|
|
||||||
|
|
||||||
sourceName:
|
|
||||||
"EDR Freight",
|
|
||||||
|
|
||||||
appKey:
|
|
||||||
"YOUR_APP_KEY",
|
|
||||||
|
|
||||||
text: `Your verification code is ${otp}`,
|
|
||||||
|
|
||||||
callbackUrl: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
accept: "*/*",
|
|
||||||
|
|
||||||
"Content-Type":
|
|
||||||
"application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Module, forwardRef } from "@nestjs/common";
|
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import {
|
import {
|
||||||
PAYMENT_EVENTS_DLX,
|
PAYMENT_EVENTS_DLX,
|
||||||
PAYMENT_EVENTS_EXCHANGE,
|
PAYMENT_EVENTS_EXCHANGE,
|
||||||
@@ -10,31 +10,31 @@ import {
|
|||||||
PaymentService as PaymentServiceEnum,
|
PaymentService as PaymentServiceEnum,
|
||||||
paymentServiceBindingPattern,
|
paymentServiceBindingPattern,
|
||||||
} from "@edr/types";
|
} 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 { PaymentClientService } from "./payment-client.service";
|
||||||
import { PaymentController } from "./payment.controller";
|
import { PaymentController } from "./payment.controller";
|
||||||
import { PaymentRepository } from "./payment.repository";
|
|
||||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||||
import { InternalPaymentController } from "./internal-payment.controller";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { PaymentService } from "./payment.service";
|
||||||
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";
|
|
||||||
|
|
||||||
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||||
|
|
||||||
@Module({
|
function rabbitMQImport(): DynamicModule[] {
|
||||||
imports: [
|
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
|
||||||
HttpModule.register({ timeout: 10_000 }),
|
|
||||||
ConfigModule,
|
return [
|
||||||
DropdownSettingsModule,
|
|
||||||
forwardRef(() => TrainSchedulingModule),
|
|
||||||
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
|
|
||||||
RabbitMQModule.forRootAsync({
|
RabbitMQModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
uri: config.get<string>("rabbitmq.url") as string,
|
uri: config.get<string>("rabbitmq.url") ?? process.env.PAYMENT_RABBITMQ_URL ?? "",
|
||||||
exchanges: [
|
exchanges: [
|
||||||
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
|
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
|
||||||
{ name: PAYMENT_EVENTS_DLX, 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 },
|
connectionInitOptions: { wait: false },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
|
ConfigModule,
|
||||||
|
DropdownSettingsModule,
|
||||||
|
forwardRef(() => FirstMileModule),
|
||||||
|
forwardRef(() => TrainSchedulingModule),
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
PaymentEntity,
|
||||||
|
PaymentWebhookEventEntity,
|
||||||
|
PaymentRefundEntity,
|
||||||
|
]),
|
||||||
|
...rabbitMQImport(),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PaymentRepository,
|
PaymentRepository,
|
||||||
@@ -62,4 +78,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
|||||||
controllers: [PaymentController, InternalPaymentController],
|
controllers: [PaymentController, InternalPaymentController],
|
||||||
exports: [PaymentService],
|
exports: [PaymentService],
|
||||||
})
|
})
|
||||||
export class PaymentModule { }
|
export class PaymentModule {}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||||
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.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. */
|
/** Setting code holding the global ordering window (months) for general contracts. */
|
||||||
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
||||||
@@ -60,6 +61,7 @@ export class PaymentService {
|
|||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly dropdownSettings: DropdownSettingsService,
|
private readonly dropdownSettings: DropdownSettingsService,
|
||||||
|
private readonly firstMileService: FirstMileService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/** Configured general-contract ordering window in months (defaults to 3). */
|
/** 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: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||||
: { paymentStatus: "PAID", status: "PAID" },
|
: { paymentStatus: "PAID", status: "PAID" },
|
||||||
);
|
);
|
||||||
|
await this.firstMileService.acceptBooking(input.bookingId);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isGeneralContract) {
|
if (isGeneralContract) {
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export interface BookingEvaluationInput {
|
|||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
totalWagons: number;
|
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[];
|
containers: BookingContainerEvalInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,16 +230,46 @@ export class RuleEngineService {
|
|||||||
});
|
});
|
||||||
if (!triggered) continue;
|
if (!triggered) continue;
|
||||||
|
|
||||||
let triggerValue: number | null = null;
|
// Surcharges scale by their own rateUnit, so the same trigger can bill the
|
||||||
let calculatedAmount = Number(rate.rateValue);
|
// 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.
|
let triggerValue: number | null = null;
|
||||||
if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') {
|
let calculatedAmount: number;
|
||||||
triggerValue = containerWeightResults.reduce(
|
|
||||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
switch (rate.rateUnit) {
|
||||||
0,
|
case 'PER_TON':
|
||||||
);
|
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
|
||||||
calculatedAmount = triggerValue * Number(rate.rateValue);
|
// (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
|
// Safety guard: never include a surcharge with a non-positive amount (a
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
route: true,
|
route: true,
|
||||||
trainSet: {
|
trainSet: {
|
||||||
locomotive: true,
|
locomotive: true,
|
||||||
|
locomotives: { locomotive: true },
|
||||||
wagons: {
|
wagons: {
|
||||||
wagonType: true,
|
wagonType: true,
|
||||||
physicalWagon: true,
|
physicalWagon: true,
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class CreateContainerTrainScheduleDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduleDate!: string;
|
scheduleDate!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({
|
||||||
@IsUUID()
|
type: [String],
|
||||||
locomotiveId!: 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' })
|
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive(
|
|||||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||||
export const MAX_FALLBACK_LENGTH = 760;
|
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. */
|
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||||
export function bookingTrainLengthMeters(
|
export function bookingTrainLengthMeters(
|
||||||
freightType: string | null | undefined,
|
freightType: string | null | undefined,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -436,7 +436,7 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("bulk/schedules/:id/cancel")
|
@Post('bulk/schedules/:id/cancel')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||||
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module';
|
|||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Route } from '../routes/entities/route.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 { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
||||||
@@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
WagonType,
|
WagonType,
|
||||||
TrainSet,
|
TrainSet,
|
||||||
TrainSetWagon,
|
TrainSetWagon,
|
||||||
|
TrainSetLocomotive,
|
||||||
Route,
|
Route,
|
||||||
Wagon,
|
Wagon,
|
||||||
Container,
|
Container,
|
||||||
|
|||||||
@@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||||
const lockedLocomotiveRepo = {
|
const lockedLocomotiveRepo = {
|
||||||
findOne: jest.fn().mockResolvedValue(locomotive),
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(locomotive)
|
||||||
|
.mockResolvedValueOnce(locomotive2),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const trainScheduleRepo = {
|
const trainScheduleRepo = {
|
||||||
@@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => {
|
|||||||
create: jest.fn().mockImplementation((value) => value),
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
||||||
};
|
};
|
||||||
|
const trainSetLocomotiveRepo = {
|
||||||
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
const manager = {
|
const manager = {
|
||||||
getRepository: jest.fn((entity: { name?: string }) => {
|
getRepository: jest.fn((entity: { name?: string }) => {
|
||||||
switch (entity?.name) {
|
switch (entity?.name) {
|
||||||
@@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => {
|
|||||||
return trainScheduleRepo;
|
return trainScheduleRepo;
|
||||||
case 'TrainSet':
|
case 'TrainSet':
|
||||||
return trainSetRepo;
|
return trainSetRepo;
|
||||||
|
case 'TrainSetLocomotive':
|
||||||
|
return trainSetLocomotiveRepo;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unexpected transaction repository ${entity?.name}`);
|
throw new Error(`Unexpected transaction repository ${entity?.name}`);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
|
||||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
if ((entity as { name?: string })?.name === 'Route') {
|
if ((entity as { name?: string })?.name === 'Route') {
|
||||||
return { findOne: jest.fn().mockResolvedValue(route) };
|
return { findOne: jest.fn().mockResolvedValue(route) };
|
||||||
@@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => {
|
|||||||
const result = await service.createContainerTrainSchedule({
|
const result = await service.createContainerTrainSchedule({
|
||||||
routeId: 'route-1',
|
routeId: 'route-1',
|
||||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
locomotiveId: 'loc-1',
|
locomotiveIds: ['loc-1', 'loc-2'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||||
expect(trainScheduleRepo.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');
|
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 }) => {
|
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||||
if (entity?.name === 'Route') {
|
if (entity?.name === 'Route') {
|
||||||
return {
|
return {
|
||||||
@@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
service.createContainerTrainSchedule({
|
service.createContainerTrainSchedule({
|
||||||
routeId: 'route-1',
|
routeId: 'route-1',
|
||||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
locomotiveId: 'loc-1',
|
locomotiveIds: ['loc-1', 'loc-2'],
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
AllocationLoadType,
|
AllocationLoadType,
|
||||||
SchedulingStatus,
|
SchedulingStatus,
|
||||||
TrainCheckpointKind,
|
TrainCheckpointKind,
|
||||||
@@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity';
|
|||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||||
import { Route } from '../routes/entities/route.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 { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
@@ -79,8 +80,10 @@ import {
|
|||||||
pickBulkWagonType,
|
pickBulkWagonType,
|
||||||
} from './wagon-type-resolver.util';
|
} from './wagon-type-resolver.util';
|
||||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||||
|
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||||
import {
|
import {
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
|
minLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
import {
|
import {
|
||||||
@@ -288,31 +291,42 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||||
const route = await this.getActiveRoute(dto.routeId);
|
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 createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
||||||
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
|
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
|
||||||
where: { id: locomotive.id },
|
const lockedLocomotives: Locomotive[] = [];
|
||||||
lock: { mode: 'pessimistic_write' },
|
for (const locomotiveId of locomotiveIds) {
|
||||||
});
|
const locked = await manager.getRepository(Locomotive).findOne({
|
||||||
if (!lockedLocomotive) {
|
where: { id: locomotiveId },
|
||||||
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
lock: { mode: 'pessimistic_write' },
|
||||||
}
|
});
|
||||||
if (lockedLocomotive.status !== 'AVAILABLE') {
|
if (!locked) {
|
||||||
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
|
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(
|
const direction = deriveScheduleDirection(
|
||||||
route.originYard ?? { country: null },
|
route.originYard ?? { country: null },
|
||||||
route.destinationYard ?? { 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({
|
const schedule = manager.getRepository(TrainSchedule).create({
|
||||||
trainSetId: trainSet.id,
|
trainSetId: trainSet.id,
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
@@ -322,11 +336,14 @@ export class TrainSchedulingService {
|
|||||||
status: TrainScheduleStatusEnum.Draft,
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
direction,
|
direction,
|
||||||
maxWagons: (
|
maxWagons: (
|
||||||
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
|
await this.resolveTrainLimitConfig(dto, limitLoco)
|
||||||
).maxWagonsPerTrain,
|
).maxWagonsPerTrain,
|
||||||
});
|
});
|
||||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
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;
|
return saved.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,8 +392,9 @@ export class TrainSchedulingService {
|
|||||||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||||||
};
|
};
|
||||||
|
|
||||||
const locomotive = schedule.trainSet.locomotive;
|
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||||||
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
|
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
|
||||||
|
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
|
||||||
const validation = await this.validateBookingsForScheduling(
|
const validation = await this.validateBookingsForScheduling(
|
||||||
previewDto,
|
previewDto,
|
||||||
freightType ?? null,
|
freightType ?? null,
|
||||||
@@ -408,17 +426,17 @@ export class TrainSchedulingService {
|
|||||||
const totalWeightTons = validation.summary.totalWeightTons;
|
const totalWeightTons = validation.summary.totalWeightTons;
|
||||||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||||||
|
|
||||||
if (!locomotive) {
|
if (!limitLoco) {
|
||||||
throw new BadRequestException('Schedule train set has no locomotive');
|
throw new BadRequestException('Schedule train set has no locomotives');
|
||||||
}
|
}
|
||||||
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
|
if (limitLoco.maxPullWeightTons < totalWeightTons) {
|
||||||
throw new BadRequestException(
|
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(
|
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();
|
const now = new Date();
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||||
|
|
||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
TrainScheduleStatusEnum.Dispatched,
|
TrainScheduleStatusEnum.Dispatched,
|
||||||
{ actualDepartureAt: now },
|
{ actualDepartureAt: now, trainNumber },
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
@@ -718,6 +738,60 @@ export class TrainSchedulingService {
|
|||||||
return this.getTrainScheduleById(scheduleId);
|
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). */
|
/** Open or close a schedule's booking window (staff override). */
|
||||||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
||||||
await this.dataSource
|
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) {
|
if (schedule.trainSet?.locomotiveId) {
|
||||||
const loco = await manager
|
const loco = await manager
|
||||||
.getRepository(Locomotive)
|
.getRepository(Locomotive)
|
||||||
@@ -982,7 +1069,7 @@ export class TrainSchedulingService {
|
|||||||
async getContainerTrainSchedules() {
|
async getContainerTrainSchedules() {
|
||||||
const schedules = await this.trainSchedulesRepository.findAll({
|
const schedules = await this.trainSchedulesRepository.findAll({
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true },
|
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||||
route: true,
|
route: true,
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
@@ -1013,10 +1100,11 @@ export class TrainSchedulingService {
|
|||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
||||||
}
|
}
|
||||||
if (schedule.trainSet?.locomotiveId) {
|
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||||
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
|
if (cancelledLocoIds.length) {
|
||||||
status: 'AVAILABLE',
|
await manager
|
||||||
});
|
.getRepository(Locomotive)
|
||||||
|
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
|
||||||
}
|
}
|
||||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||||
if (wagon.physicalWagonId) {
|
if (wagon.physicalWagonId) {
|
||||||
@@ -1251,24 +1339,29 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let assignedLocomotive: Locomotive | null = null;
|
let assignedLocomotives: Locomotive[] = [];
|
||||||
if (targetScheduleId) {
|
if (targetScheduleId) {
|
||||||
const targetSchedule =
|
const targetSchedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
||||||
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
|
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assignedLocomotive) {
|
if (assignedLocomotives.length) {
|
||||||
if (assignedLocomotive.currentYardId !== originYardId) {
|
// 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(
|
violations.push(
|
||||||
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
|
`Locomotive ${offYard.code} is not at the schedule origin yard`,
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
|
setLimits &&
|
||||||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
|
(setLimits.maxPullWeightTons < totalWeightTons ||
|
||||||
|
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
||||||
) {
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
'Assigned locomotive cannot support the total train weight and length',
|
'Assigned locomotives cannot support the total train weight and length',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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(
|
async selectOrValidateLocomotive(
|
||||||
locomotiveId: string,
|
locomotiveId: string,
|
||||||
totalWeightTons: number,
|
totalWeightTons: number,
|
||||||
@@ -1841,15 +1950,28 @@ export class TrainSchedulingService {
|
|||||||
return locomotive;
|
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({
|
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,
|
totalWeightTons: 0,
|
||||||
totalLengthMeters: 0,
|
totalLengthMeters: 0,
|
||||||
wagonCount: 0,
|
wagonCount: 0,
|
||||||
status: 'DRAFT',
|
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) {
|
private async getActiveRoute(routeId: string) {
|
||||||
@@ -1915,6 +2037,12 @@ export class TrainSchedulingService {
|
|||||||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||||||
}
|
}
|
||||||
: 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,
|
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||||
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||||||
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||||||
@@ -1952,7 +2080,7 @@ export class TrainSchedulingService {
|
|||||||
bookingWindowStatus: 'OPEN',
|
bookingWindowStatus: 'OPEN',
|
||||||
},
|
},
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true },
|
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||||
route: { milestones: true },
|
route: { milestones: true },
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
@@ -2099,6 +2227,15 @@ export class TrainSchedulingService {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null,
|
: 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 ?? [])]
|
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
.map((wagon) => ({
|
.map((wagon) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
|
|||||||
|
|
||||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { TrainSetLocomotive } from './train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from './train-set-wagon.entity';
|
import { TrainSetWagon } from './train-set-wagon.entity';
|
||||||
|
|
||||||
export const TRAIN_SET_STATUSES = [
|
export const TRAIN_SET_STATUSES = [
|
||||||
@@ -19,6 +20,7 @@ export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
|
|||||||
@Index(['locomotiveId'])
|
@Index(['locomotiveId'])
|
||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
export class TrainSet extends BaseEntity {
|
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' })
|
@Column({ name: 'locomotive_id', type: 'uuid' })
|
||||||
locomotiveId!: string;
|
locomotiveId!: string;
|
||||||
|
|
||||||
@@ -26,6 +28,10 @@ export class TrainSet extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'locomotive_id' })
|
@JoinColumn({ name: 'locomotive_id' })
|
||||||
locomotive?: Locomotive;
|
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 })
|
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
totalWeightTons!: number;
|
totalWeightTons!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { TrainSet } from './entities/train-set.entity';
|
import { TrainSet } from './entities/train-set.entity';
|
||||||
|
import { TrainSetLocomotive } from './entities/train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from './entities/train-set-wagon.entity';
|
import { TrainSetWagon } from './entities/train-set-wagon.entity';
|
||||||
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
|
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
|
||||||
import { TrainSetsRepository } from './train-sets.repository';
|
import { TrainSetsRepository } from './train-sets.repository';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
|
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon, TrainSetLocomotive])],
|
||||||
providers: [TrainSetsRepository, TrainSetWagonsRepository],
|
providers: [TrainSetsRepository, TrainSetWagonsRepository],
|
||||||
exports: [TrainSetsRepository, TrainSetWagonsRepository],
|
exports: [TrainSetsRepository, TrainSetWagonsRepository],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,4 +37,16 @@ export class CreateVehicleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignedDriverName?: string;
|
assignedDriverName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
powerPlateNo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trailerPlateNo?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 vehicle = this.repository.create(vehicleData);
|
||||||
const vehicles = await this.repository.save(vehicle);
|
return this.repository.save(vehicle);
|
||||||
return vehicles?.[0] as Vehicle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
|
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
|
||||||
return (await this.repository.save(vehicle)) as Vehicle;
|
return this.repository.save(vehicle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
|||||||
export const WAGON_STATUSES = [
|
export const WAGON_STATUSES = [
|
||||||
WagonStatus.Available,
|
WagonStatus.Available,
|
||||||
WagonStatus.Assigned,
|
WagonStatus.Assigned,
|
||||||
|
WagonStatus.ImportReady,
|
||||||
|
WagonStatus.ExportReady,
|
||||||
WagonStatus.Maintenance,
|
WagonStatus.Maintenance,
|
||||||
WagonStatus.Retired,
|
WagonStatus.Retired,
|
||||||
] as const;
|
] as const;
|
||||||
@@ -65,7 +67,7 @@ export class Wagon extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'current_train_schedule_id' })
|
@JoinColumn({ name: 'current_train_schedule_id' })
|
||||||
currentTrainSchedule?: TrainSchedule | null;
|
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' })
|
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
|
||||||
@JoinColumn({ name: 'train_id' })
|
@JoinColumn({ name: 'train_id' })
|
||||||
train!: Train | null;
|
train!: Train | null;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
warehouseId?: string;
|
warehouseId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
facilityId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateTo?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
|
|||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
bookingReference?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
bookingNumber?: string;
|
bookingNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class GenerateInvoiceDto {
|
||||||
@ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' })
|
@ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' })
|
||||||
@@ -11,6 +11,11 @@ export class GenerateInvoiceDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
performedBy?: string;
|
performedBy?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ETB', 'USD'])
|
||||||
|
billingCurrency?: 'ETB' | 'USD';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PayInvoiceBodyDto {
|
export class PayInvoiceBodyDto {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { WarehouseZone } from './warehouse-zone.entity';
|
|||||||
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
|
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
|
||||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||||
'UNLOADED',
|
'UNLOADED',
|
||||||
|
'UNLOADED_AT_DJIBOUTI_PORT',
|
||||||
'RECEIVED',
|
'RECEIVED',
|
||||||
'STORED',
|
'STORED',
|
||||||
'RESERVED',
|
'RESERVED',
|
||||||
@@ -31,12 +32,13 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
|||||||
// UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected.
|
// 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.
|
// Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection.
|
||||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
|
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
STORED: ['RESERVED'],
|
STORED: ['RESERVED'],
|
||||||
RESERVED: ['READY_FOR_LOADING'],
|
RESERVED: ['READY_FOR_LOADING'],
|
||||||
READY_FOR_LOADING: ['LOADED'],
|
READY_FOR_LOADING: ['LOADED'],
|
||||||
LOADED: ['DISPATCHED'],
|
LOADED: ['DISPATCHED'],
|
||||||
DISPATCHED: [],
|
DISPATCHED: ['UNLOADED_AT_DJIBOUTI_PORT'],
|
||||||
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
|
// 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
|
// 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.
|
// it / customs or inspection hold / operator chooses to store.
|
||||||
|
|||||||
@@ -37,6 +37,36 @@ export interface ImportTrainItemRow {
|
|||||||
lastMileRequested: boolean;
|
lastMileRequested: boolean;
|
||||||
pickupOption: string;
|
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 {
|
export interface WagonView {
|
||||||
id: string;
|
id: string;
|
||||||
wagonNumber: string;
|
wagonNumber: string;
|
||||||
@@ -67,6 +97,13 @@ export interface BookingScheduleView {
|
|||||||
export class SchedulingReadFacade {
|
export class SchedulingReadFacade {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
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. */
|
/** Look up a single physical wagon. Returns null if it does not exist. */
|
||||||
async findWagon(wagonId: string): Promise<WagonView | null> {
|
async findWagon(wagonId: string): Promise<WagonView | null> {
|
||||||
const rows = await this.dataSource.query(
|
const rows = await this.dataSource.query(
|
||||||
@@ -220,4 +257,187 @@ export class SchedulingReadFacade {
|
|||||||
);
|
);
|
||||||
return rows;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
|
|||||||
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
||||||
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
||||||
const rule = await this.findMatchingRule(criteria);
|
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(
|
const [yard] = await this.dataSource.query(
|
||||||
yardCode
|
`SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||||
? `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`,
|
||||||
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
|
[rule.targetYardCode],
|
||||||
: `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] : [],
|
|
||||||
);
|
);
|
||||||
if (!yard) return null;
|
if (!yard) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { Warehouse } from './entities/warehouse.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
@@ -26,6 +26,29 @@ export interface WarehouseDashboard {
|
|||||||
export class WarehouseDashboardService {
|
export class WarehouseDashboardService {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
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> {
|
async getDashboard(): Promise<WarehouseDashboard> {
|
||||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
@@ -47,21 +70,18 @@ export class WarehouseDashboardService {
|
|||||||
delivered,
|
delivered,
|
||||||
receivedToday,
|
receivedToday,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
warehouseRepo.count(),
|
this.safeCount(warehouseRepo),
|
||||||
inventoryRepo.count(),
|
this.safeCount(inventoryRepo),
|
||||||
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||||
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
|
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
|
||||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
|
||||||
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
|
||||||
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
|
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
|
||||||
inventoryRepo
|
this.safeReceivedToday(startOfToday),
|
||||||
.createQueryBuilder('inv')
|
|
||||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
|
||||||
.getCount(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ExchangeService } from '@edr/api-common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||||
@@ -13,6 +14,8 @@ interface ItemAttributes {
|
|||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
cargoTypeCode: string | null;
|
cargoTypeCode: string | null;
|
||||||
containerTypeCode: string | null;
|
containerTypeCode: string | null;
|
||||||
|
inventoryQuantity: number;
|
||||||
|
bookingContainerCount: number;
|
||||||
facilityId: string | null;
|
facilityId: string | null;
|
||||||
warehouseId: string | null;
|
warehouseId: string | null;
|
||||||
yardId: string | null;
|
yardId: string | null;
|
||||||
@@ -26,11 +29,15 @@ export interface FeePreview {
|
|||||||
freeDays: number;
|
freeDays: number;
|
||||||
ratePerDay: number;
|
ratePerDay: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
ruleCurrency: string | null;
|
||||||
|
billingCurrency: string;
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||||||
elapsedDays: number;
|
elapsedDays: number;
|
||||||
chargeableDays: number;
|
chargeableDays: number;
|
||||||
|
containerCount: number;
|
||||||
|
billableUnits: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +48,7 @@ export class WarehouseFeeService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||||
|
private readonly exchangeService: ExchangeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||||
@@ -67,6 +75,7 @@ export class WarehouseFeeService {
|
|||||||
`SELECT inv.arrived_at AS "arrivedAt",
|
`SELECT inv.arrived_at AS "arrivedAt",
|
||||||
inv.gate_cleared_at AS "gateClearedAt",
|
inv.gate_cleared_at AS "gateClearedAt",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
|
inv.quantity AS "inventoryQuantity",
|
||||||
inv.warehouse_id AS "warehouseId",
|
inv.warehouse_id AS "warehouseId",
|
||||||
inv.yard_id AS "yardId",
|
inv.yard_id AS "yardId",
|
||||||
inv.zone_id AS "zoneId",
|
inv.zone_id AS "zoneId",
|
||||||
@@ -74,7 +83,8 @@ export class WarehouseFeeService {
|
|||||||
b.freight_type AS "freightType",
|
b.freight_type AS "freightType",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
cgt.code AS "cargoTypeCode",
|
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
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_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.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_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 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`,
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||||
[inventoryId],
|
[inventoryId],
|
||||||
);
|
);
|
||||||
@@ -125,44 +141,86 @@ export class WarehouseFeeService {
|
|||||||
return best;
|
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 start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||||
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||||
const freeDays = rule?.freeDays ?? 0;
|
const freeDays = rule?.freeDays ?? 0;
|
||||||
const ratePerDay = Number(rule?.ratePerDay ?? 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
|
const elapsedDays = start
|
||||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||||
: 0;
|
: 0;
|
||||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
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 {
|
return {
|
||||||
ruleType,
|
ruleType,
|
||||||
ruleId: rule?.id ?? null,
|
ruleId: rule?.id ?? null,
|
||||||
ruleName: rule?.name ?? null,
|
ruleName: rule?.name ?? null,
|
||||||
freeDays,
|
freeDays,
|
||||||
ratePerDay,
|
ratePerDay: convertedRatePerDay,
|
||||||
currency: rule?.currency ?? 'USD',
|
currency: targetCurrency,
|
||||||
|
ruleCurrency,
|
||||||
|
billingCurrency: targetCurrency,
|
||||||
startDate: start ? start.toISOString() : null,
|
startDate: start ? start.toISOString() : null,
|
||||||
endDate: new Date(endDate).toISOString(),
|
endDate: new Date(endDate).toISOString(),
|
||||||
endIsOpen,
|
endIsOpen,
|
||||||
elapsedDays,
|
elapsedDays,
|
||||||
chargeableDays,
|
chargeableDays,
|
||||||
|
containerCount,
|
||||||
|
billableUnits,
|
||||||
amount,
|
amount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
/** 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 item = await this.loadItem(inventoryId);
|
||||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
||||||
return byType.map((type) =>
|
return Promise.all(
|
||||||
this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now),
|
byType.map((type) =>
|
||||||
|
this.compute(
|
||||||
|
type,
|
||||||
|
this.bestRule(rules.filter((r) => r.ruleType === type), item),
|
||||||
|
item,
|
||||||
|
now,
|
||||||
|
billingCurrency,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
@@ -16,9 +17,10 @@ export class WarehouseInspectionService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||||
private readonly filesService: FilesService,
|
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> {
|
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
||||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
||||||
@@ -29,8 +31,9 @@ export class WarehouseInspectionService {
|
|||||||
const expected = dto.expectedWeight ?? null;
|
const expected = dto.expectedWeight ?? null;
|
||||||
const actual = dto.actualWeight ?? null;
|
const actual = dto.actualWeight ?? null;
|
||||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : 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,
|
inventoryId,
|
||||||
bookingId: inventory.bookingId ?? null,
|
bookingId: inventory.bookingId ?? null,
|
||||||
reportType: dto.reportType,
|
reportType: dto.reportType,
|
||||||
@@ -46,18 +49,60 @@ export class WarehouseInspectionService {
|
|||||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||||
remarks: dto.remarks ?? null,
|
remarks: dto.remarks ?? null,
|
||||||
inspectedById: dto.inspectedById ?? 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.
|
// Mirror the latest outcome onto the inventory item so loading rules can read it.
|
||||||
await inventoryRepo.update(inventoryId, {
|
await inventoryRepo.update(inventoryId, {
|
||||||
inspectionStatus: dto.inspectionStatus,
|
inspectionStatus: dto.inspectionStatus,
|
||||||
inspectedAt: new Date(),
|
inspectedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dto.inspectionStatus === 'PASSED') {
|
||||||
|
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
|
||||||
|
}
|
||||||
|
|
||||||
return report;
|
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[]> {
|
async findByInventory(inventoryId: string): Promise<WarehouseInspectionReport[]> {
|
||||||
return this.inspectionRepository.findAll({
|
return this.inspectionRepository.findAll({
|
||||||
where: { inventoryId },
|
where: { inventoryId },
|
||||||
@@ -98,6 +143,9 @@ export class WarehouseInspectionService {
|
|||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(WarehouseInventory)
|
.getRepository(WarehouseInventory)
|
||||||
.update(report.inventoryId, { inspectionStatus: dto.inspectionStatus });
|
.update(report.inventoryId, { inspectionStatus: dto.inspectionStatus });
|
||||||
|
if (dto.inspectionStatus === 'PASSED') {
|
||||||
|
await this.markImportPickupReadyAndAcceptLastMile(report.inventoryId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
|
|||||||
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
@@ -142,6 +143,36 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.importUnloadedQueue();
|
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')
|
@Get('import/pickup-ready-queue')
|
||||||
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
||||||
importPickupReadyQueue() {
|
importPickupReadyQueue() {
|
||||||
@@ -226,6 +257,16 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.release(id, dto);
|
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')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import { WarehouseFeeService } from './warehouse-fee.service';
|
|||||||
interface GenerateOptions {
|
interface GenerateOptions {
|
||||||
confirmZero?: boolean;
|
confirmZero?: boolean;
|
||||||
performedBy?: string;
|
performedBy?: string;
|
||||||
|
billingCurrency?: 'ETB' | 'USD';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PayInvoiceDto {
|
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 isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||||
|
|
||||||
const items = previews
|
const items = previews
|
||||||
@@ -75,9 +77,9 @@ export class WarehouseInvoiceService {
|
|||||||
feeType,
|
feeType,
|
||||||
description:
|
description:
|
||||||
p.ruleType === 'STORAGE_FEE'
|
p.ruleType === 'STORAGE_FEE'
|
||||||
? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
|
? `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) after ${p.freeDays} free`,
|
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
|
||||||
quantity: p.chargeableDays,
|
quantity: p.billableUnits,
|
||||||
unitRate: p.ratePerDay,
|
unitRate: p.ratePerDay,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
currency: p.currency,
|
currency: p.currency,
|
||||||
@@ -98,7 +100,7 @@ export class WarehouseInvoiceService {
|
|||||||
const invoiceType: WarehouseInvoiceType =
|
const invoiceType: WarehouseInvoiceType =
|
||||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||||
|
|
||||||
const currency = items[0]?.currency ?? 'USD';
|
const currency = billingCurrency;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -79,7 +79,10 @@ export class WarehouseRulesController {
|
|||||||
|
|
||||||
@Get('warehouse-inventory/:id/fee-preview')
|
@Get('warehouse-inventory/:id/fee-preview')
|
||||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||||
feePreview(@Param('id', ParseUUIDPipe) id: string) {
|
feePreview(
|
||||||
return this.feeService.previewForInventory(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Query('billingCurrency') billingCurrency?: string,
|
||||||
|
) {
|
||||||
|
return this.feeService.previewForInventory(id, billingCurrency);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export class WarehouseYardsController {
|
|||||||
private readonly zonesService: WarehouseZonesService,
|
private readonly zonesService: WarehouseZonesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||||
|
findAll() {
|
||||||
|
return this.yardsService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export class WarehouseYardsService {
|
|||||||
private readonly warehousesService: WarehousesService,
|
private readonly warehousesService: WarehousesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
findAll(): Promise<WarehouseYard[]> {
|
||||||
|
return this.yardsRepository.findAll({
|
||||||
|
relations: { warehouse: true, zones: true },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||||
return this.yardsRepository.findAll({
|
return this.yardsRepository.findAll({
|
||||||
where: { warehouseId },
|
where: { warehouseId },
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
|||||||
export class WarehouseZonesController {
|
export class WarehouseZonesController {
|
||||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all warehouse zones' })
|
||||||
|
findAll() {
|
||||||
|
return this.zonesService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export class WarehouseZonesService {
|
|||||||
private readonly yardsService: WarehouseYardsService,
|
private readonly yardsService: WarehouseYardsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
findAll(): Promise<WarehouseZone[]> {
|
||||||
|
return this.zonesRepository.findAll({
|
||||||
|
relations: { yard: { warehouse: true } },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||||
return this.zonesRepository.findAll({
|
return this.zonesRepository.findAll({
|
||||||
where: { yardId },
|
where: { yardId },
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { FilesModule } from '../files/files.module';
|
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 { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||||
@@ -64,6 +69,13 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseFeeInvoiceItem,
|
WarehouseFeeInvoiceItem,
|
||||||
]),
|
]),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
|
InterchangeDocumentsModule,
|
||||||
|
LastMileModule,
|
||||||
|
ExchangeModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
|
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
WarehousesController,
|
WarehousesController,
|
||||||
@@ -100,6 +112,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseInvoiceService,
|
WarehouseInvoiceService,
|
||||||
WarehouseSchedulingAdapterService,
|
WarehouseSchedulingAdapterService,
|
||||||
SchedulingReadFacade,
|
SchedulingReadFacade,
|
||||||
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WarehousesService,
|
WarehousesService,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user