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_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# --- Notification broker (RabbitMQ) ---------------------------------------------
|
||||
# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service).
|
||||
# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker).
|
||||
RABBITMQ_ENABLED=false
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
SMS_QUEUE=sms_queue
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
},
|
||||
@@ -37,7 +38,7 @@
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -61,12 +61,12 @@ import { ContainersModule } from './modules/container-management/containers.modu
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { FacilitiesModule } from './modules/facilities/facilities.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -123,13 +123,13 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
ContainersModule,
|
||||
CargoesModule,
|
||||
RoutesModule,
|
||||
FacilitiesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
|
||||
@@ -7,13 +7,13 @@ export function deriveTradeDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
const originCountry = originYard.country?.trim().toLowerCase();
|
||||
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
if (originCountry === 'djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
|
||||
@@ -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. */
|
||||
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
return this.ordersRepository.findByContract(contractBookingId);
|
||||
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
const orders = await this.ordersRepository.findByContract(contractBookingId);
|
||||
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
|
||||
return orders;
|
||||
}
|
||||
|
||||
findById(id: string): Promise<BookingOrder | null> {
|
||||
return this.ordersRepository.findById(id);
|
||||
async findById(id: string): Promise<BookingOrder | null> {
|
||||
const order = await this.ordersRepository.findById(id);
|
||||
if (order) await this.syncOrderFromChild(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order is a ledger row; the spawned child ONE_TIME booking is what
|
||||
* actually moves through the workflow (clearance → marketing/ops accept →
|
||||
* pay → allocate), exactly like a one-time booking. Nothing writes the order
|
||||
* row after creation, so its stored status would stay 'PENDING' forever.
|
||||
*
|
||||
* Mirror the child onto the order whenever it is read: copy the child's
|
||||
* status, schedulingStatus and trainScheduleId onto the order (mutating the
|
||||
* in-memory instance the caller gets back), and persist that snapshot when it
|
||||
* has drifted so list/detail views and any stored reporting stay in sync.
|
||||
*/
|
||||
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
|
||||
const child = order.booking;
|
||||
if (!child) return;
|
||||
|
||||
const nextStatus = child.status;
|
||||
const nextScheduling = child.schedulingStatus;
|
||||
const nextTrainScheduleId = child.trainScheduleId ?? null;
|
||||
|
||||
const drifted =
|
||||
order.status !== nextStatus ||
|
||||
order.schedulingStatus !== nextScheduling ||
|
||||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
|
||||
|
||||
// Reflect the child onto the instance returned to the caller.
|
||||
order.status = nextStatus;
|
||||
order.schedulingStatus = nextScheduling;
|
||||
order.trainScheduleId = nextTrainScheduleId;
|
||||
|
||||
if (drifted) {
|
||||
await this.ordersRepository.update(order.id, {
|
||||
status: nextStatus,
|
||||
schedulingStatus: nextScheduling,
|
||||
trainScheduleId: nextTrainScheduleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +167,6 @@ export class BookingOrdersService {
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
|
||||
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
||||
// belong to. Validated for every order regardless of routing.
|
||||
@@ -142,43 +183,31 @@ export class BookingOrdersService {
|
||||
}
|
||||
}
|
||||
|
||||
if (routeLineId) {
|
||||
// Multi-route: validate against the chosen route line's remaining pool.
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
// The contract has a single shared drawdown pool (per container type for
|
||||
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
|
||||
// only fixed origin/destination/km above — so every order, routed or not,
|
||||
// validates each line against the same shared pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!;
|
||||
if (orderTotal > chosen.remainingQuantity) {
|
||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||
if (!poolLine) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
|
||||
isContainer
|
||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||
: 'This contract has no matching quantity pool',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Single-route: validate each line against the per-container-type pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||
if (!poolLine) {
|
||||
throw new BadRequestException(
|
||||
isContainer
|
||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||
: 'This contract has no matching quantity pool',
|
||||
);
|
||||
}
|
||||
if (line.quantity > poolLine.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||
);
|
||||
}
|
||||
if (line.quantity > poolLine.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,13 @@ export class ContractQuantityLineView {
|
||||
remainingQuantity!: number;
|
||||
}
|
||||
|
||||
/** A contracted/ordered/remaining pool line for one route of a general contract. */
|
||||
/**
|
||||
* A contracted route (lane) of a general contract. Routes are pure
|
||||
* origin→destination lanes the contract covers; they carry NO quantity. The
|
||||
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
|
||||
* and an order picks one lane (for scheduling/billing) while drawing from that
|
||||
* shared pool.
|
||||
*/
|
||||
export class ContractRouteLineView {
|
||||
@ApiProperty({ description: 'Contract route line id' })
|
||||
routeLineId!: string;
|
||||
@@ -39,21 +45,6 @@ export class ContractRouteLineView {
|
||||
@ApiProperty({ nullable: true })
|
||||
destinationYardName!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
||||
containerTypeId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
containerTypeName!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
contractedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
orderedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
remainingQuantity!: number;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
||||
km!: number | null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { BookingOrder } from './booking-order.entity';
|
||||
|
||||
/**
|
||||
* Postgres `numeric` columns are serialized to JS strings by the driver. This
|
||||
* transformer hydrates them back into real numbers so consumers (and the
|
||||
* `quantity: number` API type) don't have to coerce on every read.
|
||||
*/
|
||||
const numericColumn = {
|
||||
to: (value: number) => value,
|
||||
from: (value: string | null) => (value == null ? value : Number(value)),
|
||||
};
|
||||
|
||||
/**
|
||||
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
||||
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
||||
@@ -25,7 +35,7 @@ export class BookingOrderLine extends BaseEntity {
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
@@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity {
|
||||
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
||||
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
||||
*/
|
||||
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
@Column({
|
||||
name: 'hazardous_quantity',
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 3,
|
||||
default: 0,
|
||||
transformer: numericColumn,
|
||||
})
|
||||
hazardousQuantity!: number;
|
||||
|
||||
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
@Column({
|
||||
name: 'reefer_quantity',
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 3,
|
||||
default: 0,
|
||||
transformer: numericColumn,
|
||||
})
|
||||
reeferQuantity!: number;
|
||||
}
|
||||
|
||||
@@ -125,10 +125,12 @@ export class GeneralContractService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route drawdown pool for a multi-route general contract: contracted vs.
|
||||
* ordered vs. remaining, one entry per contracted route line. Returns [] for
|
||||
* single-route contracts (no route lines) — callers fall back to
|
||||
* {@link getQuantityLines}.
|
||||
* The contracted routes (lanes) of a multi-route general contract — pure
|
||||
* origin→destination pairs the contract covers. Routes carry NO quantity; the
|
||||
* contract draws from a single shared pool ({@link getQuantityLines}). An order
|
||||
* picks one lane (for scheduling + road billing) and draws from that pool.
|
||||
* Returns [] for single-route contracts (no route lines) — callers then use the
|
||||
* contract's own origin/destination.
|
||||
*/
|
||||
async getRouteLines(
|
||||
contractBookingId: string,
|
||||
@@ -140,52 +142,18 @@ export class GeneralContractService {
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
containerType: true,
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (routeLines.length === 0) return [];
|
||||
|
||||
const ordered = await this.orderedByRouteLine(contractBookingId);
|
||||
|
||||
return routeLines.map((rl) => {
|
||||
const orderedQty = ordered.get(rl.id) ?? 0;
|
||||
const contracted = Number(rl.quantity);
|
||||
return {
|
||||
routeLineId: rl.id,
|
||||
originYardId: rl.originYardId,
|
||||
originYardName: rl.originYard?.label ?? null,
|
||||
destinationYardId: rl.destinationYardId,
|
||||
destinationYardName: rl.destinationYard?.label ?? null,
|
||||
containerTypeId: rl.containerTypeId ?? null,
|
||||
containerTypeName: rl.containerType?.label ?? null,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
km: rl.km != null ? Number(rl.km) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order quantities, keyed by route_line_id. */
|
||||
private async orderedByRouteLine(
|
||||
contractBookingId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingOrder)
|
||||
.createQueryBuilder('o')
|
||||
.innerJoin('o.lines', 'line')
|
||||
.select('o.route_line_id', 'key')
|
||||
.addSelect('SUM(line.quantity)', 'total')
|
||||
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
||||
.andWhere('o.route_line_id IS NOT NULL')
|
||||
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
||||
.groupBy('o.route_line_id')
|
||||
.getRawMany<{ key: string; total: string }>();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) if (row.key) map.set(row.key, Number(row.total));
|
||||
return map;
|
||||
return routeLines.map((rl) => ({
|
||||
routeLineId: rl.id,
|
||||
originYardId: rl.originYardId,
|
||||
originYardName: rl.originYard?.label ?? null,
|
||||
destinationYardId: rl.destinationYardId,
|
||||
destinationYardName: rl.destinationYard?.label ?? null,
|
||||
km: rl.km != null ? Number(rl.km) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||
@@ -221,14 +189,12 @@ export class GeneralContractService {
|
||||
return line?.remainingQuantity ?? 0;
|
||||
}
|
||||
|
||||
/** True once every contracted line is fully drawn down. */
|
||||
/**
|
||||
* True once the contract's shared pool is fully drawn down. Routes are pure
|
||||
* lanes with no quantity, so exhaustion is purely a function of the shared
|
||||
* per-container-type (or bulk) pool, regardless of how many routes exist.
|
||||
*/
|
||||
async isExhausted(contractBookingId: string): Promise<boolean> {
|
||||
// Multi-route contracts are exhausted when every route line is drawn down;
|
||||
// single-route contracts fall back to the per-container-type pool.
|
||||
const routeLines = await this.getRouteLines(contractBookingId);
|
||||
if (routeLines.length > 0) {
|
||||
return routeLines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
return lines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
|
||||
@@ -276,6 +276,12 @@ export class BookingPricingService {
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
totalWagons,
|
||||
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||||
// Container freight carries 0 here — its surcharges scale by container count.
|
||||
bulkTons:
|
||||
booking.freightType === 'BULK'
|
||||
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||||
: 0,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
|
||||
it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => {
|
||||
const { service, bookingsRepository } = makeService([
|
||||
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
||||
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
|
||||
@@ -75,3 +75,165 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Customs bookings additionally require the GL output documents before
|
||||
* finalizing — they are cleared by Global Logistics, not the customer alone.
|
||||
*/
|
||||
describe('BookingTransitionService — finalizeClearance customs output gate', () => {
|
||||
const customsBooking = {
|
||||
id: 'b-2',
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
serviceType: { includesCustoms: true }, // input + output sets apply
|
||||
};
|
||||
|
||||
const inputSetting = {
|
||||
code: 'clearance_import_container_with_customs',
|
||||
fields: [{ fileKey: 'commercial_invoice', isRequired: true }],
|
||||
};
|
||||
const outputSetting = {
|
||||
code: 'clearance_output_import_container',
|
||||
fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }],
|
||||
};
|
||||
|
||||
function makeCustomsService(uploadedOutputCodes: string[]) {
|
||||
const bookingsRepository = {
|
||||
findDocumentReviews: jest.fn().mockResolvedValue([
|
||||
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
||||
]),
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-2' }),
|
||||
};
|
||||
const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) };
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn((code: string) =>
|
||||
Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting),
|
||||
),
|
||||
};
|
||||
const filesService = {
|
||||
findByResource: jest
|
||||
.fn()
|
||||
.mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
|
||||
it('rejects when required customs output documents are missing', async () => {
|
||||
const { service } = makeCustomsService([]); // no output uploaded
|
||||
await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => {
|
||||
const { service, bookingsRepository } = makeCustomsService(['im4']);
|
||||
await service.finalizeClearance('b-2');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-2',
|
||||
expect.objectContaining({ status: 'CLEARANCE_READY' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The first clearance submission (AWAITING_DOCUMENTS) must include every
|
||||
* required input document; subsequent re-uploads during review only need the
|
||||
* specific files being fixed, so already-uploaded required docs stay in place.
|
||||
*/
|
||||
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
||||
const inputSetting = {
|
||||
code: 'clearance_import_container_without_customs',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
||||
],
|
||||
};
|
||||
|
||||
function makeService(status: string, existingCodes: string[]) {
|
||||
const bookingsRepository = {
|
||||
upsertDocumentReviewPending: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-3' }),
|
||||
};
|
||||
const booking = {
|
||||
id: 'b-3',
|
||||
status,
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
serviceType: { includesCustoms: false },
|
||||
};
|
||||
const bookingsService = { findById: jest.fn().mockResolvedValue(booking) };
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn().mockResolvedValue(inputSetting),
|
||||
};
|
||||
const filesService = {
|
||||
findByResource: jest
|
||||
.fn()
|
||||
.mockResolvedValue(existingCodes.map((code) => ({ code }))),
|
||||
upsertByCode: jest.fn().mockResolvedValue({ id: 'file-rec' }),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
function fakeFile(fieldname: string): Express.Multer.File {
|
||||
return { fieldname, originalname: `${fieldname}.pdf` } as Express.Multer.File;
|
||||
}
|
||||
|
||||
it('rejects the first submission when a required document is missing', async () => {
|
||||
const { service } = makeService('AWAITING_DOCUMENTS', []);
|
||||
await expect(
|
||||
service.submitClearanceDocuments('b-3', [fakeFile('commercial_invoice')]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts the first submission when every required document is provided', async () => {
|
||||
const { service, bookingsRepository } = makeService('AWAITING_DOCUMENTS', []);
|
||||
await service.submitClearanceDocuments('b-3', [
|
||||
fakeFile('commercial_invoice'),
|
||||
fakeFile('packing_list'),
|
||||
]);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-3',
|
||||
expect.objectContaining({ status: 'DOCUMENTS_UNDER_REVIEW' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows re-uploading a single queried document during review without re-sending the rest', async () => {
|
||||
// packing_list was already uploaded in the first round; the customer is now
|
||||
// only re-uploading the queried commercial_invoice.
|
||||
const { service, bookingsRepository } = makeService(
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
['packing_list'],
|
||||
);
|
||||
await service.submitClearanceDocuments('b-3', [
|
||||
fakeFile('commercial_invoice'),
|
||||
]);
|
||||
// Only the re-uploaded doc is touched — no full re-gate, no rework on the rest.
|
||||
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.upsertDocumentReviewPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fileKey: 'commercial_invoice' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -645,6 +645,14 @@ export class BookingTransitionService {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
|
||||
// First submission (nothing in review yet): every required input field must
|
||||
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
|
||||
// is only fixing queried/pending docs, so the already-uploaded required docs
|
||||
// stay in place and we don't re-gate on the full required set.
|
||||
if (booking.status === 'AWAITING_DOCUMENTS') {
|
||||
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const record = await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
@@ -670,6 +678,41 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for the first clearance submission: every required field of the
|
||||
* booking's customer-input set must be covered, either by a file already on
|
||||
* the booking or by one in this upload batch. Keeps the customer from starting
|
||||
* review with required documents missing.
|
||||
*/
|
||||
private async assertRequiredInputsPresent(
|
||||
bookingId: string,
|
||||
inputCode: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return; // setting not seeded — nothing to enforce
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return;
|
||||
|
||||
const existing = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const presentKeys = new Set<string>([
|
||||
...existing.map((f) => f.code),
|
||||
...files.map((f) => f.fieldname),
|
||||
]);
|
||||
|
||||
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
||||
if (missing.length > 0) {
|
||||
const labels = missing.map((f) => f.fileLabel).join(', ');
|
||||
throw new BadRequestException(
|
||||
`Please upload all required documents before submitting: ${labels}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
|
||||
async reviewDocument(
|
||||
bookingId: string,
|
||||
@@ -795,6 +838,20 @@ export class BookingTransitionService {
|
||||
throw new BadRequestException('A valid schedule date is required');
|
||||
}
|
||||
|
||||
// The binding shipment day must have at least one OPEN departure on the
|
||||
// route — only schedule-backed days are selectable. The batch engine
|
||||
// assigns the specific train within that (route, day) pool later.
|
||||
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
scheduledDate: date,
|
||||
|
||||
@@ -134,6 +134,12 @@ export class BookingsController {
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
|
||||
// to the customs document-clearance queue only and never sees the general
|
||||
// booking-request list.
|
||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) {
|
||||
return this.bookingsService.findClearanceQueue(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
const companyId =
|
||||
@@ -236,8 +242,12 @@ export class BookingsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// Staff see any booking; customers only their own company's.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
// Staff see any booking; Global Logistics (clearance:view) may inspect any
|
||||
// booking for the clearance gate; customers only their own company's.
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface BookingListFilterOptions {
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
excludePaymentStatus?: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
consolidationPaired?: string;
|
||||
@@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
excludePaymentStatus: options.excludePaymentStatus,
|
||||
});
|
||||
}
|
||||
if (options.customsClearingEnabled !== undefined) {
|
||||
qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', {
|
||||
customsClearingEnabled: options.customsClearingEnabled,
|
||||
});
|
||||
}
|
||||
if (options.consolidationPaired === 'true') {
|
||||
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
||||
} else if (options.consolidationPaired === 'false') {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
@@ -119,6 +120,18 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/** Build evaluation input from booking freight shape. */
|
||||
/**
|
||||
* Whether a service type bundles customs clearance. This is the single source
|
||||
* of truth for a booking's `customsClearingEnabled` — the customer cannot
|
||||
* diverge from it, and it decides who clears the documents (GL vs Marketing).
|
||||
*/
|
||||
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
|
||||
const serviceType = await this.dataSource
|
||||
.getRepository(ServiceType)
|
||||
.findOne({ where: { id: serviceTypeId } });
|
||||
return serviceType?.includesCustoms ?? false;
|
||||
}
|
||||
|
||||
private async buildEvalInput(dto: {
|
||||
freightType: FreightType;
|
||||
cargoTypeId?: string | null;
|
||||
@@ -126,8 +139,10 @@ export class BookingsService {
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
isGovernment?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
bulkTons?: number;
|
||||
containers: CreateBookingContainerDto[];
|
||||
}): Promise<BookingEvaluationInput> {
|
||||
const containerLines =
|
||||
@@ -166,10 +181,14 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
// Bulk reefer comes from the customer toggle; container reefer is derived
|
||||
// from the container type and ORed in by the engine.
|
||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||
isGovernment: dto.isGovernment ?? false,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
totalWagons,
|
||||
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
@@ -317,12 +336,14 @@ export class BookingsService {
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
} else if (!isGeneralContract) {
|
||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||
// train later. General contracts skip this — they have no shipment date at
|
||||
// creation; each drawdown order validates its own day.
|
||||
const day = eatDay(new Date(dto.scheduledDate!));
|
||||
} else if (dto.scheduledDate) {
|
||||
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
||||
// directly). Require that the route has at least one OPEN departure on
|
||||
// that EAT day. The booking wizard does NOT send scheduledDate at creation
|
||||
// — it captures a non-binding estimatedShipmentDate instead, and the
|
||||
// binding day is chosen later at the operation-request step. General
|
||||
// contracts also skip this (each drawdown order validates its own day).
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
dto.originYardId,
|
||||
@@ -371,6 +392,17 @@ export class BookingsService {
|
||||
tradeDirection,
|
||||
fallbackType,
|
||||
);
|
||||
|
||||
// A customer booking under their own account may only do so once the
|
||||
// resolved operational profile has been approved by the backoffice. Staff-
|
||||
// and government-initiated bookings (companyId supplied explicitly) bypass
|
||||
// this gate.
|
||||
const customerSelfBooking = !dto.companyId && !!userId;
|
||||
if (customerSelfBooking && companyProfileId) {
|
||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const needsConsolidation =
|
||||
@@ -385,8 +417,10 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
isReefer: dto.isReefer,
|
||||
isGovernment,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
bulkTons: dto.cargoTotalWeightVgm,
|
||||
containers,
|
||||
});
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
@@ -394,6 +428,11 @@ export class BookingsService {
|
||||
|
||||
warnings.push(...ruleResult.warnings);
|
||||
|
||||
// Customs clearing is owned by the service type, not the customer: when the
|
||||
// service includes customs, EDR/GL clears it (no external agent); otherwise
|
||||
// the customer clears it themselves and may name their broker.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId: companyId ?? null,
|
||||
@@ -411,8 +450,8 @@ export class BookingsService {
|
||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
customsClearingEnabled: dto.customsClearingEnabled ?? false,
|
||||
customsClearingAgent: dto.customsClearingAgent ?? null,
|
||||
customsClearingEnabled: includesCustoms,
|
||||
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||
equipmentReturn: dto.equipmentReturn,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
@@ -423,11 +462,18 @@ export class BookingsService {
|
||||
shippingLineId: dto.shippingLineId,
|
||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
// Bulk reefer is the customer's toggle; container reefer is derived from
|
||||
// the container type at pricing time, so the booking-level flag stays off
|
||||
// for container freight to avoid double-counting.
|
||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
: null,
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
status: 'DRAFT',
|
||||
@@ -450,8 +496,11 @@ export class BookingsService {
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
// Multi-route general contracts: persist the contracted routes + quantities.
|
||||
// Each drawdown order later draws from one of these route lines.
|
||||
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
||||
// carry NO quantity — the contract has a single shared pool (the cargo-step
|
||||
// total / container quantities). Each drawdown order picks one lane for
|
||||
// scheduling + road billing and draws from that shared pool. `quantity` on the
|
||||
// route line is retained for legacy rows but is no longer meaningful (0).
|
||||
if (isGeneralContract && dto.routes?.length) {
|
||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||
await routeRepo.save(
|
||||
@@ -460,9 +509,8 @@ export class BookingsService {
|
||||
contractBookingId: booking.id,
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
containerTypeId:
|
||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
||||
quantity: r.quantity,
|
||||
containerTypeId: null,
|
||||
quantity: 0,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
@@ -480,7 +528,11 @@ export class BookingsService {
|
||||
// Reuse the booking profile's onboarding documents instead of asking the
|
||||
// customer to re-upload. Snapshot them onto the booking now (by reference),
|
||||
// so a later active-profile switch never changes this booking's documents.
|
||||
if (companyProfileId) {
|
||||
//
|
||||
// Skip this when the customer uploaded documents for this booking — those
|
||||
// per-booking files take precedence, so auto-attaching the profile snapshots
|
||||
// would create duplicates.
|
||||
if (companyProfileId && files.length === 0) {
|
||||
try {
|
||||
const onboardingFiles =
|
||||
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
|
||||
@@ -577,7 +629,9 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||
containers,
|
||||
});
|
||||
|
||||
@@ -597,6 +651,12 @@ export class BookingsService {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
// Booking-level reefer is only meaningful for bulk; container reefer is
|
||||
// derived from the container type at pricing time.
|
||||
isReefer:
|
||||
freightType === 'BULK'
|
||||
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||
: false,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
@@ -617,10 +677,22 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.estimatedShipmentDate)
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||
delete updates.containers;
|
||||
|
||||
// Customs clearing always mirrors the (possibly changed) service type — never
|
||||
// the client payload — so it can't diverge from the service's customs scope.
|
||||
const includesCustoms = await this.resolveIncludesCustoms(
|
||||
dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
);
|
||||
updates.customsClearingEnabled = includesCustoms;
|
||||
updates.customsClearingAgent = includesCustoms
|
||||
? null
|
||||
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
|
||||
|
||||
await this.bookingsRepository.update(id, updates);
|
||||
|
||||
if (freightType === 'CONTAINER' && dto.containers) {
|
||||
@@ -692,6 +764,23 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/** Return a paginated list of bookings matching the filter. */
|
||||
/**
|
||||
* Whether a route has at least one OPEN train departure on the given EAT day.
|
||||
* Used to validate the binding shipment day chosen at the operation-request
|
||||
* step (only days with a schedule are selectable).
|
||||
*/
|
||||
async hasOpenDepartureOnDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<boolean> {
|
||||
return this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
@@ -738,6 +827,48 @@ export class BookingsService {
|
||||
'AWAITING_PAYMENT',
|
||||
];
|
||||
|
||||
/**
|
||||
* Booking statuses that belong to the customs document-clearance queue. The
|
||||
* Global Logistics role is scoped to ONLY these — it never sees the general
|
||||
* booking-request list.
|
||||
*/
|
||||
private static readonly CLEARANCE_STATUSES = [
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
];
|
||||
|
||||
/**
|
||||
* List bookings in the customs document-clearance queue. Used by Global
|
||||
* Logistics (clearance:view) which has no general bookings:view — so the
|
||||
* status set is force-scoped to clearance statuses and can't be widened to
|
||||
* arbitrary bookings by a caller-supplied status filter.
|
||||
*/
|
||||
async findClearanceQueue(
|
||||
filter: FilterBookingDto,
|
||||
): Promise<PaginatedBookings> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 100;
|
||||
// Honour a caller status filter only if it's within the clearance set;
|
||||
// otherwise fall back to the full clearance status list.
|
||||
const requested = filter.status;
|
||||
const statuses =
|
||||
requested && BookingsService.CLEARANCE_STATUSES.includes(requested)
|
||||
? [requested]
|
||||
: BookingsService.CLEARANCE_STATUSES;
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page,
|
||||
pageSize,
|
||||
statuses,
|
||||
// Global Logistics only clears customs bookings; non-customs clearance is
|
||||
// reviewed by Marketing from the booking detail, not this queue.
|
||||
customsClearingEnabled: true,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List the current customer's bookings that are ready for payment:
|
||||
* payable status AND not yet PAID. Company scope is derived from the
|
||||
|
||||
@@ -55,6 +55,12 @@ export class CreateBookingContainerDto {
|
||||
vgmPerUnitTons!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contracted route (lane) of a general contract — a pure origin→destination
|
||||
* pair the contract covers. Routes carry NO quantity; the contract draws from a
|
||||
* single shared pool (the container quantities / bulk total on the booking). An
|
||||
* order picks one lane (for scheduling + road billing) and draws from that pool.
|
||||
*/
|
||||
export class CreateContractRouteDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||
@IsUUID()
|
||||
@@ -64,20 +70,6 @@ export class CreateContractRouteDto {
|
||||
@IsUUID()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Container type for CONTAINER contracts; omit for BULK',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Road distance (km) for this route; used to bill road orders.',
|
||||
minimum: 0,
|
||||
@@ -155,14 +147,24 @@ export class CreateBookingDto {
|
||||
bookingType?: string;
|
||||
|
||||
/**
|
||||
* The day the customer wants to ship (the pool day key). Required for one-time
|
||||
* bookings; omitted for general contracts, which pick the date per order.
|
||||
* The BINDING shipment day (the pool day key), validated against open train
|
||||
* departures. Set later at the operation-request step — NOT at booking
|
||||
* creation. Optional here; staff may still pin it directly.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledDate?: string;
|
||||
|
||||
/**
|
||||
* Non-binding shipment-date estimate captured in the booking wizard. Purely
|
||||
* informational — NOT validated against train departures.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
estimatedShipmentDate?: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_TYPES })
|
||||
@IsIn([...CONTRACT_TYPES])
|
||||
contractType!: string;
|
||||
@@ -296,6 +298,17 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
/**
|
||||
* Booking-level refrigerated flag. For bulk freight this is the customer's
|
||||
* reefer choice (containers derive reefer from the container type instead).
|
||||
* ORed with per-container reefer when the REEFER surcharge is evaluated.
|
||||
*/
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -155,10 +155,23 @@ export class Booking extends BaseEntity {
|
||||
/**
|
||||
* Nullable: general contracts have no shipment date at creation — the date is
|
||||
* chosen per drawdown order. One-time bookings always set this (the pool day key).
|
||||
*
|
||||
* NOTE: this is the BINDING shipment day, validated against actual open train
|
||||
* departures. It is set later, when the customer requests the operation — NOT
|
||||
* at booking creation. See estimatedShipmentDate for the non-binding estimate
|
||||
* captured in the booking wizard.
|
||||
*/
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
/**
|
||||
* Non-binding shipment-date estimate captured in the booking wizard. Purely
|
||||
* informational — NOT validated against train departures. The binding
|
||||
* scheduledDate is chosen later at the operation-request step.
|
||||
*/
|
||||
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
|
||||
estimatedShipmentDate?: Date | null;
|
||||
|
||||
/**
|
||||
* General contracts only: when the ordering window closes, computed from the
|
||||
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
||||
|
||||
@@ -41,6 +41,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
@@ -226,6 +227,17 @@ export class CompaniesController {
|
||||
await this.companiesService.setOnboardingStep(user.id, dto.step);
|
||||
}
|
||||
|
||||
@Get("onboarding/requirements")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
|
||||
})
|
||||
async getOnboardingRequirements(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<OnboardingRequirementsResponseDto> {
|
||||
return this.companiesService.getOnboardingRequirements(user.id);
|
||||
}
|
||||
|
||||
@Post("onboarding/complete")
|
||||
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
|
||||
async completeOnboarding(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { CompaniesController } from "./companies.controller";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
@@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
HttpModule,
|
||||
FilesModule,
|
||||
FileUploadSettingsModule,
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
@@ -12,7 +13,10 @@ import {
|
||||
DashboardScope,
|
||||
} from "./company-dashboard.repository";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
@@ -53,9 +57,67 @@ export class CompaniesService {
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Required company-information fields that must be filled before onboarding can
|
||||
* be submitted. The backend owns this list so the portal never has to know
|
||||
* which fields are mandatory — it just renders what's reported outstanding.
|
||||
* `get` reads the value from the company (some live in the attributes blob).
|
||||
*/
|
||||
private readonly REQUIRED_COMPANY_INFO: {
|
||||
key: string;
|
||||
label: string;
|
||||
get: (company: Company) => unknown;
|
||||
}[] = [
|
||||
{
|
||||
key: "tinNumber",
|
||||
label: "Company TIN",
|
||||
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
||||
},
|
||||
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
|
||||
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
|
||||
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
||||
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
||||
{
|
||||
key: "contactPersonName",
|
||||
label: "Contact person name",
|
||||
get: (c) => c.attributes?.contactPersonName,
|
||||
},
|
||||
{
|
||||
key: "contactPersonPhone",
|
||||
label: "Contact person phone",
|
||||
get: (c) => c.attributes?.contactPersonPhone,
|
||||
},
|
||||
{
|
||||
key: "generalManagerName",
|
||||
label: "General manager name",
|
||||
get: (c) => c.attributes?.generalManagerName,
|
||||
},
|
||||
{
|
||||
key: "generalManagerEmail",
|
||||
label: "General manager email",
|
||||
get: (c) => c.attributes?.generalManagerEmail,
|
||||
},
|
||||
{
|
||||
key: "generalManagerPhone",
|
||||
label: "General manager phone",
|
||||
get: (c) => c.attributes?.generalManagerPhone,
|
||||
},
|
||||
];
|
||||
|
||||
/** The nationality-based document setting code for a company. */
|
||||
private documentSettingCodeFor(
|
||||
nationality: CompanyNationality | null | undefined,
|
||||
): string {
|
||||
return nationality === CompanyNationality.Foreign
|
||||
? "company_onboarding_documents_foreign"
|
||||
: "company_onboarding_documents_ethiopian";
|
||||
}
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
@@ -77,10 +139,12 @@ export class CompaniesService {
|
||||
}
|
||||
}
|
||||
|
||||
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||
const existingProfile = await this.profilesRepo.findByUserId(
|
||||
identity.userId,
|
||||
);
|
||||
if (existingProfile) {
|
||||
throw new ConflictException(
|
||||
`Profile with email ${identity.email} already exists`,
|
||||
`Profile for user ${identity.userId} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,8 +178,6 @@ export class CompaniesService {
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
activeProfileType,
|
||||
@@ -134,15 +196,13 @@ export class CompaniesService {
|
||||
input.type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const reference = await this.companyProfilesRepo.generateReference(
|
||||
input.type,
|
||||
);
|
||||
// No reference yet — these profiles await backoffice approval, which
|
||||
// is when the reference is minted (see setCompanyProfileStatus).
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId: company.id,
|
||||
type: input.type,
|
||||
reference,
|
||||
businessLicense: input.businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||
@@ -191,15 +251,6 @@ export class CompaniesService {
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
// A profile may exist for the same email under a different IAM id — block
|
||||
// duplicates as the final create does.
|
||||
const byEmail = await this.profilesRepo.findByEmail(identity.email);
|
||||
if (byEmail) {
|
||||
throw new ConflictException(
|
||||
`Profile with email ${identity.email} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const activeProfileType =
|
||||
@@ -224,8 +275,6 @@ export class CompaniesService {
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: normalizeE164(identity.phone) ?? identity.phone,
|
||||
isPrimaryContact: true,
|
||||
activeProfileType,
|
||||
onboardingStep: "company",
|
||||
@@ -251,12 +300,11 @@ export class CompaniesService {
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -527,6 +575,8 @@ export class CompaniesService {
|
||||
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
||||
if (dto.contactPersonPhone !== undefined)
|
||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||
if (dto.contactVerifiedPhone !== undefined)
|
||||
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
|
||||
if (dto.generalManagerName !== undefined)
|
||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined)
|
||||
@@ -576,10 +626,10 @@ export class CompaniesService {
|
||||
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
||||
await this.findCompanyById(dto.companyId);
|
||||
|
||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||
const existing = await this.profilesRepo.findByUserId(dto.userId);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Profile with email ${dto.email} already exists`,
|
||||
`Profile for user ${dto.userId} already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -622,12 +672,33 @@ export class CompaniesService {
|
||||
profileId: string,
|
||||
status: ProfileStatus,
|
||||
): Promise<CompanyProfile> {
|
||||
const updated = await this.companyProfilesRepo.updateStatus(
|
||||
profileId,
|
||||
status,
|
||||
);
|
||||
const existing = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!existing)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
if (status === ProfileStatus.Active && !existing.reference) {
|
||||
patch.reference = await this.companyProfilesRepo.generateReference(
|
||||
existing.type,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// Approving any profile promotes a pending company to active, so the
|
||||
// customer can start working as soon as their first profile is cleared.
|
||||
if (status === ProfileStatus.Active) {
|
||||
const company = await this.companiesRepo.findById(updated.companyId);
|
||||
if (company && company.status === CompanyStatus.Pending) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -649,7 +720,7 @@ export class CompaniesService {
|
||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Company already has a ${type} profile (${existing.reference})`,
|
||||
`Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -813,6 +884,100 @@ export class CompaniesService {
|
||||
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-driven onboarding requirements for the current user's company.
|
||||
*
|
||||
* The backend resolves the nationality-based document set, checks which
|
||||
* company documents and per-profile licenses are already uploaded, and reports
|
||||
* exactly what is still outstanding. The portal renders this list verbatim and
|
||||
* relies on `isComplete` to decide when to auto-finish — it never decides for
|
||||
* itself which documents apply or which fields are mandatory.
|
||||
*/
|
||||
async getOnboardingRequirements(
|
||||
userId: string,
|
||||
): Promise<OnboardingRequirementsResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
// 1. Required company-information fields.
|
||||
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
|
||||
(f) => !f.get(company),
|
||||
).map((f) => ({ key: f.key, label: f.label }));
|
||||
|
||||
// 2. Nationality-based company documents + which are already uploaded.
|
||||
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
|
||||
const [setting, uploadedFiles] = await Promise.all([
|
||||
this.fileUploadSettingsService
|
||||
.getByCode(documentSettingCode)
|
||||
.catch(() => null),
|
||||
this.filesService.findByResource(company.id, "companies"),
|
||||
]);
|
||||
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
||||
const documents = (setting?.fields ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map((f) => ({
|
||||
fileKey: f.fileKey,
|
||||
fileLabel: f.fileLabel,
|
||||
helpText: f.helpText ?? null,
|
||||
isRequired: f.isRequired,
|
||||
isMultiple: f.isMultiple,
|
||||
maxFiles: f.maxFiles,
|
||||
allowedExtensions: f.allowedExtensions,
|
||||
maxSizeMb: f.maxSizeMb,
|
||||
displayOrder: f.displayOrder,
|
||||
uploaded: uploadedCodes.has(f.fileKey),
|
||||
}));
|
||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||
|
||||
// 3. Per-operational-profile business licenses.
|
||||
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
|
||||
profileId: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
|
||||
}));
|
||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
const outstanding = [
|
||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||
...missingLicenses.map(
|
||||
(p) =>
|
||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||
),
|
||||
];
|
||||
|
||||
// Progress spans every required item the user has to satisfy: company-info
|
||||
// fields, required documents and one license per operational profile.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const total =
|
||||
this.REQUIRED_COMPANY_INFO.length +
|
||||
requiredDocCount +
|
||||
licenseProfiles.length;
|
||||
const completed =
|
||||
total -
|
||||
(missingInfo.length + missingDocs.length + missingLicenses.length);
|
||||
|
||||
return new OnboardingRequirementsResponseDto({
|
||||
documentSettingCode,
|
||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
||||
documents,
|
||||
licenseProfiles,
|
||||
progress: { completed, total },
|
||||
isComplete: outstanding.length === 0,
|
||||
onboardingCompleted: profile.onboardingCompleted,
|
||||
outstanding,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit onboarding for review. Validation is delegated entirely to
|
||||
* getOnboardingRequirements (the same source of truth the portal renders), so
|
||||
* the gate can never drift from what the UI shows. On success the company and
|
||||
* all its operational profiles move to PENDING — the backoffice approves each
|
||||
* profile before it can be used (see setCompanyProfileStatus).
|
||||
*/
|
||||
async markOnboardingComplete(
|
||||
userId: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
@@ -821,23 +986,21 @@ export class CompaniesService {
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
|
||||
// Guard against finishing on a still-draft company (TIN never filled in).
|
||||
if (!company.tin || company.tin.startsWith("D")) {
|
||||
const requirements = await this.getOnboardingRequirements(userId);
|
||||
if (!requirements.isComplete) {
|
||||
throw new BadRequestException(
|
||||
"Company information is incomplete — please fill in your company details before finishing.",
|
||||
requirements.outstanding[0] ??
|
||||
"Your onboarding is incomplete. Please complete all required steps before submitting.",
|
||||
);
|
||||
}
|
||||
|
||||
// Every operational profile must have at least one business-license file
|
||||
// (stored directly on the profile).
|
||||
// Send every operational profile in for approval; the company itself becomes
|
||||
// active once the backoffice approves at least one profile.
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
for (const cp of profiles) {
|
||||
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
|
||||
);
|
||||
if (cp.status !== ProfileStatus.Pending) {
|
||||
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +1015,25 @@ export class CompaniesService {
|
||||
return this.getCompanyInfoByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a customer from booking under a profile that isn't approved yet.
|
||||
* Called from the booking-create path for self-service bookings; staff- and
|
||||
* government-initiated bookings bypass this. No-op when the profile can't be
|
||||
* found (defensive — resolution is best-effort upstream).
|
||||
*/
|
||||
async assertCompanyProfileApprovedForBooking(
|
||||
companyProfileId: string,
|
||||
): Promise<void> {
|
||||
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||
if (!profile) return;
|
||||
if (profile.status !== ProfileStatus.Active) {
|
||||
const role = profile.type.replace(/_/g, " ");
|
||||
throw new ForbiddenException(
|
||||
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize and resolve a company_profile that must belong to the current
|
||||
* user's company — used before accepting/returning its license files.
|
||||
|
||||
@@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
|
||||
}
|
||||
|
||||
async generateReference(type: ProfileType): Promise<string> {
|
||||
const seqName = SEQUENCE_MAP[type];
|
||||
// The sequences live in the same schema as the entity (e.g. "freight"), but
|
||||
// the connection's search_path is "public" — so the sequence MUST be
|
||||
// schema-qualified or `nextval` fails with "relation does not exist".
|
||||
const schema = this.repository.metadata.schema ?? "public";
|
||||
const seqName = `"${schema}".${SEQUENCE_MAP[type]}`;
|
||||
const result = await this.repository.query(
|
||||
`SELECT nextval('${seqName}') AS next_id`,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||
import { IsString, IsNotEmpty, IsOptional, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateExternalProfileDto {
|
||||
@IsUUID()
|
||||
@@ -20,16 +19,6 @@ export class CreateExternalProfileDto {
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
|
||||
@@ -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;
|
||||
contactPersonEmail: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
|
||||
contactVerifiedPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
@@ -81,6 +83,7 @@ export class ProfileResponseDto {
|
||||
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
|
||||
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
|
||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||
|
||||
@@ -28,7 +28,7 @@ export class ResponseCompanyProfileDto {
|
||||
this.id = profile.id;
|
||||
this.companyId = profile.companyId;
|
||||
this.type = profile.type;
|
||||
this.reference = profile.reference;
|
||||
this.reference = profile.reference ?? '';
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||
|
||||
@@ -10,8 +10,6 @@ export class ResponseExternalProfileDto {
|
||||
companyId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
@@ -34,8 +32,6 @@ export class ResponseExternalProfileDto {
|
||||
this.companyId = profile.companyId;
|
||||
this.firstName = profile.firstName;
|
||||
this.lastName = profile.lastName;
|
||||
this.email = profile.email;
|
||||
this.phone = profile.phone;
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
|
||||
@@ -67,6 +67,16 @@ export class UpdateProfileDto {
|
||||
@IsValidPhone()
|
||||
contactPersonPhone?: string;
|
||||
|
||||
/**
|
||||
* The contact-person phone that completed SMS OTP verification. Persisted so
|
||||
* the onboarding "verify" step can resume its "done" state after a refresh
|
||||
* (compared against the current contactPersonPhone on the client).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
contactVerifiedPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generalManagerName?: string;
|
||||
|
||||
@@ -40,14 +40,19 @@ export class CompanyProfile extends BaseEntity {
|
||||
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
|
||||
type!: ProfileType;
|
||||
|
||||
/**
|
||||
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
|
||||
* is approved (status → Active); pending/unapproved profiles carry NULL.
|
||||
* The unique index tolerates this because Postgres treats NULLs as distinct.
|
||||
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
|
||||
*/
|
||||
@Column({
|
||||
name: "reference",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
nullable: false,
|
||||
unique: true,
|
||||
nullable: true,
|
||||
})
|
||||
reference!: string;
|
||||
reference!: string | null;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
|
||||
@@ -23,12 +23,6 @@ export class ExternalProfile extends BaseEntity {
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
||||
nationalId?: string | null;
|
||||
|
||||
|
||||
@@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
|
||||
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
||||
return this.repository.find({ where: { companyId } as any });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<ExternalProfile | null> {
|
||||
return this.repository.findOne({ where: { email } as any });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export class FirstMileController {
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
||||
acceptBooking(@Param('reference') reference: string) {
|
||||
return this.firstMileService.acceptBooking(reference);
|
||||
return this.firstMileService.acceptBookingByReference(reference);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
import { FirstMile } from './entities/first-mile.entity';
|
||||
import { FirstMileController } from './first-mile.controller';
|
||||
import { FirstMileRepository } from './first-mile.repository';
|
||||
import { FirstMileService } from './first-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FirstMile]),
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [FirstMileController],
|
||||
providers: [FirstMileRepository, FirstMileService],
|
||||
exports: [FirstMileRepository, FirstMileService],
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
|
||||
@@ -26,9 +29,14 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
|
||||
|
||||
@Injectable()
|
||||
export class FirstMileService {
|
||||
private readonly logger = new Logger(FirstMileService.name);
|
||||
|
||||
constructor(
|
||||
private readonly firstMileRepository: FirstMileRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -36,8 +44,8 @@ export class FirstMileService {
|
||||
* paid before any first-mile work proceeds. Throws if the reference is
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
@@ -53,6 +61,22 @@ export class FirstMileService {
|
||||
});
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: 0,
|
||||
});
|
||||
}
|
||||
async findAll(filter: FirstMileListFilter = {}): Promise<{
|
||||
data: FirstMile[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
@@ -109,7 +133,7 @@ export class FirstMileService {
|
||||
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
|
||||
return this.firstMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'PAYMENT_PENDING',
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
@@ -119,7 +143,7 @@ export class FirstMileService {
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||
await this.findById(id);
|
||||
const existing = await this.findById(id);
|
||||
|
||||
const updated = await this.firstMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
@@ -135,9 +159,45 @@ export class FirstMileService {
|
||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||
}
|
||||
|
||||
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||
if (dto.vehicleId) {
|
||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
if (!vehicle.assignedDriverId) {
|
||||
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||
return;
|
||||
}
|
||||
|
||||
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||
if (!driver.phoneNumber) {
|
||||
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||
return;
|
||||
}
|
||||
|
||||
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||
|
||||
await this.notificationsService.notifyDriverVehicleAssignment({
|
||||
driverPhone: driver.phoneNumber,
|
||||
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
||||
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
||||
bookingReference: booking?.reference ?? record.bookingId,
|
||||
pickupAddress: booking?.firstMilePickupAddress,
|
||||
destinationYard: booking?.originYard?.label,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.firstMileRepository.softDelete(id);
|
||||
|
||||
@@ -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()
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||
acceptBooking(@Param('reference') reference: string) {
|
||||
return this.lastMileService.acceptBooking(reference);
|
||||
return this.lastMileService.acceptBookingByReference(reference);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -2,13 +2,22 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
import { LastMileController } from './last-mile.controller';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { LastMileService } from './last-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMile]),
|
||||
BookingsModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [LastMileController],
|
||||
providers: [LastMileRepository, LastMileService],
|
||||
exports: [LastMileRepository, LastMileService],
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||
@@ -26,27 +29,47 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
|
||||
|
||||
@Injectable()
|
||||
export class LastMileService {
|
||||
private readonly logger = new Logger(LastMileService.name);
|
||||
|
||||
constructor(
|
||||
private readonly lastMileRepository: LastMileRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile> {
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: booking.totalAmount,
|
||||
advancedPayment: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<LastMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +129,7 @@ export class LastMileService {
|
||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||
return this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'PAYMENT_PENDING',
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
advancedPayment: dto.advancedPayment ?? 0,
|
||||
remainingPayment: dto.remainingPayment ?? 0,
|
||||
estimatedKm: dto.estimatedKm ?? null,
|
||||
@@ -116,7 +139,7 @@ export class LastMileService {
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
||||
await this.findById(id);
|
||||
const existing = await this.findById(id);
|
||||
|
||||
const updated = await this.lastMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
@@ -132,9 +155,50 @@ export class LastMileService {
|
||||
throw new NotFoundException(`Last-mile record ${id} not found`);
|
||||
}
|
||||
|
||||
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||
if (dto.vehicleId) {
|
||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
if (!vehicle.assignedDriverId) {
|
||||
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
|
||||
return;
|
||||
}
|
||||
|
||||
const driver = await this.driversService.findById(vehicle.assignedDriverId);
|
||||
if (!driver.phoneNumber) {
|
||||
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
|
||||
return;
|
||||
}
|
||||
|
||||
type BookingWithYards = {
|
||||
reference?: string;
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
destinationYard?: { label?: string } | null;
|
||||
};
|
||||
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
|
||||
|
||||
await this.notificationsService.notifyDriverVehicleAssignment({
|
||||
driverPhone: driver.phoneNumber,
|
||||
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
||||
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
||||
bookingReference: booking?.reference ?? record.bookingId,
|
||||
pickupAddress: booking?.destinationYard?.label,
|
||||
destinationYard: booking?.lastMileDeliveryAddress,
|
||||
});
|
||||
|
||||
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.lastMileRepository.softDelete(id);
|
||||
|
||||
@@ -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 { ConfigModule } from "@nestjs/config";
|
||||
import { ClientsModule, Transport } from "@nestjs/microservices";
|
||||
|
||||
import { NotificationsService } from "./notifications.service";
|
||||
import { SmsClientService } from "./sms-client.service";
|
||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [
|
||||
ConfigModule,
|
||||
ClientsModule.register([
|
||||
{
|
||||
name: "SMS_SERVICE",
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
urls: [process.env.RABBITMQ_URL as string],
|
||||
queue: process.env.SMS_QUEUE ?? "sms_queue",
|
||||
queueOptions: { durable: true },
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [],
|
||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
|
||||
exports: [NotificationsService, SmsClientService],
|
||||
})
|
||||
export class NotificationsModule { }
|
||||
export class NotificationsModule {}
|
||||
|
||||
@@ -27,9 +27,29 @@ export class NotificationsService {
|
||||
if (!strategy) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const sent = await strategy.send(recipient, message)
|
||||
this.logger.log(`is sent - ${sent}`)
|
||||
const sent = await strategy.send(recipient, message);
|
||||
this.logger.log(`is sent - ${sent}`);
|
||||
}
|
||||
|
||||
async notifyDriverVehicleAssignment(params: {
|
||||
driverPhone: string;
|
||||
driverName: string;
|
||||
vehiclePlateNumber: string;
|
||||
bookingReference: string;
|
||||
pickupAddress?: string | null;
|
||||
destinationYard?: string | null;
|
||||
}): Promise<void> {
|
||||
const { driverPhone, driverName, vehiclePlateNumber, bookingReference, pickupAddress, destinationYard } = params;
|
||||
const message =
|
||||
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||
`Booking: ${bookingReference}. Vehicle: ${vehiclePlateNumber}. ` +
|
||||
(pickupAddress ? `Pickup: ${pickupAddress}. ` : '') +
|
||||
(destinationYard ? `Destination: ${destinationYard}.` : '');
|
||||
|
||||
try {
|
||||
await this.directSend('sms', driverPhone, message);
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to notify driver ${driverName} (${driverPhone}): ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { NotificationStrategy } from "./notification.strategy";
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import axios, { isAxiosError } from "axios";
|
||||
|
||||
import { NotificationStrategy } from "./notification.strategy";
|
||||
|
||||
@Injectable()
|
||||
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { }
|
||||
async send(recipient: string, message: string) {
|
||||
const url = this.configService.get("OZIKING_SMS_URL")
|
||||
const body = {
|
||||
to: recipient,
|
||||
text: message
|
||||
}
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
url,
|
||||
body,
|
||||
),
|
||||
);
|
||||
private readonly logger = new Logger(SmsNotificationStrategy.name);
|
||||
|
||||
return response.status === 201;
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async send(recipient: string, message: string): Promise<boolean> {
|
||||
const url =
|
||||
this.configService.get<string>("OZIKING_SMS_URL") ??
|
||||
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
|
||||
|
||||
const appKey = this.configService.get<string>("OZIKING_APP_KEY") ?? "";
|
||||
if (!appKey) {
|
||||
this.logger.warn("OZIKING_APP_KEY is not set — SMS may be rejected by the API");
|
||||
}
|
||||
|
||||
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
to: recipient,
|
||||
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
|
||||
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
|
||||
appKey,
|
||||
text: message,
|
||||
callbackUrl: "",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.debug(`SMS API response: ${response.status} ${JSON.stringify(response.data)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isAxiosError(err)) {
|
||||
this.logger.error(
|
||||
`SMS API error: ${err.message} | status=${err.response?.status} | body=${JSON.stringify(err.response?.data)}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(`SMS send failed: ${String(err)}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,9 @@ export class OtpController {
|
||||
@Post("send")
|
||||
async sendOtp(
|
||||
@Body("phone")
|
||||
phone: string,
|
||||
@Body("otp")
|
||||
otp: string
|
||||
phone: string
|
||||
) {
|
||||
return this.otpService.sendOtp(
|
||||
phone,otp
|
||||
);
|
||||
return this.otpService.sendOtp(phone);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,11 +12,14 @@ import { OtpService } from "./otp.service";
|
||||
|
||||
import { OtpRepository } from "./otp.repository";
|
||||
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
OtpVerification,
|
||||
]),
|
||||
NotificationsModule,
|
||||
],
|
||||
|
||||
controllers: [OtpController],
|
||||
|
||||
@@ -5,14 +5,15 @@ import {
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import { OtpRepository } from "./otp.repository";
|
||||
|
||||
import { SmsClientService } from "../notifications/sms-client.service";
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
constructor(
|
||||
private readonly otpRepository: OtpRepository
|
||||
private readonly otpRepository: OtpRepository,
|
||||
private readonly smsClient: SmsClientService
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -29,11 +30,12 @@ export class OtpService {
|
||||
// Send OTP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async sendOtp(phone: string, otp: string) {
|
||||
async sendOtp(phone: string) {
|
||||
try {
|
||||
// generate otp
|
||||
// const otp =
|
||||
// this.generateOtp();
|
||||
// The verification code is generated server-side — never supplied by the
|
||||
// caller — so the OTP stays a secret known only to the server and the
|
||||
// recipient of the SMS.
|
||||
const otp = this.generateOtp();
|
||||
|
||||
// find existing phone
|
||||
const existingPhone =
|
||||
@@ -55,33 +57,11 @@ export class OtpService {
|
||||
);
|
||||
}
|
||||
|
||||
// send sms
|
||||
await axios.post(
|
||||
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms",
|
||||
{
|
||||
to: phone,
|
||||
|
||||
sourceId: "EDR",
|
||||
|
||||
sourceName:
|
||||
"EDR Freight",
|
||||
|
||||
appKey:
|
||||
"YOUR_APP_KEY",
|
||||
|
||||
text: `Your verification code is ${otp}`,
|
||||
|
||||
callbackUrl: "",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
accept: "*/*",
|
||||
|
||||
"Content-Type":
|
||||
"application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
// send sms (queued to RabbitMQ via the shared SMS service)
|
||||
await this.smsClient.sendSms({
|
||||
to: phone,
|
||||
message: `Your verification code is ${otp}`,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import {
|
||||
PAYMENT_EVENTS_DLX,
|
||||
PAYMENT_EVENTS_EXCHANGE,
|
||||
@@ -10,31 +10,31 @@ import {
|
||||
PaymentService as PaymentServiceEnum,
|
||||
paymentServiceBindingPattern,
|
||||
} from "@edr/types";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { InternalPaymentController } from "./internal-payment.controller";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import { InternalPaymentController } from "./internal-payment.controller";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
|
||||
function rabbitMQImport(): DynamicModule[] {
|
||||
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
|
||||
|
||||
return [
|
||||
RabbitMQModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
uri: config.get<string>("rabbitmq.url") as string,
|
||||
uri: config.get<string>("rabbitmq.url") ?? process.env.PAYMENT_RABBITMQ_URL ?? "",
|
||||
exchanges: [
|
||||
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
|
||||
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
|
||||
@@ -51,6 +51,22 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||
connectionInitOptions: { wait: false },
|
||||
}),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
TypeOrmModule.forFeature([
|
||||
PaymentEntity,
|
||||
PaymentWebhookEventEntity,
|
||||
PaymentRefundEntity,
|
||||
]),
|
||||
...rabbitMQImport(),
|
||||
],
|
||||
providers: [
|
||||
PaymentRepository,
|
||||
@@ -62,4 +78,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||
controllers: [PaymentController, InternalPaymentController],
|
||||
exports: [PaymentService],
|
||||
})
|
||||
export class PaymentModule { }
|
||||
export class PaymentModule {}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
|
||||
/** Setting code holding the global ordering window (months) for general contracts. */
|
||||
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
||||
@@ -60,6 +61,7 @@ export class PaymentService {
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
private readonly firstMileService: FirstMileService,
|
||||
) { }
|
||||
|
||||
/** Configured general-contract ordering window in months (defaults to 3). */
|
||||
@@ -342,6 +344,8 @@ export class PaymentService {
|
||||
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||
: { paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMileService.acceptBooking(input.bookingId);
|
||||
|
||||
});
|
||||
|
||||
if (isGeneralContract) {
|
||||
|
||||
@@ -57,6 +57,12 @@ export interface BookingEvaluationInput {
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
totalWagons: number;
|
||||
/**
|
||||
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
|
||||
* PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
|
||||
* container freight, which is scaled by container count instead.
|
||||
*/
|
||||
bulkTons?: number;
|
||||
containers: BookingContainerEvalInput[];
|
||||
}
|
||||
|
||||
@@ -224,16 +230,46 @@ export class RuleEngineService {
|
||||
});
|
||||
if (!triggered) continue;
|
||||
|
||||
let triggerValue: number | null = null;
|
||||
let calculatedAmount = Number(rate.rateValue);
|
||||
// Surcharges scale by their own rateUnit, so the same trigger can bill the
|
||||
// right way per freight shape — e.g. a PER_TON reefer rate multiplies the
|
||||
// bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
|
||||
// count. triggerValue records the quantity billed (shown on the breakdown).
|
||||
const rateValue = Number(rate.rateValue);
|
||||
const containerCount = input.containers.reduce(
|
||||
(sum, c) => sum + Number(c.quantity || 0),
|
||||
0,
|
||||
);
|
||||
const overweightExcessTons = containerWeightResults.reduce(
|
||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons.
|
||||
if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') {
|
||||
triggerValue = containerWeightResults.reduce(
|
||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||
0,
|
||||
);
|
||||
calculatedAmount = triggerValue * Number(rate.rateValue);
|
||||
let triggerValue: number | null = null;
|
||||
let calculatedAmount: number;
|
||||
|
||||
switch (rate.rateUnit) {
|
||||
case 'PER_TON':
|
||||
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
|
||||
// (e.g. bulk reefer) bills the full bulk tonnage.
|
||||
triggerValue =
|
||||
rate.trigger === 'OVERWEIGHT'
|
||||
? overweightExcessTons
|
||||
: Number(input.bulkTons ?? 0);
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_CONTAINER':
|
||||
triggerValue = containerCount;
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_WAGON':
|
||||
triggerValue = input.totalWagons;
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'FLAT':
|
||||
default:
|
||||
// FLAT (and any unknown unit) bills once.
|
||||
calculatedAmount = rateValue;
|
||||
break;
|
||||
}
|
||||
|
||||
// Safety guard: never include a surcharge with a non-positive amount (a
|
||||
|
||||
@@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
route: true,
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
wagons: {
|
||||
wagonType: true,
|
||||
physicalWagon: true,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateContainerTrainScheduleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
locomotiveId!: string;
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Locomotives pulling the train (minimum 2 — front and back)',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive(
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
|
||||
/**
|
||||
* Effective pull limits for a train set with multiple locomotives: the weakest
|
||||
* locomotive caps the train, so take the minimum pull weight and minimum length
|
||||
* across all assigned locomotives. Returns null when no locomotives are given.
|
||||
*/
|
||||
export function minLocomotiveLimits(
|
||||
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
|
||||
): LocomotiveLimits | null {
|
||||
if (!locomotives.length) return null;
|
||||
return {
|
||||
maxPullWeightTons: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
|
||||
),
|
||||
maxTrainLengthMeters: Math.min(
|
||||
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@Post("bulk/schedules/:id/cancel")
|
||||
@Post('bulk/schedules/:id/cancel')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||
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 { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Route } from '../routes/entities/route.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
||||
@@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
WagonType,
|
||||
TrainSet,
|
||||
TrainSetWagon,
|
||||
TrainSetLocomotive,
|
||||
Route,
|
||||
Wagon,
|
||||
Container,
|
||||
|
||||
@@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => {
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||
const lockedLocomotiveRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(locomotive),
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(locomotive)
|
||||
.mockResolvedValueOnce(locomotive2),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const trainScheduleRepo = {
|
||||
@@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
||||
};
|
||||
const trainSetLocomotiveRepo = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
switch (entity?.name) {
|
||||
@@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => {
|
||||
return trainScheduleRepo;
|
||||
case 'TrainSet':
|
||||
return trainSetRepo;
|
||||
case 'TrainSetLocomotive':
|
||||
return trainSetLocomotiveRepo;
|
||||
default:
|
||||
throw new Error(`Unexpected transaction repository ${entity?.name}`);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') {
|
||||
return { findOne: jest.fn().mockResolvedValue(route) };
|
||||
@@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => {
|
||||
const result = await service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
locomotiveId: 'loc-1',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
});
|
||||
|
||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
||||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
|
||||
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
expect(result.id).toBe('schedule-1');
|
||||
});
|
||||
|
||||
@@ -508,7 +521,6 @@ describe('TrainSchedulingService', () => {
|
||||
})),
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Route') {
|
||||
return {
|
||||
@@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => {
|
||||
service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
locomotiveId: 'loc-1',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
AllocationLoadType,
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
@@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { Route } from '../routes/entities/route.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
@@ -79,8 +80,10 @@ import {
|
||||
pickBulkWagonType,
|
||||
} from './wagon-type-resolver.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
minLocomotiveLimits,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -288,31 +291,42 @@ export class TrainSchedulingService {
|
||||
|
||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||
const route = await this.getActiveRoute(dto.routeId);
|
||||
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
|
||||
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||
}
|
||||
|
||||
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
||||
const lockedLocomotive = await manager.getRepository(Locomotive).findOne({
|
||||
where: { id: locomotive.id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!lockedLocomotive) {
|
||||
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
||||
}
|
||||
if (lockedLocomotive.status !== 'AVAILABLE') {
|
||||
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
|
||||
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
|
||||
const lockedLocomotives: Locomotive[] = [];
|
||||
for (const locomotiveId of locomotiveIds) {
|
||||
const locked = await manager.getRepository(Locomotive).findOne({
|
||||
where: { id: locomotiveId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!locked) {
|
||||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||
}
|
||||
if (locked.status !== 'AVAILABLE') {
|
||||
throw new ConflictException(`Locomotive ${locked.code} is not available`);
|
||||
}
|
||||
if (locked.currentYardId !== route.originYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
|
||||
);
|
||||
}
|
||||
lockedLocomotives.push(locked);
|
||||
}
|
||||
|
||||
const direction = deriveScheduleDirection(
|
||||
route.originYard ?? { country: null },
|
||||
route.destinationYard ?? { country: null },
|
||||
);
|
||||
if (lockedLocomotive.currentYardId !== route.originYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
|
||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||||
// Effective capacity is capped by the weakest locomotive in the set.
|
||||
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
@@ -322,11 +336,14 @@ export class TrainSchedulingService {
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons: (
|
||||
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
|
||||
await this.resolveTrainLimitConfig(dto, limitLoco)
|
||||
).maxWagonsPerTrain,
|
||||
});
|
||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ id: In(lockedLocomotives.map((l) => l.id)) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
@@ -375,8 +392,9 @@ export class TrainSchedulingService {
|
||||
maxWagonsPerTrain: dto.maxWagonsPerTrain,
|
||||
};
|
||||
|
||||
const locomotive = schedule.trainSet.locomotive;
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
|
||||
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
|
||||
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
|
||||
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
|
||||
const validation = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
freightType ?? null,
|
||||
@@ -408,17 +426,17 @@ export class TrainSchedulingService {
|
||||
const totalWeightTons = validation.summary.totalWeightTons;
|
||||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||||
|
||||
if (!locomotive) {
|
||||
throw new BadRequestException('Schedule train set has no locomotive');
|
||||
if (!limitLoco) {
|
||||
throw new BadRequestException('Schedule train set has no locomotives');
|
||||
}
|
||||
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
|
||||
if (limitLoco.maxPullWeightTons < totalWeightTons) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
|
||||
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
||||
);
|
||||
}
|
||||
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
|
||||
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
||||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -681,10 +699,12 @@ export class TrainSchedulingService {
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
TrainScheduleStatusEnum.Dispatched,
|
||||
{ actualDepartureAt: now },
|
||||
{ actualDepartureAt: now, trainNumber },
|
||||
manager,
|
||||
);
|
||||
if (schedule.trainSetId) {
|
||||
@@ -718,6 +738,60 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a fixed train number on dispatch. The number is drawn from the pool
|
||||
* for the train's dominant cargo type (container vs bulk) and trade direction
|
||||
* (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
|
||||
* "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
|
||||
* concurrent dispatches can't grab the same number. Throws when the pool is
|
||||
* exhausted. Idempotent: returns the existing number if already assigned.
|
||||
*/
|
||||
private async assignTrainNumber(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
): Promise<string> {
|
||||
if (schedule.trainNumber) return schedule.trainNumber;
|
||||
|
||||
// Count container vs bulk wagons from the planned allocations.
|
||||
let containerWagons = 0;
|
||||
let bulkWagons = 0;
|
||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||
const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
|
||||
if (isBulk) bulkWagons += 1;
|
||||
else containerWagons += 1;
|
||||
}
|
||||
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
|
||||
const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);
|
||||
|
||||
// Lock the set of currently-active numbered schedules so two concurrent
|
||||
// dispatches serialize and can't both claim the same lowest-free number.
|
||||
const activeNumbered = await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('schedule')
|
||||
.setLock('pessimistic_write')
|
||||
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
|
||||
.andWhere('schedule.train_number IS NOT NULL')
|
||||
.getMany();
|
||||
|
||||
const usedNumbers = activeNumbered
|
||||
.map((s) => s.trainNumber)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
|
||||
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
|
||||
if (!number) {
|
||||
throw new ConflictException(
|
||||
`No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
|
||||
);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
/** Open or close a schedule's booking window (staff override). */
|
||||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
||||
await this.dataSource
|
||||
@@ -931,6 +1005,19 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = $2,
|
||||
scheduling_status = $3
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||
);
|
||||
|
||||
if (schedule.trainSet?.locomotiveId) {
|
||||
const loco = await manager
|
||||
.getRepository(Locomotive)
|
||||
@@ -982,7 +1069,7 @@ export class TrainSchedulingService {
|
||||
async getContainerTrainSchedules() {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||
route: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
@@ -1013,10 +1100,11 @@ export class TrainSchedulingService {
|
||||
if (schedule.trainSetId) {
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
||||
}
|
||||
if (schedule.trainSet?.locomotiveId) {
|
||||
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, {
|
||||
status: 'AVAILABLE',
|
||||
});
|
||||
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
if (cancelledLocoIds.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
|
||||
}
|
||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||
if (wagon.physicalWagonId) {
|
||||
@@ -1251,24 +1339,29 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
let assignedLocomotive: Locomotive | null = null;
|
||||
let assignedLocomotives: Locomotive[] = [];
|
||||
if (targetScheduleId) {
|
||||
const targetSchedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
|
||||
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
|
||||
assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
|
||||
}
|
||||
|
||||
if (assignedLocomotive) {
|
||||
if (assignedLocomotive.currentYardId !== originYardId) {
|
||||
if (assignedLocomotives.length) {
|
||||
// Every locomotive of the set must sit at the origin yard, and the weakest
|
||||
// one must still be able to pull the train (min limits across the set).
|
||||
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
|
||||
const setLimits = minLocomotiveLimits(assignedLocomotives);
|
||||
if (offYard) {
|
||||
violations.push(
|
||||
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
|
||||
`Locomotive ${offYard.code} is not at the schedule origin yard`,
|
||||
);
|
||||
} else if (
|
||||
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
|
||||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons < totalWeightTons ||
|
||||
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
||||
) {
|
||||
violations.push(
|
||||
'Assigned locomotive cannot support the total train weight and length',
|
||||
'Assigned locomotives cannot support the total train weight and length',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1818,6 +1911,22 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All locomotives attached to a loaded train set. Prefers the `locomotives`
|
||||
* link rows; falls back to the legacy single `locomotive` for train sets
|
||||
* created before multi-loco support.
|
||||
*/
|
||||
private locomotivesOfTrainSet(
|
||||
trainSet: TrainSet | null | undefined,
|
||||
): Locomotive[] {
|
||||
if (!trainSet) return [];
|
||||
const linked = (trainSet.locomotives ?? [])
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco));
|
||||
if (linked.length) return linked;
|
||||
return trainSet.locomotive ? [trainSet.locomotive] : [];
|
||||
}
|
||||
|
||||
async selectOrValidateLocomotive(
|
||||
locomotiveId: string,
|
||||
totalWeightTons: number,
|
||||
@@ -1841,15 +1950,28 @@ export class TrainSchedulingService {
|
||||
return locomotive;
|
||||
}
|
||||
|
||||
private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) {
|
||||
private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) {
|
||||
const [primary] = locomotives;
|
||||
const trainSet = manager.getRepository(TrainSet).create({
|
||||
locomotiveId: locomotive.id,
|
||||
// `locomotiveId` retained as the primary locomotive for single-loco read paths.
|
||||
locomotiveId: primary.id,
|
||||
totalWeightTons: 0,
|
||||
totalLengthMeters: 0,
|
||||
wagonCount: 0,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
return manager.getRepository(TrainSet).save(trainSet);
|
||||
const saved = await manager.getRepository(TrainSet).save(trainSet);
|
||||
|
||||
const links = locomotives.map((loco, index) =>
|
||||
manager.getRepository(TrainSetLocomotive).create({
|
||||
trainSetId: saved.id,
|
||||
locomotiveId: loco.id,
|
||||
sequenceNo: index,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(TrainSetLocomotive).save(links);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async getActiveRoute(routeId: string) {
|
||||
@@ -1915,6 +2037,12 @@ export class TrainSchedulingService {
|
||||
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||||
id: loco.id,
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
currentYardId: loco.currentYardId ?? null,
|
||||
})),
|
||||
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
||||
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
|
||||
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
|
||||
@@ -1952,7 +2080,7 @@ export class TrainSchedulingService {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||
route: { milestones: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
@@ -2099,6 +2227,15 @@ export class TrainSchedulingService {
|
||||
),
|
||||
}
|
||||
: null,
|
||||
locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
|
||||
id: loco.id,
|
||||
code: loco.code,
|
||||
name: loco.name ?? null,
|
||||
status: loco.status,
|
||||
currentYardId: loco.currentYardId ?? null,
|
||||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||||
})),
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
|
||||
@@ -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 { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetLocomotive } from './train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from './train-set-wagon.entity';
|
||||
|
||||
export const TRAIN_SET_STATUSES = [
|
||||
@@ -19,6 +20,7 @@ export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
|
||||
@Index(['locomotiveId'])
|
||||
@Index(['status'])
|
||||
export class TrainSet extends BaseEntity {
|
||||
/** Primary locomotive (first of the set). Kept for back-compat with single-loco read paths. */
|
||||
@Column({ name: 'locomotive_id', type: 'uuid' })
|
||||
locomotiveId!: string;
|
||||
|
||||
@@ -26,6 +28,10 @@ export class TrainSet extends BaseEntity {
|
||||
@JoinColumn({ name: 'locomotive_id' })
|
||||
locomotive?: Locomotive;
|
||||
|
||||
/** All locomotives pulling this train set (minimum 2). */
|
||||
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
|
||||
locomotives?: TrainSetLocomotive[];
|
||||
|
||||
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
totalWeightTons!: number;
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TrainSet } from './entities/train-set.entity';
|
||||
import { TrainSetLocomotive } from './entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from './entities/train-set-wagon.entity';
|
||||
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
|
||||
import { TrainSetsRepository } from './train-sets.repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
|
||||
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon, TrainSetLocomotive])],
|
||||
providers: [TrainSetsRepository, TrainSetWagonsRepository],
|
||||
exports: [TrainSetsRepository, TrainSetWagonsRepository],
|
||||
})
|
||||
|
||||
@@ -37,4 +37,16 @@ export class CreateVehicleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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 vehicles = await this.repository.save(vehicle);
|
||||
return vehicles?.[0] as Vehicle;
|
||||
return this.repository.save(vehicle);
|
||||
}
|
||||
|
||||
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
|
||||
return (await this.repository.save(vehicle)) as Vehicle;
|
||||
return this.repository.save(vehicle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
export const WAGON_STATUSES = [
|
||||
WagonStatus.Available,
|
||||
WagonStatus.Assigned,
|
||||
WagonStatus.ImportReady,
|
||||
WagonStatus.ExportReady,
|
||||
WagonStatus.Maintenance,
|
||||
WagonStatus.Retired,
|
||||
] as const;
|
||||
@@ -65,7 +67,7 @@ export class Wagon extends BaseEntity {
|
||||
@JoinColumn({ name: 'current_train_schedule_id' })
|
||||
currentTrainSchedule?: TrainSchedule | null;
|
||||
|
||||
/** Fleet master consist grouping — separate from operational train_schedules. */
|
||||
/** Fleet master consist grouping — separate from operational train_schedules. */
|
||||
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train!: Train | null;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
facilityId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class GenerateInvoiceDto {
|
||||
@ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' })
|
||||
@@ -11,6 +11,11 @@ export class GenerateInvoiceDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' })
|
||||
@IsOptional()
|
||||
@IsIn(['ETB', 'USD'])
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
}
|
||||
|
||||
export class PayInvoiceBodyDto {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { WarehouseZone } from './warehouse-zone.entity';
|
||||
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
|
||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||
'UNLOADED',
|
||||
'UNLOADED_AT_DJIBOUTI_PORT',
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
@@ -31,12 +32,13 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
// UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected.
|
||||
// Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection.
|
||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
STORED: ['RESERVED'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
DISPATCHED: [],
|
||||
DISPATCHED: ['UNLOADED_AT_DJIBOUTI_PORT'],
|
||||
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
|
||||
// out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects
|
||||
// it / customs or inspection hold / operator chooses to store.
|
||||
|
||||
@@ -37,6 +37,36 @@ export interface ImportTrainItemRow {
|
||||
lastMileRequested: boolean;
|
||||
pickupOption: string;
|
||||
}
|
||||
|
||||
export interface ExportDjiboutiQueueFilter {
|
||||
scheduleId?: string;
|
||||
destination?: string;
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface ExportTrainRow extends ImportTrainRow {
|
||||
departureTime: string | null;
|
||||
}
|
||||
|
||||
export interface ExportTrainItemRow {
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
itemType: 'CONTAINER' | 'CARGO';
|
||||
itemId: string | null;
|
||||
inventoryId: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
trainSchedule: string | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
}
|
||||
export interface WagonView {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
@@ -67,6 +97,13 @@ export interface BookingScheduleView {
|
||||
export class SchedulingReadFacade {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
private isDjiboutiPortDestination(value: string | null | undefined): boolean {
|
||||
const normalized = (value ?? '').toUpperCase();
|
||||
return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
|
||||
normalized.includes(token),
|
||||
);
|
||||
}
|
||||
|
||||
/** Look up a single physical wagon. Returns null if it does not exist. */
|
||||
async findWagon(wagonId: string): Promise<WagonView | null> {
|
||||
const rows = await this.dataSource.query(
|
||||
@@ -220,4 +257,187 @@ export class SchedulingReadFacade {
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* ARRIVED export train schedules at Djibouti-side destinations, with assigned item counts.
|
||||
* Read-only: this only selects from scheduling/booking/inventory tables.
|
||||
*/
|
||||
async exportDjiboutiArrivalQueue(
|
||||
filter: ExportDjiboutiQueueFilter = {},
|
||||
): Promise<ExportTrainRow[]> {
|
||||
const params: unknown[] = [];
|
||||
const where = [
|
||||
'ts.deleted_at IS NULL',
|
||||
"ts.status = ANY($1)",
|
||||
`EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.train_schedule_bookings tsb_exists
|
||||
JOIN freight.bookings b_exists ON b_exists.id = tsb_exists.booking_id AND b_exists.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_inventory inv_exists ON inv_exists.booking_id = b_exists.id AND inv_exists.deleted_at IS NULL
|
||||
WHERE tsb_exists.train_schedule_id = ts.id
|
||||
AND tsb_exists.deleted_at IS NULL
|
||||
AND (inv_exists.status = ANY($2) OR b_exists.status = ANY($2))
|
||||
)`,
|
||||
];
|
||||
params.push(
|
||||
filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'],
|
||||
['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'],
|
||||
);
|
||||
|
||||
if (filter.scheduleId) {
|
||||
params.push(filter.scheduleId);
|
||||
where.push(`ts.id = $${params.length}`);
|
||||
}
|
||||
if (filter.destination) {
|
||||
params.push(`%${filter.destination}%`);
|
||||
where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`);
|
||||
}
|
||||
if (filter.dateFrom) {
|
||||
params.push(filter.dateFrom);
|
||||
where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) >= $${params.length}`);
|
||||
}
|
||||
if (filter.dateTo) {
|
||||
params.push(filter.dateTo);
|
||||
where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) <= $${params.length}`);
|
||||
}
|
||||
|
||||
const rows: Array<
|
||||
ExportTrainRow & {
|
||||
originCountry: string | null;
|
||||
destinationCountry: string | null;
|
||||
destinationName: string | null;
|
||||
}
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS "scheduleId",
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
dy.name AS "destinationName",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
ts.scheduled_departure_date AS "departureTime",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
ts.status,
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings",
|
||||
(SELECT count(*) FROM freight.containers c
|
||||
JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL
|
||||
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
|
||||
(SELECT count(*) FROM freight.cargoes cg
|
||||
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
|
||||
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`,
|
||||
params,
|
||||
);
|
||||
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const direction = deriveTradeDirection(
|
||||
{ country: r.originCountry },
|
||||
{ country: r.destinationCountry },
|
||||
);
|
||||
return (
|
||||
direction === 'EXPORT' &&
|
||||
this.isDjiboutiPortDestination(`${r.destination ?? ''} ${r.destinationName ?? ''}`)
|
||||
);
|
||||
})
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, destinationName: _dn, ...rest }) => ({
|
||||
...rest,
|
||||
totalBookings: Number(rest.totalBookings) || 0,
|
||||
totalContainers: Number(rest.totalContainers) || 0,
|
||||
totalCargoes: Number(rest.totalCargoes) || 0,
|
||||
route: rest.origin || rest.destination ? `${rest.origin ?? '?'} -> ${rest.destination ?? '?'}` : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Assigned export booking items for an arrived Djibouti-side export train. Read-only. */
|
||||
async exportDjiboutiTrainDetail(scheduleId: string): Promise<ExportTrainItemRow[]> {
|
||||
const rows: ExportTrainItemRow[] = await this.dataSource.query(
|
||||
`WITH assigned AS (
|
||||
SELECT b.id AS booking_id,
|
||||
b.reference,
|
||||
b.company_id,
|
||||
company.name AS customer_name,
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
|
||||
b.cargo_total_weight_vgm AS booking_weight,
|
||||
oy.code AS origin,
|
||||
dy.code AS destination,
|
||||
ts.train_number,
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS arrival_time,
|
||||
inv.id AS inventory_id,
|
||||
COALESCE(inv.status, b.status) AS current_status
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
)
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
a.company_id AS "customerId",
|
||||
a.customer_name AS "customerName",
|
||||
'CONTAINER' AS "itemType",
|
||||
c.id AS "itemId",
|
||||
a.inventory_id AS "inventoryId",
|
||||
c.container_number AS "containerNumber",
|
||||
a.cargo_type AS "cargoType",
|
||||
a.booking_weight AS "weight",
|
||||
a.origin,
|
||||
a.destination,
|
||||
a.train_number AS "trainSchedule",
|
||||
a.arrival_time AS "arrivalTime",
|
||||
a.current_status AS "currentStatus"
|
||||
FROM assigned a
|
||||
JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
a.company_id AS "customerId",
|
||||
a.customer_name AS "customerName",
|
||||
'CARGO' AS "itemType",
|
||||
cg.id AS "itemId",
|
||||
a.inventory_id AS "inventoryId",
|
||||
NULL AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, a.cargo_type) AS "cargoType",
|
||||
COALESCE(cg.weight, a.booking_weight) AS "weight",
|
||||
a.origin,
|
||||
a.destination,
|
||||
a.train_number AS "trainSchedule",
|
||||
a.arrival_time AS "arrivalTime",
|
||||
a.current_status AS "currentStatus"
|
||||
FROM assigned a
|
||||
JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
a.company_id AS "customerId",
|
||||
a.customer_name AS "customerName",
|
||||
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
|
||||
a.inventory_id AS "itemId",
|
||||
a.inventory_id AS "inventoryId",
|
||||
NULL AS "containerNumber",
|
||||
a.cargo_type AS "cargoType",
|
||||
a.booking_weight AS "weight",
|
||||
a.origin,
|
||||
a.destination,
|
||||
a.train_number AS "trainSchedule",
|
||||
a.arrival_time AS "arrivalTime",
|
||||
a.current_status AS "currentStatus"
|
||||
FROM assigned a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
|
||||
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
|
||||
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
|
||||
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
|
||||
const rule = await this.findMatchingRule(criteria);
|
||||
const yardCode = rule?.targetYardCode;
|
||||
if (!rule) return null;
|
||||
|
||||
// Resolve yard (by rule code, else first available yard with a zone).
|
||||
// Resolve yard by rule code.
|
||||
const [yard] = await this.dataSource.query(
|
||||
yardCode
|
||||
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
|
||||
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
|
||||
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
|
||||
yardCode ? [yardCode] : [],
|
||||
`SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
|
||||
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`,
|
||||
[rule.targetYardCode],
|
||||
);
|
||||
if (!yard) return null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
@@ -26,6 +26,29 @@ export interface WarehouseDashboard {
|
||||
export class WarehouseDashboardService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
private async safeCount<T extends ObjectLiteral>(
|
||||
repo: Repository<T>,
|
||||
options?: FindManyOptions<T>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
return await repo.count(options);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async safeReceivedToday(startOfToday: Date): Promise<number> {
|
||||
try {
|
||||
return await this.dataSource
|
||||
.getRepository(WarehouseInventory)
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
.getCount();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getDashboard(): Promise<WarehouseDashboard> {
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
@@ -47,21 +70,18 @@ export class WarehouseDashboardService {
|
||||
delivered,
|
||||
receivedToday,
|
||||
] = await Promise.all([
|
||||
warehouseRepo.count(),
|
||||
inventoryRepo.count(),
|
||||
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
|
||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }),
|
||||
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
|
||||
inventoryRepo
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
.getCount(),
|
||||
this.safeCount(warehouseRepo),
|
||||
this.safeCount(inventoryRepo),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
|
||||
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
|
||||
this.safeReceivedToday(startOfToday),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
@@ -13,6 +14,8 @@ interface ItemAttributes {
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
containerTypeCode: string | null;
|
||||
inventoryQuantity: number;
|
||||
bookingContainerCount: number;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
@@ -26,11 +29,15 @@ export interface FeePreview {
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
currency: string;
|
||||
ruleCurrency: string | null;
|
||||
billingCurrency: string;
|
||||
startDate: string | null;
|
||||
endDate: string;
|
||||
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -41,6 +48,7 @@ export class WarehouseFeeService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
) {}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
@@ -67,6 +75,7 @@ export class WarehouseFeeService {
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.quantity AS "inventoryQuantity",
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId",
|
||||
inv.zone_id AS "zoneId",
|
||||
@@ -74,7 +83,8 @@ export class WarehouseFeeService {
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
ctt.code AS "containerTypeCode"
|
||||
ctt.code AS "containerTypeCode",
|
||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -82,6 +92,12 @@ export class WarehouseFeeService {
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = inv.booking_id
|
||||
AND bc.deleted_at IS NULL
|
||||
) container_lines ON true
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
@@ -125,44 +141,86 @@ export class WarehouseFeeService {
|
||||
return best;
|
||||
}
|
||||
|
||||
private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview {
|
||||
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
||||
return currency === 'ETB' ? 'ETB' : 'USD';
|
||||
}
|
||||
|
||||
private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
|
||||
const from = this.normalizeCurrency(fromCurrency);
|
||||
const to = this.normalizeCurrency(toCurrency);
|
||||
if (from === to) return Math.round(amount * 100) / 100;
|
||||
const rate = await this.exchangeService.getRate(from, to);
|
||||
return Math.round(amount * rate * 100) / 100;
|
||||
}
|
||||
|
||||
private async compute(
|
||||
ruleType: FeeRuleType,
|
||||
rule: WarehouseFeeRule | null,
|
||||
item: ItemAttributes,
|
||||
now: Date,
|
||||
billingCurrency: string,
|
||||
): Promise<FeePreview> {
|
||||
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||
const freeDays = rule?.freeDays ?? 0;
|
||||
const ratePerDay = Number(rule?.ratePerDay ?? 0);
|
||||
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
|
||||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
|
||||
const containerCount = isContainer
|
||||
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||||
: 1;
|
||||
|
||||
const elapsedDays = start
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
: 0;
|
||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
|
||||
const billableUnits = chargeableDays * containerCount;
|
||||
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||
const convertedRatePerDay = ruleCurrency
|
||||
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
ruleType,
|
||||
ruleId: rule?.id ?? null,
|
||||
ruleName: rule?.name ?? null,
|
||||
freeDays,
|
||||
ratePerDay,
|
||||
currency: rule?.currency ?? 'USD',
|
||||
ratePerDay: convertedRatePerDay,
|
||||
currency: targetCurrency,
|
||||
ruleCurrency,
|
||||
billingCurrency: targetCurrency,
|
||||
startDate: start ? start.toISOString() : null,
|
||||
endDate: new Date(endDate).toISOString(),
|
||||
endIsOpen,
|
||||
elapsedDays,
|
||||
chargeableDays,
|
||||
containerCount,
|
||||
billableUnits,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string): Promise<FeePreview[]> {
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
||||
return byType.map((type) =>
|
||||
this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now),
|
||||
return Promise.all(
|
||||
byType.map((type) =>
|
||||
this.compute(
|
||||
type,
|
||||
this.bestRule(rules.filter((r) => r.ruleType === type), item),
|
||||
item,
|
||||
now,
|
||||
billingCurrency,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
@@ -16,9 +17,10 @@ export class WarehouseInspectionService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
) {}
|
||||
|
||||
/** Create an inspection report for an inventory item and sync its inspectionStatus. */
|
||||
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||
async create(inventoryId: string, dto: CreateInspectionReportDto): Promise<WarehouseInspectionReport> {
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } });
|
||||
@@ -29,8 +31,9 @@ export class WarehouseInspectionService {
|
||||
const expected = dto.expectedWeight ?? null;
|
||||
const actual = dto.actualWeight ?? null;
|
||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
||||
const inspectedAt = new Date();
|
||||
|
||||
const report = await this.inspectionRepository.create({
|
||||
const payload = {
|
||||
inventoryId,
|
||||
bookingId: inventory.bookingId ?? null,
|
||||
reportType: dto.reportType,
|
||||
@@ -46,18 +49,60 @@ export class WarehouseInspectionService {
|
||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||
remarks: dto.remarks ?? null,
|
||||
inspectedById: dto.inspectedById ?? null,
|
||||
inspectedAt: new Date(),
|
||||
inspectedAt,
|
||||
};
|
||||
|
||||
const [existingReport] = await this.inspectionRepository.findAll({
|
||||
where: { inventoryId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
let report: WarehouseInspectionReport;
|
||||
if (existingReport) {
|
||||
await this.inspectionRepository.update(existingReport.id, payload);
|
||||
report = await this.findById(existingReport.id);
|
||||
} else {
|
||||
report = await this.inspectionRepository.create(payload);
|
||||
}
|
||||
|
||||
// Mirror the latest outcome onto the inventory item so loading rules can read it.
|
||||
await inventoryRepo.update(inventoryId, {
|
||||
inspectionStatus: dto.inspectionStatus,
|
||||
inspectedAt: new Date(),
|
||||
inspectedAt,
|
||||
});
|
||||
|
||||
if (dto.inspectionStatus === 'PASSED') {
|
||||
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
private async markImportPickupReadyAndAcceptLastMile(inventoryId: string): Promise<void> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[inventoryId],
|
||||
);
|
||||
if ((row?.tradeDirection ?? '').toUpperCase() !== 'IMPORT') return;
|
||||
|
||||
await this.dataSource.getRepository(WarehouseInventory).update(inventoryId, {
|
||||
status: 'READY_FOR_PICKUP',
|
||||
readyForPickupAt: new Date(),
|
||||
});
|
||||
|
||||
if (row.bookingReference && row.lastMileDeliveryAddress) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
}
|
||||
}
|
||||
|
||||
async findByInventory(inventoryId: string): Promise<WarehouseInspectionReport[]> {
|
||||
return this.inspectionRepository.findAll({
|
||||
where: { inventoryId },
|
||||
@@ -98,6 +143,9 @@ export class WarehouseInspectionService {
|
||||
await this.dataSource
|
||||
.getRepository(WarehouseInventory)
|
||||
.update(report.inventoryId, { inspectionStatus: dto.inspectionStatus });
|
||||
if (dto.inspectionStatus === 'PASSED') {
|
||||
await this.markImportPickupReadyAndAcceptLastMile(report.inventoryId);
|
||||
}
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
@@ -142,6 +143,36 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.importUnloadedQueue();
|
||||
}
|
||||
|
||||
@Get('export/djibouti-arrival-queue')
|
||||
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
|
||||
exportDjiboutiArrivalQueue(
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@Query('destination') destination?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
) {
|
||||
return this.scheduling.exportDjiboutiArrivalQueue({
|
||||
scheduleId,
|
||||
destination,
|
||||
status,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('export/djibouti-trains/:scheduleId/items')
|
||||
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
|
||||
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('export/auto-unload-at-djibouti')
|
||||
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
|
||||
}
|
||||
|
||||
@Get('import/pickup-ready-queue')
|
||||
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
||||
importPickupReadyQueue() {
|
||||
@@ -226,6 +257,16 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.release(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/release-document')
|
||||
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
|
||||
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
interface GenerateOptions {
|
||||
confirmZero?: boolean;
|
||||
performedBy?: string;
|
||||
billingCurrency?: 'ETB' | 'USD';
|
||||
}
|
||||
|
||||
export interface PayInvoiceDto {
|
||||
@@ -58,7 +59,8 @@ export class WarehouseInvoiceService {
|
||||
);
|
||||
}
|
||||
|
||||
const previews = await this.feeService.previewForInventory(inventoryId);
|
||||
const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||
const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency);
|
||||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
const items = previews
|
||||
@@ -75,9 +77,9 @@ export class WarehouseInvoiceService {
|
||||
feeType,
|
||||
description:
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? `Storage fee — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage — ${p.chargeableDays} chargeable day(s) after ${p.freeDays} free`,
|
||||
quantity: p.chargeableDays,
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
|
||||
quantity: p.billableUnits,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
@@ -98,7 +100,7 @@ export class WarehouseInvoiceService {
|
||||
const invoiceType: WarehouseInvoiceType =
|
||||
hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE';
|
||||
|
||||
const currency = items[0]?.currency ?? 'USD';
|
||||
const currency = billingCurrency;
|
||||
const now = new Date();
|
||||
const periodEnd = previews[0] ? new Date(previews[0].endDate) : now;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
@@ -79,7 +79,10 @@ export class WarehouseRulesController {
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-preview')
|
||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||
feePreview(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.feeService.previewForInventory(id);
|
||||
feePreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('billingCurrency') billingCurrency?: string,
|
||||
) {
|
||||
return this.feeService.previewForInventory(id, billingCurrency);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ export class WarehouseYardsController {
|
||||
private readonly zonesService: WarehouseZonesService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||
findAll() {
|
||||
return this.yardsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -13,6 +13,13 @@ export class WarehouseYardsService {
|
||||
private readonly warehousesService: WarehousesService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
relations: { warehouse: true, zones: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
where: { warehouseId },
|
||||
|
||||
@@ -10,6 +10,12 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all warehouse zones' })
|
||||
findAll() {
|
||||
return this.zonesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -13,6 +13,13 @@ export class WarehouseZonesService {
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
relations: { yard: { warehouse: true } },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
where: { yardId },
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
@@ -64,6 +69,13 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseFeeInvoiceItem,
|
||||
]),
|
||||
FilesModule,
|
||||
InterchangeDocumentsModule,
|
||||
LastMileModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
WarehousesController,
|
||||
@@ -100,6 +112,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseInvoiceService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
SchedulingReadFacade,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
|
||||
@@ -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