diff --git a/.pnpm-store/v11/.pnpm-needs-build-marker b/.pnpm-store/v11/.pnpm-needs-build-marker new file mode 100644 index 000000000..e69de29bb diff --git a/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json b/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json new file mode 100644 index 000000000..1d4a3e729 --- /dev/null +++ b/.pnpm-store/v11/.tmp/pnpm-11.1.1-1781268424966/package.json @@ -0,0 +1 @@ +{"dependencies":{"pnpm":"11.1.1"}} \ No newline at end of file diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 000000000..8fdf9e7d3 Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 73312aae3..4ccffb6a4 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 897a7b764..580173cc1 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 792a268ed..3b69d2812 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts index e9e183b25..b8a6f8afb 100644 --- a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -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'; diff --git a/apps/edr-freight-api/src/migrations/1810000000002-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1810000000002-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..a2a1aae17 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-AddVehicleCodeAndPlates.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddVehicleCodeAndPlates1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.dropColumn('freight.vehicles', 'trailer_plate_no'); + await queryRunner.dropColumn('freight.vehicles', 'power_plate_no'); + await queryRunner.dropColumn('freight.vehicles', 'code'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts b/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts new file mode 100644 index 000000000..bc1765cfe --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-MakeCompanyProfileReferenceNullable.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "reference" DROP NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts new file mode 100644 index 000000000..ec0610f52 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000003-CreateOtpVerifications.ts @@ -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 { + 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 { + await queryRunner.dropTable("otp_verifications", true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts new file mode 100644 index 000000000..b014e71a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts @@ -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 { + 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 { + 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;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts new file mode 100644 index 000000000..757c20720 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000011-DropEmailPhoneFromExternalProfiles.ts @@ -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 { + 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 { + // 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);`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts new file mode 100644 index 000000000..6b77f53a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS estimated_shipment_date; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000000-CreateInterchangeDocuments.ts b/apps/edr-freight-api/src/migrations/1821000000000-CreateInterchangeDocuments.ts new file mode 100644 index 000000000..386eabd4e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000000-CreateInterchangeDocuments.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +export class CreateInterchangeDocuments1821000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts index 8eb258a88..3c5bc4019 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -41,12 +41,54 @@ export class BookingOrdersService { ) {} /** Orders placed against a contract, with their lines and child booking. */ - listByContract(contractBookingId: string): Promise { - return this.ordersRepository.findByContract(contractBookingId); + async listByContract(contractBookingId: string): Promise { + const orders = await this.ordersRepository.findByContract(contractBookingId); + await Promise.all(orders.map((o) => this.syncOrderFromChild(o))); + return orders; } - findById(id: string): Promise { - return this.ordersRepository.findById(id); + async findById(id: string): Promise { + 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 { + 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}` : ''), + ); } } diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts index 3c85ad31e..bfdcc72b8 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts index 7b4728e02..d6fff951a 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts index 4cf5dfe59..58122bd65 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -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> { - 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(); - 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 { - // 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); } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 0315de60d..f9d8fb17e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 12ce77d6d..f9c7e182f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -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' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2c2e0ce0e..d819ee527 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 { + 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([ + ...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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 8197125f1..331706c5a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 6cfac85fa..e8f0a1393 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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') { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 45d2fbf7c..c47064014 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { + 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 { 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 { + 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 { + 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 diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index e66c85522..677ef03fd 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 5693cfaf5..00f6e41c1 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 2fba1e878..4bcc3252a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 { + return this.companiesService.getOnboardingRequirements(user.id); + } + @Post("onboarding/complete") @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) async completeOnboarding( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index d275c57a7..88871f8ad 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 9c1e290be..a838495d5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { 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 { 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 { - 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 = { 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 { + 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 { + 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. diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index bc2d95224..15aec5ac6 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository { } async generateReference(type: ProfileType): Promise { - 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`, ); diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts index 7a9b94c44..ff0f94495 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -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) diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts new file mode 100644 index 000000000..92f9fa513 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -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) { + 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; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 97f2d9f50..89a52b5e6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index b62182968..5d90d8d60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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 ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 7e17bcc60..256641074 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index c99ef9d3c..316038dc9 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index c0cb41a63..e61668a07 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 3b1554cc9..93e499b5e 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts index 581dfd72b..70c05abd7 100644 --- a/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/external-profile.repository.ts @@ -23,8 +23,4 @@ export class ExternalProfileRepository extends BaseRepository { async findByCompanyId(companyId: string): Promise { return this.repository.find({ where: { companyId } as any }); } - - async findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } as any }); - } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 161604e81..78c3d43ff 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index 713efa52d..bf6815af7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a4ead8c2a..ba06604ce 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -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 { - const booking = await this.bookingsRepository.findByReference(bookingReference); + async acceptBooking(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { return null; @@ -53,6 +61,22 @@ export class FirstMileService { }); } + async acceptBookingByReference(bookingReference: string): Promise { + 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 { 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 { - 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 { + 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 { await this.findById(id); await this.firstMileRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/create-interchange-document.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/create-interchange-document.dto.ts new file mode 100644 index 000000000..92328f38f --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/create-interchange-document.dto.ts @@ -0,0 +1,3 @@ +import { GenerateFromScheduleDto } from './generate-from-schedule.dto'; + +export class CreateInterchangeDocumentDto extends GenerateFromScheduleDto {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/generate-from-schedule.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/generate-from-schedule.dto.ts new file mode 100644 index 000000000..9e4c57e0f --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/generate-from-schedule.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/interchange-document-query.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/interchange-document-query.dto.ts new file mode 100644 index 000000000..5a8599f7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/interchange-document-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/update-interchange-document-status.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/update-interchange-document-status.dto.ts new file mode 100644 index 000000000..9491d0528 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/update-interchange-document-status.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document-item.entity.ts b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document-item.entity.ts new file mode 100644 index 000000000..d51d2f311 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document-item.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document.entity.ts b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document.entity.ts new file mode 100644 index 000000000..2d8f28aa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts new file mode 100644 index 000000000..fb6478400 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.module.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.module.ts new file mode 100644 index 000000000..cd8927c76 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.repository.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.repository.ts new file mode 100644 index 000000000..0a4bb3a19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.repository.ts @@ -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 { + constructor(@InjectRepository(InterchangeDocument) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts new file mode 100644 index 000000000..667f22659 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -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 { + const where: FindOptionsWhere[] = []; + const base: FindOptionsWhere = { + ...(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`; + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index ea1e29a3d..e8abf52c6 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index fa654f6ec..32c2de721 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index d25729324..22b96a344 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -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 { + async acceptBooking(bookingReference: string): Promise { 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 { + 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 { 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 { - 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 { + 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 { await this.findById(id); await this.lastMileRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/sms.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/sms.dto.ts new file mode 100644 index 000000000..b263b54f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/sms.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 2ff2f9727..663f931ef 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts index 35e8ff07d..088f2bbe0 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.service.ts @@ -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 { + 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)}`); + } + } } diff --git a/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts new file mode 100644 index 000000000..f94c0c20e --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/sms-client.service.ts @@ -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 }; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index 2f8916845..d8404e84c 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -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 { + const url = + this.configService.get("OZIKING_SMS_URL") ?? + "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms"; + + const appKey = this.configService.get("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("OZIKING_SOURCE_ID") ?? "EDR", + sourceName: this.configService.get("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; + } + } } diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 9866ca570..5850cbb1a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -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); } // --------------------------------------------------------------------------- diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index 7a6d1faa6..ec1d9f9ed 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 4e16be20a..ffa9c4e68 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e496bb867..c48f1fac9 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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("rabbitmq.url") as string, + uri: config.get("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 {} diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1f95c6253..582e7467e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index ce0082f83..16027f9c3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 8ec002d49..58e71143c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository { route: true, trainSet: { locomotive: true, + locomotives: { locomotive: true }, wagons: { wagonType: true, physicalWagon: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 5c2486fa3..60aab2862 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 593bb7bee..5ec385924 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -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>, +): 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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts new file mode 100644 index 000000000..ce3d6166b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts new file mode 100644 index 000000000..f4af19cc1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts @@ -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 | null { + const used = new Set(usedNumbers); + for (const number of pool) { + if (!used.has(number)) return number; + } + return null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 0c01dd219..08fc74172 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 64112d720..e38fc4d75 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 163008ecc..6f22f7a83 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 239640624..92aca8ac8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 { + 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 { 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) => ({ diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts new file mode 100644 index 000000000..4ad52a226 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index 9099824d5..fde6d75c6 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts index f11052727..19ed7ea73 100644 --- a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -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], }) diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 8a2ad8519..55e12047b 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -37,4 +37,16 @@ export class CreateVehicleDto { @IsOptional() @IsString() assignedDriverName?: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + powerPlateNo?: string; + + @IsOptional() + @IsString() + trailerPlateNo?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts index 7851f9485..1080480fa 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository { }; } - async createVehicle(vehicleData: any): Promise { + async createVehicle(vehicleData: Partial): Promise { 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 { - return (await this.repository.save(vehicle)) as Vehicle; + return this.repository.save(vehicle); } } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 42db52231..195b4932b 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index c867eec3c..9f89e7c2c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts index cba259d00..4de117064 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/inquiry-inventory.dto.ts @@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto { @ApiPropertyOptional() @IsOptional() @IsString() + bookingReference?: string; + + @ApiPropertyOptional({ description: 'Legacy alias for bookingReference' }) + @IsOptional() + @IsString() bookingNumber?: string; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 9d25c974a..9c62aeae5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index a5d4e1eeb..815841e54 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -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 + normalized.includes(token), + ); + } + /** Look up a single physical wagon. Returns null if it does not exist. */ async findWagon(wagonId: string): Promise { 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 { + 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 { + 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; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts index f110dfcf7..1cfbc4661 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-allocation.service.ts @@ -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 { 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; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts index fcc09f668..0bbcdee48 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -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( + repo: Repository, + options?: FindManyOptions, + ): Promise { + try { + return await repo.count(options); + } catch { + return 0; + } + } + + private async safeReceivedToday(startOfToday: Date): Promise { + try { + return await this.dataSource + .getRepository(WarehouseInventory) + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(); + } catch { + return 0; + } + } + async getDashboard(): Promise { 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 { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index ccf66deb4..e0a0f2b6c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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 { + 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 { 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 { + async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { 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, + ), + ), ); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 9d1d0f148..1de6daf81 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -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 { 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 { + 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 { 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); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 2699adbef..230192a91 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 31975b089..8171983df 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,8 +1,12 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; +import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; +import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; +import { LastMileService } from '../last-mile/last-mile.service'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -38,8 +42,11 @@ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'A export interface InventoryInquiryResult { id: string; + inventoryId: string | null; bookingId: string | null; + bookingReference: string | null; bookingNumber: string | null; + bookingStatus: string | null; customerName: string | null; containerNumber: string | null; cargoType: string | null; @@ -48,32 +55,32 @@ export interface InventoryInquiryResult { warehouse: { id: string; name: string; code: string } | null; yard: { id: string; name: string; code: string } | null; zone: { id: string; name: string; code: string } | null; - status: string; + status: string | null; + trainNumber: string | null; + trainStatus: string | null; + route: string | null; + locationSummary: string | null; quantity: number; weight: number; arrivedAt: Date | null; readyForLoadingAt: Date | null; } -interface LocationNode { - maxWeight?: number | null; - capacityWeight?: number | null; - maxVolume?: number | null; - capacityContainers?: number | null; - currentWeight: number; - currentVolume: number; - currentContainers: number; +interface BookingSummaryRow { + id: string; + reference: string | null; + status: string | null; + customer: string | null; } -// ── Batch 4.5 result/queue shapes ──────────────────────────────────────────── interface ArrivalQueueRow { bookingId: string; - bookingReference: string; + bookingReference: string | null; customer: string | null; cargo: string | null; container: string | null; arrivalDate: Date | null; - bookingStatus: string; + bookingStatus: string | null; inventoryId: string | null; currentStatus: string | null; inspectionStatus: string | null; @@ -85,7 +92,7 @@ interface ArrivalQueueRow { export interface ArrivalQueueItem { bookingId: string; - bookingReference: string; + bookingReference: string | null; customer: string | null; cargo: string | null; container: string | null; @@ -102,22 +109,71 @@ export interface ArrivalQueueItem { interface DefaultLocation { warehouseId: string; + facilityId?: string | null; yardId: string; zoneId: string; - facilityId: string | null; +} + +interface StorageAllocationLocation extends DefaultLocation { + path?: string | null; + rule?: { id: string; name: string; storageType: string | null } | null; +} + +interface InventoryAllocationCriteria { + freightType?: string | null; + tradeDirection?: string | null; + cargoTypeCode?: string | null; + containerStatus?: string | null; + requiresInspection?: boolean | null; } export interface AutoUnloadResult { processedCount: number; skippedCount: number; failedCount: number; - results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; + results: Array<{ + bookingId: string; + inventoryId?: string; + status: 'PROCESSED' | 'FAILED'; + reason?: string; + }>; } export interface AutoLoadResult { loadedCount: number; skippedCount: number; - results: { inventoryId: string; status: string; reason?: string }[]; + results: Array<{ + inventoryId: string; + status: 'LOADED' | 'SKIPPED'; + reason?: string; + }>; +} + +interface WarehouseDashboardSummary { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + +interface LocationRef { + warehouseId: string; + yardId: string; + zoneId: string; +} + +interface LocationNode { + capacityWeight?: number | null; + capacityContainers?: number | null; + currentWeight: number; + maxWeight?: number | null; + maxVolume?: number | null; + currentVolume?: number | null; + currentContainers: number; } // ── Receive (Import/Export bulk) shapes ────────────────────────────────────── @@ -182,6 +238,23 @@ export interface AutoUnloadArrivedResult { results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; } +export interface AutoUnloadExportDjiboutiResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + interchangeDocument?: Pick; + results: Array<{ + bookingId: string; + itemType: 'CONTAINER' | 'CARGO'; + itemId?: string | null; + inventoryId?: string; + containerNumber?: string | null; + status: string; + message?: string; + reason?: string; + }>; +} + export interface ImportUnloadedRow { id: string; bookingId: string | null; @@ -210,6 +283,9 @@ export class WarehouseInventoryService { private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, + private readonly pdfService: ContractPdfService, + private readonly interchangeDocuments: InterchangeDocumentsService, + private readonly lastMileService: LastMileService, ) {} /** @@ -242,7 +318,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - findAll(filter: FilterWarehouseInventoryDto): Promise { + async findAll(filter: FilterWarehouseInventoryDto): Promise { + const createdAt = + filter.dateFrom && filter.dateTo + ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) + : filter.dateFrom + ? MoreThanOrEqual(new Date(filter.dateFrom)) + : filter.dateTo + ? LessThanOrEqual(new Date(filter.dateTo)) + : undefined; + const base = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), @@ -252,6 +337,8 @@ export class WarehouseInventoryService { ...(filter.containerId ? { containerId: filter.containerId } : {}), ...(filter.goodsId ? { goodsId: filter.goodsId } : {}), ...(filter.status ? { status: filter.status } : {}), + ...(createdAt ? { createdAt } : {}), + ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), }; const search = filter.search?.trim(); @@ -259,11 +346,13 @@ export class WarehouseInventoryService { ? { ...base, notes: ILike(`%${search}%`) } : base; - return this.inventoryRepository.findAll({ + const items = await this.inventoryRepository.findAll({ where, - relations: { warehouse: { facility: true }, yard: true, zone: true, booking: true }, + relations: { warehouse: true, yard: true, zone: true }, order: { createdAt: 'DESC' }, }); + await this.attachBookingSummaries(items); + return items; } findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { @@ -272,7 +361,7 @@ export class WarehouseInventoryService { async findById(id: string): Promise { const item = await this.inventoryRepository.findById(id, { - relations: { warehouse: true, yard: true, zone: true }, + relations: { warehouse: { facility: true }, yard: true, zone: true }, }); if (!item) { @@ -800,14 +889,23 @@ export class WarehouseInventoryService { return result; } - /** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */ - private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [ + /** Booking statuses that must never be unloaded into warehouse inventory. */ + private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; + private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [ + 'DISPATCHED', 'IN_TRANSIT', - 'ARRIVED_AT_INDODE', + 'ARRIVED_AT_DJIBOUTI', + 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION', - 'ARRIVED_AT_FACILITY', ]; + private isDjiboutiPortDestination(value: string | null | undefined): boolean { + const normalized = (value ?? '').toUpperCase(); + return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) => + normalized.includes(token), + ); + } + /** * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. * Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect — @@ -875,8 +973,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason }); }; - if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) { - skip(`Booking status ${booking.status} is not unload-eligible`); + if (this.IMPORT_UNLOAD_BLOCKED_STATUSES.includes(booking.status)) { + skip(`Booking status ${booking.status} cannot be unloaded`); continue; } @@ -948,6 +1046,243 @@ export class WarehouseInventoryService { return result; } + /** + * Unload eligible EXPORT inventory from an arrived Djibouti-side train. + * This only advances warehouse inventory items assigned to the train and does not write to + * train schedules, wagon assignment, rescheduling, or booking payment state. + */ + async autoUnloadExportAtDjibouti( + scheduleId: string, + performedBy?: string, + ): Promise { + const result: AutoUnloadExportDjiboutiResult = { + unloadedCount: 0, + skippedCount: 0, + failedCount: 0, + results: [], + }; + + const [schedule] = await this.dataSource.query( + `SELECT ts.id, + ts.status, + oy.country AS "originCountry", + dy.country AS "destinationCountry", + dy.code AS "destinationCode", + dy.name AS "destinationName" + 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`); + } + + const direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + if (direction !== 'EXPORT') { + throw new BadRequestException(`Train schedule route is ${direction}, not EXPORT`); + } + if (!this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + throw new BadRequestException('Train schedule destination is not Djibouti / Doraleh / DMP / DCT / Nagad'); + } + if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) { + throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); + } + + const items: Array<{ + bookingId: string; + inventoryId: string | null; + inventoryStatus: string | null; + bookingStatus: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + itemType: 'CONTAINER' | 'CARGO'; + itemId: string | null; + containerNumber: string | null; + }> = await this.dataSource.query( + `WITH assigned AS ( + SELECT b.id AS booking_id, + b.status AS booking_status, + inv.id AS inventory_id, + inv.status AS inventory_status, + inv.warehouse_id, + inv.yard_id, + inv.zone_id + 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.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.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CONTAINER' AS "itemType", + c.id AS "itemId", + c.container_number AS "containerNumber" + 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.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CARGO' AS "itemType", + cg.id AS "itemId", + NULL AS "containerNumber" + FROM assigned a + JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CARGO' AS "itemType", + a.inventory_id AS "itemId", + NULL AS "containerNumber" + 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)`, + [scheduleId], + ); + + const seenInventory = new Set(); + const now = new Date(); + + await this.dataSource.transaction(async (manager) => { + for (const item of items) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId ?? undefined, + containerNumber: item.containerNumber, + status: 'SKIPPED', + reason, + }); + }; + const fail = (reason: string) => { + result.failedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId ?? undefined, + containerNumber: item.containerNumber, + status: 'FAILED', + reason, + }); + }; + + if (!item.inventoryId || !item.warehouseId || !item.yardId || !item.zoneId) { + skip('No warehouse inventory found for assigned export item'); + continue; + } + if (seenInventory.has(item.inventoryId)) { + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Unloaded at Djibouti Port', + }); + continue; + } + + const currentStatus = item.inventoryStatus ?? item.bookingStatus; + if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) { + skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`); + continue; + } + + try { + await manager.getRepository(WarehouseInventory).update(item.inventoryId, { + status: 'UNLOADED_AT_DJIBOUTI_PORT', + unloadedAt: now, + arrivedAt: now, + notes: 'Unloaded at Djibouti Port', + }); + await manager.getRepository(WarehouseInventoryMovement).save( + manager.getRepository(WarehouseInventoryMovement).create({ + inventoryId: item.inventoryId, + fromWarehouseId: item.warehouseId, + fromYardId: item.yardId, + fromZoneId: item.zoneId, + toWarehouseId: item.warehouseId, + toYardId: item.yardId, + toZoneId: item.zoneId, + remarks: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT', + movedBy: performedBy ?? null, + movedAt: now, + }), + ); + await this.activityLog.record( + { + activityType: 'INVENTORY_UNLOADED', + inventoryId: item.inventoryId, + warehouseId: item.warehouseId, + description: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT', + performedBy, + }, + manager, + ); + seenInventory.add(item.inventoryId); + result.unloadedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Unloaded at Djibouti Port', + }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + } + }); + + if (result.unloadedCount > 0) { + const document = await this.interchangeDocuments.generateFromSchedule({ + scheduleId, + direction: 'EXPORT', + handoverLocation: schedule.destinationName ?? 'Djibouti Port', + handoverFrom: 'EDR', + handoverTo: 'Djibouti Port Operator', + portOperatorName: 'Doraleh Multipurpose Port', + generatedBy: performedBy, + remarks: 'Generated after export unloading at Djibouti Port', + }); + result.interchangeDocument = { + id: document.id, + documentNo: document.documentNo, + status: document.status, + }; + } + + return result; + } + /** * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING. @@ -1016,6 +1351,7 @@ export class WarehouseInventoryService { manager, ); }); + await this.acceptLastMileIfRequested(item.bookingId); result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' }); } else { result.results.push({ inventoryId, status: 'INSPECTED' }); @@ -1028,6 +1364,20 @@ export class WarehouseInventoryService { // ── Receive ────────────────────────────────────────────────────────────── + private async acceptLastMileIfRequested(bookingId?: string | null): Promise { + if (!bookingId) return; + const [booking] = await this.dataSource.query( + `SELECT reference, + last_mile_delivery_address AS "lastMileDeliveryAddress" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + if (!booking?.reference || !booking.lastMileDeliveryAddress) return; + await this.lastMileService.acceptBooking(booking.reference); + } + async receive(dto: ReceiveWarehouseInventoryDto): Promise { const weight = Number(dto.weight) || 0; const volume = Number(dto.volume) || 0; @@ -1063,7 +1413,7 @@ export class WarehouseInventoryService { }), ); - await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); await this.activityLog.record( { @@ -1082,15 +1432,148 @@ export class WarehouseInventoryService { return this.findById(id); } + async move(id: string, dto: MoveInventoryDto): Promise { + const movedId = await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + + if ( + item.warehouseId === dto.warehouseId && + item.yardId === dto.yardId && + item.zoneId === dto.zoneId + ) { + throw new BadRequestException('Destination location is the same as current location'); + } + + const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + const weight = Number(item.weight) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + if (item.warehouseId !== dto.warehouseId) { + this.assertCapacity('Warehouse', warehouse, weight, Number(item.volume) || 0, containerCount); + } + if (item.yardId !== dto.yardId) { + this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount); + } + this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + + await this.applyCapacityDelta( + manager, + { + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + }, + -weight, + -(Number(item.volume) || 0), + -containerCount, + ); + + await this.applyCapacityDelta(manager, dto, weight, Number(item.volume) || 0, containerCount); + + item.warehouseId = dto.warehouseId; + item.yardId = dto.yardId; + item.zoneId = dto.zoneId; + if (dto.remarks?.trim()) { + const existingNotes = item.notes?.trim(); + item.notes = existingNotes + ? `${existingNotes}\nMove: ${dto.remarks.trim()}` + : `Move: ${dto.remarks.trim()}`; + } + + const saved = await manager.getRepository(WarehouseInventory).save(item); + return saved.id; + }); + + return this.findById(movedId); + } + // ── Lifecycle transitions ──────────────────────────────────────────────── - store(id: string, performedBy?: string): Promise { - return this.transition(id, 'STORED', { - timestampField: 'storedAt', - activityType: 'INVENTORY_STORED', - description: 'Inventory stored', - performedBy, + async store(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'STORED'); + + const criteria = await this.getInventoryAllocationCriteria(item); + const ruleLocation = await this.allocation.resolveLocation(criteria); + const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + + if (!location) { + throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); + } + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0; + + await this.dataSource.transaction(async (manager) => { + const locked = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + this.assertTransition(locked.status, 'STORED'); + + if ( + locked.warehouseId !== location.warehouseId || + locked.yardId !== location.yardId || + locked.zoneId !== location.zoneId + ) { + const { warehouse, yard, zone } = await this.validateLocation(manager, location); + this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', yard, weight, volume, containerCount); + this.assertCapacity('Zone', zone, weight, volume, containerCount); + + await this.applyCapacityDelta( + manager, + { + warehouseId: locked.warehouseId, + yardId: locked.yardId, + zoneId: locked.zoneId, + }, + -weight, + -volume, + -containerCount, + ); + await this.applyCapacityDelta(manager, location, weight, volume, containerCount); + } + + await manager.getRepository(WarehouseInventory).update(id, { + status: 'STORED', + storedAt: new Date(), + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + notes: this.appendNote( + locked.notes, + ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, + ), + }); + + await this.activityLog.record( + { + activityType: 'INVENTORY_STORED', + inventoryId: id, + warehouseId: location.warehouseId, + description: ruleLocation?.rule + ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` + : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + performedBy, + }, + manager, + ); }); + + return this.findById(id); } async reserve(dto: ReserveInventoryDto): Promise { @@ -1204,6 +1687,80 @@ export class WarehouseInventoryService { return this.findById(id); } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + const item = await this.findById(id); + if (!item.releaseDate) { + throw new BadRequestException('A release order must be issued before downloading the exit paper'); + } + + const [row] = await this.dataSource.query( + `SELECT inv.id, + inv.release_order_reference AS "releaseOrderReference", + inv.release_date AS "releaseDate", + inv.quantity, + inv.weight, + inv.status, + b.id AS "bookingId", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + company.name AS "customerName", + container.container_number AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.name AS "zoneName", + zone.code AS "zoneCode" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON ( + (inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = b.id) + ) AND container.deleted_at IS NULL + LEFT JOIN freight.cargoes cargo ON ( + (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) + ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [id], + ); + + const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`; + const bookingReference = row?.bookingReference || item.bookingId || 'N/A'; + const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date(); + const html = this.buildReleaseDocumentHtml({ + reference, + issuedAt, + bookingReference, + bookingStatus: row?.bookingStatus ?? null, + customerName: row?.customerName ?? null, + freightType: row?.freightType ?? null, + tradeDirection: row?.tradeDirection ?? null, + containerNumber: row?.containerNumber ?? null, + cargoDescription: row?.cargoDescription ?? null, + quantity: Number(row?.quantity ?? item.quantity ?? 0), + weight: Number(row?.weight ?? item.weight ?? 0), + warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null, + yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, + zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, + inventoryStatus: row?.status ?? item.status, + }); + + return { + filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.pdfService.htmlToPdfBuffer(html), + }; + } + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ async deliver(id: string, dto: DeliverInventoryDto): Promise { const item = await this.findById(id); @@ -1226,7 +1783,17 @@ export class WarehouseInventoryService { }); // Goods physically leave the warehouse on pickup — free up capacity. - await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + await this.applyCapacityDelta( + manager, + { + warehouseId: item.warehouseId, + yardId: item.yardId, + zoneId: item.zoneId, + }, + -weight, + -volume, + -containerCount, + ); // Proof of delivery is captured on the linked cargo. if (item.cargoId) { @@ -1361,97 +1928,48 @@ export class WarehouseInventoryService { }); } - async dispatch(id: string, performedBy?: string): Promise { - const item = await this.findById(id); - this.assertTransition(item.status, 'DISPATCHED'); - - const weight = Number(item.weight) || 0; - const volume = Number(item.volume) || 0; - const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(WarehouseInventory).update(id, { - status: 'DISPATCHED', - dispatchedAt: new Date(), - }); - // Item physically leaves the warehouse — free up capacity. - await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); - await this.activityLog.record( - { - activityType: 'INVENTORY_DISPATCHED', - inventoryId: id, - warehouseId: item.warehouseId, - description: 'Inventory dispatched', - performedBy, - }, - manager, - ); + dispatch(id: string, performedBy?: string): Promise { + return this.transition(id, 'DISPATCHED', { + timestampField: 'dispatchedAt', + activityType: 'INVENTORY_DISPATCHED', + description: 'Inventory dispatched', + performedBy, }); - - return this.findById(id); } - // ── Movement ────────────────────────────────────────────────────────────── - - async move(id: string, dto: MoveInventoryDto): Promise { - const item = await this.findById(id); - if (item.status === 'DISPATCHED') { - throw new BadRequestException('Dispatched inventory cannot be moved'); - } - - const weight = Number(item.weight) || 0; - const volume = Number(item.volume) || 0; - const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; - - const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }; - - await this.dataSource.transaction(async (manager) => { - const { warehouse } = await this.validateLocation(manager, dto); - - // Capacity check at the destination (item is added there). - const dest = await this.loadLocation(manager, dto); - this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount); - this.assertCapacity('Yard', dest.yard, weight, volume, containerCount); - this.assertCapacity('Zone', dest.zone, weight, volume, containerCount); - - // Free the old location, occupy the new one. - await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1); - await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); - - await manager.getRepository(WarehouseInventory).update(id, { - warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - }); - - await manager.getRepository(WarehouseInventoryMovement).save( - manager.getRepository(WarehouseInventoryMovement).create({ - inventoryId: id, - fromWarehouseId: from.warehouseId, - fromYardId: from.yardId, - fromZoneId: from.zoneId, - toWarehouseId: dto.warehouseId, - toYardId: dto.yardId, - toZoneId: dto.zoneId, - remarks: dto.remarks?.trim() ?? null, - movedBy: dto.movedBy ?? 'system', - movedAt: new Date(), - }), - ); - - await this.activityLog.record( - { - activityType: 'INVENTORY_MOVED', - inventoryId: id, - warehouseId: warehouse.id, - description: dto.remarks?.trim() || 'Inventory moved', - performedBy: dto.movedBy, - }, - manager, - ); + async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise { + const warehouses = await this.dataSource.getRepository(Warehouse).find({ + where: { + status: 'ACTIVE', + ...(filter.facilityId ? { stationId: filter.facilityId } : {}), + ...(filter.warehouseId ? { id: filter.warehouseId } : {}), + }, }); + const inventory = await this.findAll(filter); + const today = new Date(); - return this.findById(id); + const byStatus = inventory.reduce>((acc, item) => { + acc[item.status] = (acc[item.status] ?? 0) + 1; + return acc; + }, {}); + + return { + totalWarehouses: warehouses.length, + totalInventory: inventory.length, + receivedToday: inventory.filter((item) => { + const arrivedAt = item.arrivedAt ?? item.createdAt; + return ( + arrivedAt.getFullYear() === today.getFullYear() && + arrivedAt.getMonth() === today.getMonth() && + arrivedAt.getDate() === today.getDate() + ); + }).length, + stored: byStatus.STORED ?? 0, + reserved: byStatus.RESERVED ?? 0, + readyForLoading: byStatus.READY_FOR_LOADING ?? 0, + loaded: byStatus.LOADED ?? 0, + dispatched: byStatus.DISPATCHED ?? 0, + }; } findMovements(id: string): Promise { @@ -1468,6 +1986,145 @@ export class WarehouseInventoryService { // ── Inquiry (Batch 1) ────────────────────────────────────────────────── async inquiry(filter: InquiryWarehouseInventoryDto): Promise { + const bookingReference = (filter.bookingReference ?? filter.bookingNumber)?.trim(); + if (bookingReference) { + const params: unknown[] = [`%${bookingReference}%`]; + const where = ['b.reference ILIKE $1', 'b.deleted_at IS NULL']; + + if (filter.containerNumber?.trim()) { + params.push(`%${filter.containerNumber.trim()}%`); + where.push(`container.container_number ILIKE $${params.length}`); + } + if (filter.cargoType?.trim()) { + params.push(`%${filter.cargoType.trim()}%`); + where.push(`cargo_type.cargo_type_name ILIKE $${params.length}`); + } + if (filter.goodsName?.trim()) { + params.push(`%${filter.goodsName.trim()}%`); + where.push(`(inv.notes ILIKE $${params.length} OR cargo.description ILIKE $${params.length})`); + } + if (filter.warehouseId) { + params.push(filter.warehouseId); + where.push(`inv.warehouse_id = $${params.length}`); + } + if (filter.yardId) { + params.push(filter.yardId); + where.push(`inv.yard_id = $${params.length}`); + } + if (filter.zoneId) { + params.push(filter.zoneId); + where.push(`inv.zone_id = $${params.length}`); + } + if (filter.status) { + params.push(filter.status); + where.push(`inv.status = $${params.length}`); + } + + const rows = await this.dataSource.query( + `SELECT COALESCE(inv.id::text, b.id::text) AS "id", + inv.id AS "inventoryId", + b.id AS "bookingId", + b.reference AS "bookingReference", + b.reference AS "bookingNumber", + b.status AS "bookingStatus", + company.name AS "customerName", + container.container_number AS "containerNumber", + cargo_type.cargo_type_name AS "cargoType", + cargo.description AS "cargoDescription", + inv.goods_id AS "goodsId", + wh.id AS "warehouseId", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.id AS "yardId", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.id AS "zoneId", + zone.name AS "zoneName", + zone.code AS "zoneCode", + inv.status, + ts.train_number AS "trainNumber", + ts.status AS "trainStatus", + oy.code AS "originCode", + dy.code AS "destinationCode", + CASE + WHEN inv.id IS NOT NULL THEN concat_ws(' / ', wh.code, yard.code, zone.code) + WHEN ts.status = 'ARRIVED' THEN concat('Arrived at ', COALESCE(dy.code, 'destination'), ' - awaiting unload') + WHEN ts.status = 'DISPATCHED' THEN concat('In transit: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?')) + WHEN ts.id IS NOT NULL THEN concat('Scheduled: ', COALESCE(oy.code, '?'), ' -> ', COALESCE(dy.code, '?')) + ELSE 'No warehouse inventory yet' + END AS "locationSummary", + COALESCE(inv.quantity, 0) AS quantity, + COALESCE(inv.weight, b.cargo_total_weight_vgm, 0) AS weight, + inv.arrived_at AS "arrivedAt", + inv.ready_for_loading_at AS "readyForLoadingAt" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON ( + (inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = b.id) + ) AND container.deleted_at IS NULL + LEFT JOIN freight.cargoes cargo ON ( + (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) + ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN LATERAL ( + SELECT ts_inner.* + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts_inner ON ts_inner.id = tsb.train_schedule_id + WHERE tsb.booking_id = b.id + AND tsb.deleted_at IS NULL + AND ts_inner.deleted_at IS NULL + ORDER BY ts_inner.scheduled_departure_date DESC NULLS LAST + LIMIT 1 + ) ts ON TRUE + 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 inv.created_at DESC NULLS LAST, b.created_at DESC`, + params, + ); + + return rows.map((row: Record) => ({ + id: String(row.id), + inventoryId: (row.inventoryId as string | null) ?? null, + bookingId: (row.bookingId as string | null) ?? null, + bookingReference: (row.bookingReference as string | null) ?? null, + bookingNumber: (row.bookingNumber as string | null) ?? null, + bookingStatus: (row.bookingStatus as string | null) ?? null, + customerName: (row.customerName as string | null) ?? null, + containerNumber: (row.containerNumber as string | null) ?? null, + cargoType: (row.cargoType as string | null) ?? null, + cargoDescription: (row.cargoDescription as string | null) ?? null, + goodsId: (row.goodsId as string | null) ?? null, + warehouse: row.warehouseId + ? { id: row.warehouseId as string, name: row.warehouseName as string, code: row.warehouseCode as string } + : null, + yard: row.yardId + ? { id: row.yardId as string, name: row.yardName as string, code: row.yardCode as string } + : null, + zone: row.zoneId + ? { id: row.zoneId as string, name: row.zoneName as string, code: row.zoneCode as string } + : null, + status: (row.status as string | null) ?? null, + trainNumber: (row.trainNumber as string | null) ?? null, + trainStatus: (row.trainStatus as string | null) ?? null, + route: + row.originCode || row.destinationCode + ? `${row.originCode ?? '?'} -> ${row.destinationCode ?? '?'}` + : null, + locationSummary: (row.locationSummary as string | null) ?? null, + quantity: Number(row.quantity) || 0, + weight: Number(row.weight) || 0, + arrivedAt: (row.arrivedAt as Date | null) ?? null, + readyForLoadingAt: (row.readyForLoadingAt as Date | null) ?? null, + })); + } + const qb = this.dataSource .getRepository(WarehouseInventory) .createQueryBuilder('inv') @@ -1476,8 +2133,20 @@ export class WarehouseInventoryService { .leftJoinAndSelect('inv.zone', 'zone') .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') - .leftJoin('freight.containers', 'container', 'container.id = inv.container_id') - .leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id') + .leftJoin( + 'freight.containers', + 'container', + `((inv.container_id IS NOT NULL AND container.id = inv.container_id) + OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id)) + AND container.deleted_at IS NULL`, + ) + .leftJoin( + 'freight.cargoes', + 'cargo', + `((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) + OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id)) + AND cargo.deleted_at IS NULL`, + ) .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') .addSelect('booking.reference', 'b_reference') .addSelect('company.name', 'c_name') @@ -1486,9 +2155,6 @@ export class WarehouseInventoryService { .addSelect('cargo_type.cargo_type_name', 'cgt_name') .orderBy('inv.created_at', 'DESC'); - if (filter.bookingNumber?.trim()) { - qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` }); - } if (filter.containerNumber?.trim()) { qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` }); } @@ -1509,8 +2175,11 @@ export class WarehouseInventoryService { const row = raw[index] ?? {}; return { id: inv.id, + inventoryId: inv.id, bookingId: inv.bookingId ?? null, + bookingReference: row.b_reference ?? null, bookingNumber: row.b_reference ?? null, + bookingStatus: null, customerName: row.c_name ?? null, containerNumber: row.ct_number ?? null, cargoType: row.cgt_name ?? null, @@ -1522,6 +2191,12 @@ export class WarehouseInventoryService { yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null, zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null, status: inv.status, + trainNumber: null, + trainStatus: null, + route: null, + locationSummary: inv.warehouse + ? [inv.warehouse.code, inv.yard?.code, inv.zone?.code].filter(Boolean).join(' / ') + : null, quantity: Number(inv.quantity), weight: Number(inv.weight), arrivedAt: inv.arrivedAt ?? null, @@ -1566,6 +2241,109 @@ export class WarehouseInventoryService { return this.findById(id); } + private buildReleaseDocumentHtml(data: { + reference: string; + issuedAt: Date; + bookingReference: string; + bookingStatus: string | null; + customerName: string | null; + freightType: string | null; + tradeDirection: string | null; + containerNumber: string | null; + cargoDescription: string | null; + quantity: number; + weight: number; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryStatus: string | null; + }): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const issuedAt = data.issuedAt.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + const rows = [ + ['Booking reference', data.bookingReference], + ['Customer', data.customerName], + ['Booking status', data.bookingStatus], + ['Freight type', data.freightType], + ['Trade direction', data.tradeDirection], + ['Container number', data.containerNumber], + ['Cargo / goods', data.cargoDescription], + ['Quantity', data.quantity], + ['Weight', `${data.weight.toLocaleString()} kg`], + ['Warehouse', data.warehouse], + ['Yard', data.yard], + ['Zone', data.zone], + ['Inventory status', data.inventoryStatus], + ]; + + return ` + + + + Warehouse Release Exit Paper + + + +
+
+
+
EDR Warehouse Operations
+

Warehouse Release / Exit Paper

+
+
+ Release reference + ${esc(data.reference)} + Issued: ${esc(issuedAt)} +
+
+
+ This document authorizes the listed booking/goods to leave the warehouse after release checks. +
+ + + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
+
Warehouse officer name / signature / date
+
Customer or driver name / signature / date
+
+ +
+ +`; + } + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { throw new BadRequestException(`Invalid transition ${from} → ${to}`); @@ -1574,22 +2352,7 @@ export class WarehouseInventoryService { private async validateLocation( manager: EntityManager, - dto: { warehouseId: string; yardId: string; zoneId: string }, - ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { - const { warehouse, yard, zone } = await this.loadLocation(manager, dto); - - if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE'); - if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse'); - if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE'); - if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard'); - if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE'); - - return { warehouse, yard, zone }; - } - - private async loadLocation( - manager: EntityManager, - dto: { warehouseId: string; yardId: string; zoneId: string }, + dto: LocationRef, ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } }); if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); @@ -1610,12 +2373,210 @@ export class WarehouseInventoryService { } } + private appendNote(existing: string | null | undefined, note: string): string { + const trimmed = existing?.trim(); + return trimmed ? `${trimmed}\n${note}` : note; + } + + private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise { + const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null; + + if (!item.bookingId) { + return { + freightType: fallbackFreightType, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + const [row]: Array<{ + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + containerStatus: string | null; + originCountry: string | null; + destinationCountry: string | null; + }> = await this.dataSource.query( + `SELECT b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode", + COALESCE(selected_container.status, booking_container.status) AS "containerStatus", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.bookings b + 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.containers selected_container + ON selected_container.id = $2 AND selected_container.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT c.status + FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.created_at ASC + LIMIT 1 + ) booking_container ON true + WHERE b.id = $1 AND b.deleted_at IS NULL + LIMIT 1`, + [item.bookingId, item.containerId], + ); + + if (!row) { + return { + freightType: fallbackFreightType, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + const derivedDirection = deriveTradeDirection( + { country: row.originCountry }, + { country: row.destinationCountry }, + ); + + return { + freightType: row.freightType ?? fallbackFreightType, + tradeDirection: row.tradeDirection ?? derivedDirection, + cargoTypeCode: row.cargoTypeCode, + containerStatus: row.containerStatus, + requiresInspection: item.inspectionStatus !== 'PASSED', + }; + } + + private yardTypeFor(criteria: InventoryAllocationCriteria): string { + const freightType = criteria.freightType?.toUpperCase(); + if (freightType === 'CONTAINER') return 'CONTAINER_YARD'; + if (freightType === 'BULK') return 'BULK_YARD'; + return 'GENERAL_CARGO_YARD'; + } + + private zoneTypeFor(criteria: InventoryAllocationCriteria): string { + const freightType = criteria.freightType?.toUpperCase(); + if (freightType === 'CONTAINER') return 'CONTAINER_ZONE'; + if (freightType === 'BULK') return 'BULK_ZONE'; + return 'GENERAL_CARGO_ZONE'; + } + + private async pickCapacityBalancedStorageLocation( + item: WarehouseInventory, + criteria: InventoryAllocationCriteria, + ): Promise { + const weight = Number(item.weight) || 0; + const containerCount = item.containerId ? Math.max(1, Math.round(Number(item.quantity) || 1)) : 0; + const yardType = this.yardTypeFor(criteria); + const zoneType = this.zoneTypeFor(criteria); + + const query = async (warehouseId: string | null) => { + const [row]: Array<{ + warehouseId: string; + facilityId: string | null; + warehouseName: string | null; + yardId: string; + yardName: string | null; + yardCode: string | null; + zoneId: string; + zoneName: string | null; + zoneCode: string | null; + }> = await this.dataSource.query( + `SELECT wh.id AS "warehouseId", + wh.facility_id AS "facilityId", + wh.name AS "warehouseName", + yard.id AS "yardId", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.id AS "zoneId", + zone.name AS "zoneName", + zone.code AS "zoneCode" + FROM freight.warehouses wh + JOIN freight.warehouse_yards yard + ON yard.warehouse_id = wh.id + AND yard.deleted_at IS NULL + AND yard.status = 'ACTIVE' + AND yard.is_active = true + JOIN freight.warehouse_zones zone + ON zone.yard_id = yard.id + AND zone.deleted_at IS NULL + AND zone.status = 'ACTIVE' + AND zone.is_active = true + WHERE wh.deleted_at IS NULL + AND wh.status = 'ACTIVE' + AND wh.is_active = true + AND ($1::uuid IS NULL OR wh.id = $1::uuid) + AND (COALESCE(yard.max_weight, yard.capacity_weight) IS NULL + OR yard.current_weight::numeric + $4::numeric <= COALESCE(yard.max_weight, yard.capacity_weight)) + AND (COALESCE(zone.max_weight, zone.capacity_weight) IS NULL + OR zone.current_weight::numeric + $4::numeric <= COALESCE(zone.max_weight, zone.capacity_weight)) + AND (yard.capacity_containers IS NULL + OR yard.current_containers + $5::int <= yard.capacity_containers) + AND (zone.capacity_containers IS NULL + OR zone.current_containers + $5::int <= zone.capacity_containers) + ORDER BY + CASE WHEN yard.type = $2 THEN 0 ELSE 1 END, + CASE WHEN zone.type = $3 THEN 0 ELSE 1 END, + ( + CASE WHEN yard.capacity_weight IS NULL OR yard.capacity_weight = 0 THEN 0 + ELSE yard.current_weight::numeric / yard.capacity_weight::numeric END + + + CASE WHEN yard.capacity_containers IS NULL OR yard.capacity_containers = 0 THEN 0 + ELSE yard.current_containers::numeric / yard.capacity_containers::numeric END + + + CASE WHEN zone.capacity_weight IS NULL OR zone.capacity_weight = 0 THEN 0 + ELSE zone.current_weight::numeric / zone.capacity_weight::numeric END + + + CASE WHEN zone.capacity_containers IS NULL OR zone.capacity_containers = 0 THEN 0 + ELSE zone.current_containers::numeric / zone.capacity_containers::numeric END + ) ASC, + yard.code ASC, + zone.code ASC + LIMIT 1`, + [warehouseId, yardType, zoneType, weight, containerCount], + ); + return row; + }; + + const row = (await query(item.warehouseId)) ?? (await query(null)); + if (!row) return null; + + return { + warehouseId: row.warehouseId, + facilityId: row.facilityId, + yardId: row.yardId, + zoneId: row.zoneId, + rule: null, + path: [row.warehouseName, row.yardCode ?? row.yardName, row.zoneCode ?? row.zoneName] + .filter(Boolean) + .join(' -> '), + }; + } + private async getBookingStatus(bookingId: string): Promise { - const rows = await this.dataSource.query( + const [row]: Array<{ status: string | null }> = await this.dataSource.query( 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', [bookingId], ); - return rows?.[0]?.status ?? null; + return row?.status ?? null; + } + + private async attachBookingSummaries(items: WarehouseInventory[]): Promise { + const bookingIds = [...new Set(items.map((item) => item.bookingId).filter(Boolean))] as string[]; + if (bookingIds.length === 0) return; + + const rows: BookingSummaryRow[] = await this.dataSource.query( + `SELECT b.id, b.reference, b.status, company.name AS customer + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, + [bookingIds], + ); + const summaries = new Map(rows.map((row) => [row.id, row])); + + items.forEach((item) => { + const summary = item.bookingId ? summaries.get(item.bookingId) : undefined; + if (!summary) return; + Object.assign(item, { + bookingReference: summary.reference, + bookingStatus: summary.status, + customerName: summary.customer, + }); + }); } /** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */ @@ -1665,25 +2626,24 @@ export class WarehouseInventoryService { private async applyCapacityDelta( manager: EntityManager, - warehouseId: string, - yardId: string, - zoneId: string, - weight: number, - volume: number, - containers: number, - sign: 1 | -1, + location: LocationRef, + weightAdd: number, + volumeAdd: number, + containerAdd: number, ): Promise { - const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager); const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [ - [Warehouse, warehouseId], - [WarehouseYard, yardId], - [WarehouseZone, zoneId], + [Warehouse, location.warehouseId], + [WarehouseYard, location.yardId], + [WarehouseZone, location.zoneId], ]; for (const [entity, id] of targets) { - if (weight) await apply(entity, { id }, 'currentWeight', weight); - if (volume) await apply(entity, { id }, 'currentVolume', volume); - if (containers) await apply(entity, { id }, 'currentContainers', containers); + if (weightAdd > 0) await manager.increment(entity, { id }, 'currentWeight', weightAdd); + if (weightAdd < 0) await manager.decrement(entity, { id }, 'currentWeight', Math.abs(weightAdd)); + if (volumeAdd > 0) await manager.increment(entity, { id }, 'currentVolume', volumeAdd); + if (volumeAdd < 0) await manager.decrement(entity, { id }, 'currentVolume', Math.abs(volumeAdd)); + if (containerAdd > 0) await manager.increment(entity, { id }, 'currentContainers', containerAdd); + if (containerAdd < 0) await manager.decrement(entity, { id }, 'currentContainers', Math.abs(containerAdd)); } } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index d58a74b7d..29493bc9a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 333597618..715080612 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -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); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 3ee0dde82..c14cea7a4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index f65e4593e..3279e9092 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -13,6 +13,13 @@ export class WarehouseYardsService { private readonly warehousesService: WarehousesService, ) {} + findAll(): Promise { + return this.yardsRepository.findAll({ + relations: { warehouse: true, zones: true }, + order: { code: 'ASC' }, + }); + } + findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 30c4407f6..7d51feac3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index a2f3800cd..b4ae2e0de 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -13,6 +13,13 @@ export class WarehouseZonesService { private readonly yardsService: WarehouseYardsService, ) {} + findAll(): Promise { + return this.zonesRepository.findAll({ + relations: { yard: { warehouse: true } }, + order: { code: 'ASC' }, + }); + } + findByYard(yardId: string): Promise { return this.zonesRepository.findAll({ where: { yardId }, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 4a08d7f28..98c6f0222 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -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('app.cbeExchange') ?? {}, + }), ], controllers: [ WarehousesController, @@ -100,6 +112,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseInvoiceService, WarehouseSchedulingAdapterService, SchedulingReadFacade, + ContractPdfService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts new file mode 100644 index 000000000..a91c0293b --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts @@ -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); +}); diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts index f9e4e2af7..c5a49e629 100644 --- a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -102,6 +102,12 @@ export class Batch5TestDataSeeder { serviceTypeId: serviceType.id, status: 'PAID', paymentStatus: 'PAID', + scheduledDate: now, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, tradeDirection: 'EXPORT', freightType: 'BULK', cargoTotalWeightVgm: seed.weight, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 669ea2153..12cca3f53 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -51,6 +51,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), + perm('a1000001-0001-4000-8000-000000000023', 'edr_freight_app:bookings:clearance_view', 'View customs-clearance queue'), perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'), perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'), perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'), @@ -97,6 +98,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO export const FREIGHT_PERMS = { bookings: { view: 'edr_freight_app:bookings:view', + clearanceView: 'edr_freight_app:bookings:clearance_view', staffAccept: 'edr_freight_app:bookings:staff_accept', requestChanges: 'edr_freight_app:bookings:request_changes', reject: 'edr_freight_app:bookings:reject', @@ -171,15 +173,20 @@ export const ROLE_PERMISSION_PRESETS = { ...allRuleEngineViewKeys(), ], finance: [FREIGHT_PERMS.bookings.view], - // Global Logistics: reviews post-counter-sign clearance documents, uploads - // customs output documents, and finalizes the clearance gate. + // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of + // the general booking-request list (no bookings:view) — instead a dedicated + // clearance:view permission lists the clearance bookings. Reviews customer + // clearance documents, uploads customs output documents, and finalizes the + // clearance gate. globalLogistics: [ - FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.finalizeClearance, ], - // Marketing handles intake through contract (same as line staff here). + // Marketing handles intake through contract (same as line staff here) and, + // for non-customs bookings, reviews/finalizes the customer's clearance + // documents from the booking detail (customs bookings go to Global Logistics). marketing: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.staffAccept, @@ -190,6 +197,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.bookings.signStaff, + FREIGHT_PERMS.bookings.reviewDocuments, + FREIGHT_PERMS.bookings.finalizeClearance, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index d5ae772d4..ae48ada66 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -32,7 +32,7 @@ export class PricingDataSeeder { const prRepo = manager.getRepository(PriorityConfig); const rRepo = manager.getRepository(Rate); - await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.upsertReferenceData(manager, ctRepo, yRepo, slRepo); await this.seedDomesticRoute(manager, yRepo); await this.seedWeightLimits(wlRepo, ctRepo); await this.seedPriorityConfigs(prRepo); @@ -71,7 +71,6 @@ export class PricingDataSeeder { private async upsertReferenceData( manager: any, ctRepo: any, - stRepo: any, yRepo: any, slRepo: any, ): Promise { @@ -155,47 +154,7 @@ export class PricingDataSeeder { { conflictPaths: { code: true } }, ); - await stRepo.upsert( - [ - { - code: "RAIL_CONTAINER", - serviceName: "Rail Container Service", - description: "Standard rail container transport", - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 0, - isActive: true, - displayOrder: 1, - }, - { - code: "RAIL_FORWARDING", - serviceName: "Rail Forwarding Service", - description: "Rail transport with first/last mile and customs", - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: true, - includesCustoms: true, - priorityBonusPoints: 15, - isActive: true, - displayOrder: 2, - }, - { - code: "RAIL_BULK", - serviceName: "Rail Bulk Transport", - description: "Bulk commodity rail transport", - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 10, - isActive: true, - displayOrder: 3, - }, - ], - { conflictPaths: { code: true } }, - ); + await slRepo.upsert( [ @@ -238,59 +197,86 @@ export class PricingDataSeeder { { conflictPaths: { code: true } }, ); - await manager.getRepository(CargoType).upsert( + await this.seedCargoTypes(manager); + } + + /** + * Cargo types are a fixed two-level tree: two top-level groups — Bulk and + * Break Bulk — each with a set of commodity children. The groups are the + * stable parents the booking wizard renders; children carry the + * unit_of_measure used when reserving quantity (PER_TON for bulk commodities, + * PER_ITEM for break-bulk items like vehicles/machinery). + * + * Parents are upserted first, then re-read by code to resolve their ids so the + * children can be linked via parent_group_id (upsert doesn't return ids). + */ + private async seedCargoTypes(manager: any): Promise { + const repo = manager.getRepository(CargoType); + + const groups = [ + { code: "BULK", cargoTypeName: "Bulk", displayOrder: 1 }, + { code: "BREAK_BULK", cargoTypeName: "Break Bulk", displayOrder: 2 }, + ]; + await repo.upsert( + groups.map((g) => ({ ...g, isActive: true })), + { conflictPaths: { code: true } }, + ); + + const bulk = await repo.findOneBy({ code: "BULK" }); + const breakBulk = await repo.findOneBy({ code: "BREAK_BULK" }); + if (!bulk || !breakBulk) return; + + // Bulk commodities — measured by tonnage (PER_TON). + const bulkChildren = [ + { code: "SUGAR", cargoTypeName: "Sugar" }, + { code: "GRAIN", cargoTypeName: "Grain / Cereals" }, + { code: "WHEAT", cargoTypeName: "Wheat" }, + { code: "FERTILIZER", cargoTypeName: "Fertilizer" }, + { code: "CEMENT", cargoTypeName: "Cement / Clinker" }, + { code: "COAL", cargoTypeName: "Coal" }, + ]; + + // Break-bulk items — counted as whole units (PER_ITEM). + const breakBulkChildren = [ + { code: "CARS", cargoTypeName: "Cars / Vehicles" }, + { + code: "MACHINERY", + cargoTypeName: "Heavy Machinery", + requiresDirectorApproval: true, + }, + { + code: "STEEL", + cargoTypeName: "Steel / Rebar", + requiresDirectorApproval: true, + }, + { code: "PIPES", cargoTypeName: "Pipes" }, + { code: "TIMBER", cargoTypeName: "Timber" }, + ]; + + await repo.upsert( [ - { - code: "GRAIN", - cargoTypeName: "Grain / Cereals", - showFreeTextBox: false, - requiresDirectorApproval: false, + ...bulkChildren.map((c, i) => ({ + ...c, + parentGroupId: bulk.id, + unitOfMeasure: "PER_TON", isActive: true, - displayOrder: 1, - }, - { - code: "FERTILIZER", - cargoTypeName: "Fertilizer", - showFreeTextBox: false, - requiresDirectorApproval: false, + displayOrder: i + 1, + })), + ...breakBulkChildren.map((c, i) => ({ + ...c, + parentGroupId: breakBulk.id, + unitOfMeasure: "PER_ITEM", isActive: true, - displayOrder: 2, - }, - { - code: "CEMENT", - cargoTypeName: "Cement / Clinker", - showFreeTextBox: false, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 3, - }, - { - code: "STEEL", - cargoTypeName: "Steel / Rebar", - showFreeTextBox: false, - requiresDirectorApproval: true, - isActive: true, - displayOrder: 4, - }, - { - code: "MACHINERY", - cargoTypeName: "Heavy Machinery", - showFreeTextBox: false, - requiresDirectorApproval: true, - isActive: true, - displayOrder: 5, - }, - { - code: "OTHER_BULK", - cargoTypeName: "Other Bulk Cargo", - showFreeTextBox: false, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 6, - }, + displayOrder: i + 1, + })), ], { conflictPaths: { code: true } }, ); + + // Retire the old flat "Other Bulk Cargo" top-level type from earlier seeds so + // it no longer shows alongside the Bulk / Break Bulk groups. No-op on a fresh + // DB where it was never seeded. + await repo.update({ code: "OTHER_BULK" }, { isActive: false }); } private async seedDomesticRoute(manager: any, yRepo: any): Promise { @@ -448,7 +434,13 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { // ── Surcharges (trigger-based) ────────────────────────────────────── { appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" }, - { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" }, + // Reefer surcharge scales with the freight shape: container bookings bill + // per reefer container, bulk bookings bill per ton. The engine now honors + // each rate's unit, so both rows can coexist — only the matching one + // produces a non-zero line (the other multiplies by 0 and is dropped). + // Small test values (< 20) so the surcharge stays a minor add for now. + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" }, + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index e00544dc0..fbc19100a 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -89,6 +89,7 @@ export class WarehouseDemoSeeder { ): Promise => bookingRepo.save( bookingRepo.create({ + ...this.demoBookingDefaults(), reference, originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, @@ -237,6 +238,7 @@ export class WarehouseDemoSeeder { for (let i = 1; i <= 3; i++) { const b = await bookingRepo.save( bookingRepo.create({ + ...this.demoBookingDefaults(), reference: `WH-DEMO-ARR-${i}`, originYardId: djibYard.id, destinationYardId: ethYard.id, @@ -255,4 +257,15 @@ export class WarehouseDemoSeeder { ); } } + + private demoBookingDefaults(): Partial { + return { + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + }; + } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 462c97f9a..ac8249f4b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,705 +1,703 @@ -import { - Boxes, - Building2, - Container, - FileText, - LayoutDashboard, - LayoutGrid, - Network, - Package, - PackageCheck, - PackageOpen, - Paperclip, - Send, - Settings, - ShieldCheck, - SlidersHorizontal, - Train, - Truck, - Users, - Wallet, -} from "lucide-react"; -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; - -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; -import { useAuth } from "./auth/useAuth"; -import LoadingScreen from "./components/LoadingScreen"; -import LoginPage from "./pages/auth/LoginPage"; -import BookingContractPage from "./pages/bookings/BookingContractPage"; -import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; -import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import GlClearancePage from "./pages/bookings/GlClearancePage"; -import NewBookingPage from "./pages/bookings/NewBookingPage"; -import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; -import CustomersPage from "./pages/customers/CustomersPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import MyProfilePage from "./pages/dashboard/MyProfilePage"; -import OverviewPage from "./pages/dashboard/OverviewPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; -//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; -import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; -import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; -import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; -import RolesPage from "./pages/dashboard/user-management/RolesPage"; -import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; -import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -import FleetResourcePage from "./pages/fleet/FleetResourcePage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; -import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; -import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; -import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; -import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; -import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; -import FirstMilePage from "./pages/operations/FirstMilePage"; -import LastMilePage from "./pages/operations/LastMilePage"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; -import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; -import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; -import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; -import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; -import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; -import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; -import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; -import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; - -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "UM", - href: "/um", - icon: , - }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - ...demoItems, - ], - }, - { - title: "Operations", - items: [ - { - label: "Document Clearance", - href: "/dashboard/clearance", - icon: , - permission: FREIGHT_PERMS.bookings.reviewDocuments, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Warehouse Management", - items: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - }, - { - label: "Inventory", - href: "/dashboard/warehouse-inventory", - icon: , - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - }, - ], - }, - { - title: "Administration", - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - ], - }, -]; - -/** Keep only items the user is permitted to see; drop now-empty sections. */ -const filterSidebarByPermission = ( - sections: SidebarSection[], - user: ReturnType["user"], -): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { - if (!item.permission) return true; - const keys = Array.isArray(item.permission) - ? item.permission - : [item.permission]; - return keys.some((key) => hasFreightPermission(user, key)); - }; - - return sections - .map((section) => ({ - ...section, - items: section.items.filter(itemAllowed), - })) - .filter((section) => section.items.length > 0); -}; - -const DashboardShell = () => { - const navigate = useNavigate(); - const location = useLocation(); - const { user, logout } = useAuth(); - - const demoItems: SidebarItem[] = []; - - const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), - user, - ); - const displayName = user?.name?.en || user?.username || user?.email || "User"; - - return ( - - - - ); -}; - -const App = () => { - const { user, loading } = useAuth(); - - if (loading) { - return ; - } - - if (!user) { - return ( - - } /> - } /> - } /> - - ); - } - - return ( - - } /> - } /> - } /> - }> - } /> - } /> - - } /> - - - - } - /> - } /> - } /> - } /> - } /> - } - /> - - - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - - - - - } - /> - - - - } - /> - - } - /> - - - - } - /> - } /> - } /> - } /> - - } - /> - } /> - - } - /> - } /> - - } /> - } /> - - } /> - } /> - - - } /> - - ); -}; - -export default App; +import { + Boxes, + Building2, + Container, + FileText, + LayoutDashboard, + LayoutGrid, + Network, + Package, + PackageCheck, + PackageOpen, + Paperclip, + Send, + Settings, + SlidersHorizontal, + Train, + Truck, + Users, + Wallet, +} from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; + +import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; +import LoginPage from "./pages/auth/LoginPage"; +import BookingContractPage from "./pages/bookings/BookingContractPage"; +import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; +import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; +//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; +import RolesPage from "./pages/dashboard/user-management/RolesPage"; +import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import FirstMilePage from "./pages/operations/FirstMilePage"; +import LastMilePage from "./pages/operations/LastMilePage"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; +import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; +import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; +import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; +import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; + +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "UM", + href: "/um", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + title: "Warehouse Management", + items: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], + }, + { + title: "Administration", + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + // { + // label: "Train scheduling rules", + // href: "/dashboard/configuration/train-scheduling-rules", + // }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + ], + }, +]; + +/** Keep only items the user is permitted to see; drop now-empty sections. */ +const filterSidebarByPermission = ( + sections: SidebarSection[], + user: ReturnType["user"], +): SidebarSection[] => { + const itemAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; + + return sections + .map((section) => ({ + ...section, + items: section.items.filter(itemAllowed), + })) + .filter((section) => section.items.length > 0); +}; + +const DashboardShell = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = []; + + const sidebarSections = filterSidebarByPermission( + buildSidebarSections(demoItems), + user, + ); + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( + + + + ); +}; + +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + } /> + + ); + } + + return ( + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + } + /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } /> + } /> + + + } /> + + ); +}; + +export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 2fac40263..01a0db69a 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/auth/useAuth"; import { getNextPendingApprovalStep, isAllocateAction, + isClearanceNavAction, isContractNavAction, listRowHasActions, type BookingActionContext, @@ -38,6 +39,7 @@ export function BookingActionsMenu({ reference: row.reference, approvalSteps: row.approvalSteps, schedulingStatus: row.schedulingStatus, + customsClearingEnabled: row.customsClearingEnabled, }; const flow = useBookingActionDialog(row.id, context); @@ -46,10 +48,15 @@ export function BookingActionsMenu({ const goToContract = () => navigate(`/dashboard/booking-requests/${row.id}/contract`); + const goToClearanceTab = () => + navigate(`/dashboard/booking-requests/${row.id}?tab=clearance`); + const handleAction = (action: (typeof actions)[number]) => { onSuppressRowClick?.(); if (isContractNavAction(action.id)) { goToContract(); + } else if (isClearanceNavAction(action.id)) { + goToClearanceTab(); } else if (isAllocateAction(action.id)) { onAllocateBooking?.(); } else { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx index c0eaaaace..e5ca1dabd 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx @@ -2,9 +2,11 @@ import { Badge, ScrollArea, Tabs } from "@mantine/core"; import { CheckCircle, ClipboardCheck, + ClipboardList, FileSignature, Inbox, LayoutGrid, + ShieldCheck, Train, Wallet, XCircle, @@ -21,7 +23,9 @@ const TAB_ICONS: Record = { intake: , in_approval: , approved_contract: , + clearance: , payment: , + ops_review: , operations: , completed: , closed: , diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx index ae360c609..bb40823d8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx @@ -1,77 +1,16 @@ -import { useMemo, useState } from "react"; -import { ArrowRight, Building2, Package } from "lucide-react"; -import { - Accordion, - Badge, - Button, - Checkbox, - Group, - Paper, - Stack, - Text, - Title, -} from "@mantine/core"; +import { useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, Calendar, Package, User } from "lucide-react"; +import { Group } from "@mantine/core"; +import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; -import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; +import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; +import { bookingTable } from "@/components/bookings/booking-ui.styles"; import type { BookingListRow } from "@/types/booking"; -import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue"; - -function BookingQueueRow({ - booking, - selected, - disabled, - onToggle, -}: { - booking: BookingListRow; - selected: boolean; - disabled: boolean; - onToggle: () => void; -}) { - return ( - - - - - - {booking.reference} - {booking.isGovernment ? ( - }> - Government - - ) : null} - {booking.freightType} - {booking.schedulingStatus ? ( - {booking.schedulingStatus} - ) : null} - - {booking.customerLabel} - - {booking.originLabel} - - {booking.destinationLabel} - - - - {booking.serviceTypeLabel ? ( - - {booking.serviceTypeLabel} - {booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""} - - ) : null} - - - - ); -} +import { cn } from "@/lib/utils"; +import { Badge, DataTable, type ColumnDef } from "@edr/ui-common"; export function OperationsBookingQueue({ bookings, @@ -82,144 +21,144 @@ export function OperationsBookingQueue({ isLoading?: boolean; onAllocate: (bookingIds: string[]) => void; }) { - const { government, commercial } = useMemo( - () => groupBookingsForOperationsQueue(bookings), - [bookings], + const navigate = useNavigate(); + const suppressRowClickRef = useRef(false); + + const suppressRowClick = useCallback(() => { + suppressRowClickRef.current = true; + window.setTimeout(() => { + suppressRowClickRef.current = false; + }, 400); + }, []); + + const handleRowClick = useCallback( + (row: BookingListRow) => { + if (suppressRowClickRef.current) return; + navigate(`/dashboard/booking-requests/${row.id}`); + }, + [navigate], ); - const [govSelected, setGovSelected] = useState([]); - const [selectedByBucket, setSelectedByBucket] = useState>({}); - const allocatable = (row: BookingListRow) => - row.status === "PAID" && - canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus }); - - const govSelection = govSelected.length - ? govSelected - : government.filter(allocatable).map((b) => b.id); - - const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => { - const existing = selectedByBucket[bucketKey]; - if (existing) return existing; - return bucketBookings.filter(allocatable).map((b) => b.id); - }; - - const toggleGov = (bookingId: string) => { - setGovSelected((prev) => { - const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id); - return base.includes(bookingId) - ? base.filter((id) => id !== bookingId) - : [...base, bookingId]; - }); - }; - - const toggleBucket = (bucketKey: string, bookingId: string) => { - setSelectedByBucket((prev) => { - const current = prev[bucketKey] ?? []; - const next = current.includes(bookingId) - ? current.filter((id) => id !== bookingId) - : [...current, bookingId]; - return { ...prev, [bucketKey]: next }; - }); - }; - - if (isLoading) { - return Loading operations queue…; - } - - if (!government.length && !commercial.length) { - return ( - - No PAID bookings ready to allocate. - - ); - } + const columns: ColumnDef[] = [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ +
+
+ +

{booking.reference}

+ {booking.isGovernment ? ( + + Government + + ) : null} +
+

+ + {booking.customerLabel} +

+
+
+ ); + }, + }, + { + id: "route", + header: () => Route, + cell: ({ row }) => { + const booking = row.original; + return ( +
+
+ {booking.originLabel} + + {booking.destinationLabel} +
+
+ + {booking.tradeDirection} + + + {booking.freightType} + +
+
+ ); + }, + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( +
+ + {row.original.schedulingStatus ? ( + + ) : null} +
+ ), + }, + { + id: "scheduled", + header: () => Scheduled, + cell: ({ row }) => ( + + + {row.original.scheduledDate} + + ), + }, + { + id: "priority", + header: () => Priority, + cell: ({ row }) => , + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {row.original.paymentCurrency}{" "} + {row.original.totalAmount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + ), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( + onAllocate([row.original.id])} + /> + ), + }, + ]; return ( - - {government.length > 0 ? ( - - - - Government priority - - Served first — not grouped by 3-hour window - - - - {govSelection.length} selected - - - - - {government.map((booking) => ( - toggleGov(booking.id)} - /> - ))} - - - ) : null} - - {commercial.length > 0 ? ( - - {commercial.map((bucket) => { - const selected = bucketSelection(bucket.key, bucket.bookings); - return ( - - - - - {bucket.label} - - {bucket.bookings.length} commercial booking - {bucket.bookings.length === 1 ? "" : "s"} - - - - {selected.length} selected - - - - - - - {bucket.bookings.map((booking) => ( - toggleBucket(bucket.key, booking.id)} - /> - ))} - - - - ); - })} - - ) : null} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index a4976ec61..67851ab30 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -1,4 +1,4 @@ -import { Train, MapPin, ArrowRight } from "lucide-react"; +import { Train, MapPin, ArrowRight, FileText } from "lucide-react"; import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; @@ -44,6 +44,8 @@ export function BookingRouteServiceCard({ const serviceLabel = booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service"; + const includesCustoms = booking.serviceType?.includesCustoms; + const metrics = [ { label: "Trade direction", value: booking.tradeDirection }, { label: "Freight type", value: booking.freightType }, @@ -96,6 +98,47 @@ export function BookingRouteServiceCard({ ))} + + {includesCustoms ? ( + + + + + Customs clearing included automatically + + + + ) : booking.customsClearingAgent ? ( + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + + + + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx similarity index 51% rename from apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx rename to apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index cf51c4967..d8c0577fd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -5,15 +5,13 @@ import { Badge, Box, Button, - Card, FileButton, Group, Loader, + Paper, Progress, - ScrollArea, Stack, Text, - TextInput, Textarea, ThemeIcon, Tooltip, @@ -21,257 +19,63 @@ import { import { AlertCircle, CheckCircle2, - Clock, Download, ExternalLink, + FileCheck2, FileText, - Inbox, MessageSquareWarning, - Search, - ShieldCheck, Upload, - X, } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; -import { PageContainer } from "@/components/page/PageContainer"; -import { PageHeader } from "@/components/page/PageHeader"; +import { SectionCard } from "./SectionCard"; import { bookingsService } from "@/services/bookings.service"; -const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; - -export default function GlClearancePage() { - const qc = useQueryClient(); - const [selectedId, setSelectedId] = useState(null); - const [search, setSearch] = useState(""); - - // Bookings currently awaiting GL document review. - const { data: list, isLoading } = useQuery({ - queryKey: ["gl-clearance", "list"], - queryFn: () => - bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }), - }); - - const bookings = list?.items ?? []; - const filtered = useMemo(() => { - const q = search.trim().toLowerCase(); - if (!q) return bookings; - return bookings.filter( - (b) => - b.reference?.toLowerCase().includes(q) || - b.tradeDirection?.toLowerCase().includes(q) || - b.freightType?.toLowerCase().includes(q), - ); - }, [bookings, search]); - - const activeId = - selectedId && filtered.some((b) => b.id === selectedId) - ? selectedId - : (filtered[0]?.id ?? null); - - return ( - - } - > - {bookings.length} awaiting review - - } - /> - -
- {/* ── Review queue ─────────────────────────────────────────────── */} - - - - Review queue - - - {filtered.length} - - - - setSearch(e.currentTarget.value)} - placeholder="Search reference…" - size="xs" - radius="md" - mb="xs" - leftSection={} - rightSection={ - search ? ( - setSearch("")} - /> - ) : null - } - /> - - {isLoading ? ( - - - - Loading… - - - ) : filtered.length === 0 ? ( - - - - - - {search - ? "No bookings match your search." - : "Nothing awaiting document review."} - - - ) : ( - - - {filtered.map((b) => ( - setSelectedId(b.id)} - /> - ))} - - - )} - - - {/* ── Review panel ─────────────────────────────────────────────── */} - - {activeId ? ( - - qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] }) - } - /> - ) : ( - - )} - -
-
- ); +export interface ClearanceReviewSectionProps { + bookingId: string; + /** Called after any review/finalize mutation so the parent can refetch. */ + onChanged?: () => void; + /** Hide the inline progress summary (e.g. when the parent renders its own). */ + hideSummary?: boolean; } -/** A single booking row in the left-hand review queue. */ -function QueueItem({ - booking, - active, - onSelect, -}: { - booking: Freight.IBooking; - active: boolean; - onSelect: () => void; -}) { - return ( - - - - - {booking.reference} - - - - {booking.tradeDirection} - - - {booking.freightType} - - - - - - ); -} +const STATUS_META: Record< + Freight.DocumentReviewStatus, + { label: string; color: string } +> = { + APPROVED: { label: "Approved", color: "edr-green" }, + QUERIED: { label: "Queried", color: "red" }, + PENDING: { label: "Pending", color: "gray" }, +}; -function EmptyPanel() { - return ( - - - - - - - No booking selected - - - Pick a booking from the review queue to inspect its customer documents - and start clearance. - - - - ); -} - -function ClearanceReviewPanel({ +/** + * Staff-facing clearance document review: approve / query each customer + * document, upload customs output documents (customs bookings only) and + * finalize once every required document is approved. Shared by the Global + * Logistics clearance detail page (customs) and the Marketing booking detail + * (non-customs) — the only difference is the output-docs block, which renders + * only when the booking has a customs output set. + */ +export function ClearanceReviewSection({ bookingId, onChanged, -}: { - bookingId: string; - onChanged: () => void; -}) { + hideSummary, +}: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); const [openQuery, setOpenQuery] = useState>({}); const [outputFiles, setOutputFiles] = useState>({}); const { data: clearance, isLoading } = useQuery({ - queryKey: ["gl-clearance", bookingId], + queryKey: ["clearance", bookingId], queryFn: () => bookingsService.getClearance(bookingId), }); const refresh = () => { - qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] }); - onChanged(); + qc.invalidateQueries({ queryKey: ["clearance", bookingId] }); + qc.invalidateQueries({ queryKey: ["clearance", "list"] }); + onChanged?.(); }; const reviewMutation = useMutation({ @@ -292,8 +96,7 @@ function ClearanceReviewPanel({ }); const outputMutation = useMutation({ - mutationFn: () => - bookingsService.uploadClearanceOutput(bookingId, outputFiles), + mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles), onSuccess: () => { toast.success("Output documents uploaded"); setOutputFiles({}); @@ -323,7 +126,6 @@ function ClearanceReviewPanel({ [clearance], ); - // Review progress across the customer documents — drives the summary bar. const stats = useMemo(() => { const total = customerDocs.length; const approved = customerDocs.filter( @@ -333,120 +135,97 @@ function ClearanceReviewPanel({ (d) => d.reviewStatus === "QUERIED", ).length; const pending = total - approved - queried; - return { total, approved, queried, pending }; + const pct = total === 0 ? 0 : Math.round((approved / total) * 100); + return { total, approved, queried, pending, pct }; }, [customerDocs]); if (isLoading || !clearance) { return ( - - - - Loading clearance… - - + + + Loading clearance… + ); } - const progressPct = - stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100); - return ( - - {/* ── Progress summary ───────────────────────────────────────────── */} - - - - - Customer documents - - - Approve each document, or open a query to tell the customer what to - fix. - - - {clearance.allApproved ? ( - } - > - All approved - - ) : ( - } - > - Review pending - - )} - - - - - - - - + + {stats.approved}/{stats.total} approved - - - - {/* ── Document review list ───────────────────────────────────────── */} - - {customerDocs.map((doc) => ( - - setOpenQuery((o) => ({ ...o, [doc.fileKey]: open })) - } - onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))} - onApprove={() => - reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" }) - } - onQuery={() => - reviewMutation.mutate({ - fileKey: doc.fileKey, - status: "QUERIED", - note: queryNotes[doc.fileKey], - }) - } - busy={reviewMutation.isPending} - /> - ))} - - - {/* ── Customs output documents (GL-supplied) ─────────────────────── */} - {clearance.outputCode && ( - - - - - - - Customs output documents + } + > + + {!hideSummary && stats.total > 0 && ( + + + + + + + + + )} + {customerDocs.length === 0 ? ( + + No customer documents are required for this booking. - + ) : ( + customerDocs.map((doc) => ( + + setOpenQuery((o) => ({ ...o, [doc.fileKey]: open })) + } + onNote={(v) => + setQueryNotes((n) => ({ ...n, [doc.fileKey]: v })) + } + onApprove={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "APPROVED", + }) + } + onQuery={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "QUERIED", + note: queryNotes[doc.fileKey], + }) + } + busy={reviewMutation.isPending} + /> + )) + )} + + + + {clearance.outputCode && ( + {glDocs.map((doc) => ( - + {doc.label} {doc.required ? " *" : ""} @@ -460,7 +239,7 @@ function ClearanceReviewPanel({ href={doc.file.url} target="_blank" rel="noreferrer" - c="edr-blue" + c="edr-green" style={{ display: "flex" }} > @@ -506,7 +285,7 @@ function ClearanceReviewPanel({ Upload output documents - + )} {finalizeMutation.isError && ( @@ -517,14 +296,23 @@ function ClearanceReviewPanel({ )} - {/* ── Finalize bar ───────────────────────────────────────────────── */} - + - - {clearance.allApproved - ? "All required documents are approved. You can finalize clearance." - : "Approve every required document to unlock finalization."} - + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + + + + )} + + {/* Assign / Reassign modal */} 0 + ? schedule.trainSet.locomotives + : schedule.trainSet?.locomotive + ? [schedule.trainSet.locomotive] + : []; + const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status); const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0; const canDispatch = schedule.status === "SCHEDULED"; @@ -811,13 +819,13 @@ export default function TrainScheduleV2DetailPage() { 1 ? "Locomotives" : "Locomotive", + value: locomotives.length + ? locomotives.map((l) => l.code).join(" + ") + : "—", + hint: locomotives.length + ? `${locomotives.length} locomotive${locomotives.length > 1 ? "s" : ""}` + : "No locomotives assigned", icon: Train, }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 193caa0a5..899777fda 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -7,6 +7,7 @@ import { Group, Menu, Modal, + MultiSelect, Select, SimpleGrid, Stack, @@ -82,7 +83,7 @@ export default function TrainScheduleV2ListPage() { const [createOpen, setCreateOpen] = useState(false); const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); - const [locomotiveId, setLocomotiveId] = useState(""); + const [locomotiveIds, setLocomotiveIds] = useState([]); const schedulesQuery = useQuery( api.trainScheduling.scheduleList.queryOptions({ input: {} }), @@ -113,7 +114,7 @@ export default function TrainScheduleV2ListPage() { }, [selectedRoute]); useEffect(() => { - setLocomotiveId(""); + setLocomotiveIds([]); }, [routeId]); const allSchedules = schedulesQuery.data ?? []; @@ -147,6 +148,7 @@ export default function TrainScheduleV2ListPage() { s.origin, s.destination, s.locomotive?.code, + ...(s.locomotives ?? []).map((l) => l.code), s.freightType, s.status, ] @@ -229,21 +231,32 @@ export default function TrainScheduleV2ListPage() { }, { id: "loco", - header: "Locomotive", + header: "Locomotives", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - row.original.locomotive?.code ? ( + cell: ({ row }) => { + const locos = + row.original.locomotives && row.original.locomotives.length > 0 + ? row.original.locomotives + : row.original.locomotive + ? [row.original.locomotive] + : []; + if (!locos.length) { + return ( + + — + + ); + } + return ( - {row.original.locomotive.code} + {locos[0].code} + {locos.length > 1 ? ` +${locos.length - 1}` : ""} - ) : ( - - — - - ), + ); + }, }, { id: "metrics", @@ -331,13 +344,16 @@ export default function TrainScheduleV2ListPage() { }, [navigate, cancel.isPending, cancel, toast]); const handleCreate = async () => { - if (!routeId || !scheduleDate || !locomotiveId) { - toast({ title: "Select route, date, and locomotive", variant: "destructive" }); + if (!routeId || !scheduleDate || locomotiveIds.length < 2) { + toast({ + title: "Select route, date, and at least two locomotives", + variant: "destructive", + }); return; } try { const created = await create.mutateAsync({ - payload: { routeId, scheduleDate, locomotiveId }, + payload: { routeId, scheduleDate, locomotiveIds }, }); toast({ title: "Train schedule created" }); setCreateOpen(false); @@ -532,17 +548,25 @@ export default function TrainScheduleV2ListPage() { setScheduleDate(raw ? new Date(raw).toISOString() : ""); }} /> - - setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) - } - w={200} - /> - setDraft((f) => ({ ...f, zoneId: value ?? undefined }))} - w={180} - /> - + setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined })) + } + w={200} + /> + setDraft((f) => ({ ...f, zoneId: value ?? undefined }))} + w={180} + /> + + + + + {!selectedYardId ? ( + + Select a yard to view its zones. + + ) : ( void yardsQuery.refetch(), + message: 'Failed to load zones.', + onRetry: () => void zonesQuery.refetch(), } : undefined } /> - - - + )} + + + - {/* ZONES */} - - - - - setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))} + onChange={(value) => + setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined })) + } w={200} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 3f0958a7f..0407447d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -37,7 +37,7 @@ const STATUS_COLOR: Record = { CANCELLED: 'gray', }; -const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`; +const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`; const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—'); export default function WarehouseInvoicesPage() { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx index 93fcde493..3f9df44f6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx @@ -12,9 +12,7 @@ import { WarehouseTable, type WarehouseView, } from '@/components/warehouses'; -import { useQuery } from '@tanstack/react-query'; - -import { api } from '@/services/api'; +import { useWarehouses } from '@/hooks/useWarehouses'; import type { Warehouse, WarehouseFilter } from '@/types/warehouse'; export default function WarehouseListPage() { @@ -30,9 +28,7 @@ export default function WarehouseListPage() { [filter, debouncedSearch], ); - const { data, isLoading, isError } = useQuery( - api.warehouses.list.queryOptions({ input: { filter: queryFilter } }), - ); + const { data, isLoading, isError } = useWarehouses(queryFilter); const warehouses = data ?? []; const openCreate = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 81e998385..3d07bf5d0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -1,26 +1,34 @@ import { useState } from 'react'; import { ActionIcon, + Alert, Badge, Button, Card, Group, + Loader, Modal, NumberInput, Select, Stack, Tabs, + Table, Text, TextInput, } from '@mantine/core'; -import { Plus, Trash2 } from 'lucide-react'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { Info, Plus, Trash2 } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; -import { useMutation, useQuery } from '@tanstack/react-query'; - -import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { + useAllWarehouseYards, + useAllocationRules, + useCreateAllocationRule, + useCreateFeeRule, + useDeleteAllocationRule, + useDeleteFeeRule, + useFeeRules, +} from '@/hooks/useWarehouses'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; const FREIGHT = [ @@ -32,15 +40,26 @@ const TRADE = [ { value: 'EXPORT', label: 'Export' }, { value: 'DOMESTIC', label: 'Domestic' }, ]; +const CURRENCIES = [ + { value: 'USD', label: 'USD - Dollar' }, + { value: 'ETB', label: 'ETB - Birr' }, +]; const clean = (s: string) => s.trim() || undefined; +const selectValue = (value: string | null, fallback = '') => value ?? fallback; +const numberValue = (value: string | number, fallback = 0) => { + const next = Number(value); + return Number.isFinite(next) ? next : fallback; +}; +const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`; +const dash = '-'; export default function WarehouseRulesPage() { return ( @@ -62,11 +81,10 @@ export default function WarehouseRulesPage() { function AllocationRules() { const { toast } = useToast(); - const { data, isLoading } = useQuery( - api.warehouses.allocationRules.queryOptions(), - ); - const create = useMutation(api.warehouses.createAllocationRule.mutationOptions()); - const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions()); + const { data, isLoading } = useAllocationRules(); + const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards(); + const create = useCreateAllocationRule(); + const remove = useDeleteAllocationRule(); const [open, setOpen] = useState(false); const [form, setForm] = useState({ name: '', @@ -78,13 +96,33 @@ function AllocationRules() { targetYardCode: '', storageType: '', }); + const rules = data ?? []; + const yardOptions = yards + .filter((yard) => yard.code) + .map((yard) => ({ + value: yard.code, + label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`, + })); + + const resetForm = () => + setForm({ + name: '', + priority: 100, + freightType: '', + tradeDirection: '', + cargoTypeCode: '', + containerStatus: '', + targetYardCode: '', + storageType: '', + }); const submit = async () => { if (!form.name.trim() || !form.targetYardCode.trim()) { - toast({ variant: 'destructive', title: 'Name and target yard code are required' }); + toast({ variant: 'destructive', title: 'Name and target yard are required' }); return; } + await create.mutateAsync({ name: form.name.trim(), priority: form.priority, @@ -98,77 +136,179 @@ function AllocationRules() { } as never); toast({ title: 'Allocation rule created' }); setOpen(false); - setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' }); + resetForm(); }; - const columns: ColumnDef<(typeof rules)[number]>[] = [ - { id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority }, - { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, - { id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' }, - { id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' }, - { id: 'cargo', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? '—' }, - { - id: 'targetYard', - header: 'Target yard', - cell: ({ row }) => {row.original.targetYardCode}, - }, - { - id: 'active', - header: 'Active', - cell: ({ row }) => ( - - {row.original.isActive ? 'Yes' : 'No'} - - ), - }, - { - id: 'actions', - header: '', - cell: ({ row }) => ( - e.stopPropagation()}> - remove.mutate(row.original.id)} title="Delete"> - - - - ), - }, - ]; - return ( <> - {rules.length} rule(s) — matched by ascending priority - + + {rules.length} rule(s) matched by ascending priority + + - + } color="orange" variant="light" mb="md"> + + Allocation rules tell the system where to place a booking when it enters the warehouse. + Lower priority numbers are checked first. + + + + {isLoading ? ( + + + + ) : ( + + + + + Priority + Name + Freight + Trade + Cargo code + Target yard + Active + Actions + + + + {rules.map((rule) => ( + + {rule.priority} + {rule.name} + {rule.freightType ?? dash} + {rule.tradeDirection ?? dash} + {rule.cargoTypeCode ?? dash} + + {rule.targetYardCode} + + + + {rule.isActive ? 'Yes' : 'No'} + + + + remove.mutate(rule.id)} + title="Delete" + > + + + + + ))} + +
+
+ )} setOpen(false)} title="New allocation rule" centered size="lg"> - + + + + + Rule preview + + + When {anyLabel(form.tradeDirection, 'trade direction').toLowerCase()} /{' '} + {anyLabel(form.freightType, 'freight type').toLowerCase()} booking + {form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''} + {form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '} + is received, send it to {form.targetYardCode || 'a selected target yard'}. + + + - setForm((f) => ({ ...f, name: e.currentTarget.value }))} /> - setForm((f) => ({ ...f, priority: Number(v) || 100 }))} /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, name: value })); + }} + /> + setForm((f) => ({ ...f, priority: numberValue(v, 100) || 100 }))} + /> - setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable /> + setForm((f) => ({ ...f, tradeDirection: selectValue(v) }))} + clearable + /> - setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} /> - setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, cargoTypeCode: value })); + }} + /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, containerStatus: value })); + }} + /> - setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} /> - setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} /> + ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} /> + { + const value = e.currentTarget.value; + setForm((f) => ({ ...f, name: value })); + }} + /> + setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable /> - setForm((f) => ({ ...f, freightType: selectValue(value) }))} + clearable + /> + setForm((f) => ({ ...f, currency: selectValue(value, 'USD') }))} + allowDeselect={false} + /> - - + + diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index fdd0f3563..8ad915446 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -749,12 +749,12 @@ export const api = { () => ["warehouse-fee-rules"], ), - feePreview: endpoint<{ inventoryId: string }, FeePreview[]>( + feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>( "warehouse-inventory", "fee-preview", - ({ inventoryId }) => - warehouseService.feePreview(inventoryId).then((r) => r.data), - ({ inventoryId }) => ["warehouse-inventory", inventoryId, "fee-preview"], + ({ inventoryId, billingCurrency }) => + warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data), + ({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'], ), invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>( @@ -1045,14 +1045,14 @@ export const api = { // ── Invoices ─────────────────────────────────────────────────────────── generateInvoice: endpoint< - { inventoryId: string; confirmZero?: boolean }, + { inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' }, WarehouseFeeInvoice >( "warehouse-fee-invoices", "generate", - ({ inventoryId, confirmZero }) => + ({ inventoryId, confirmZero, billingCurrency }) => warehouseService - .generateInvoice(inventoryId, confirmZero) + .generateInvoice(inventoryId, confirmZero, billingCurrency) .then((r) => r.data), undefined, () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], @@ -1980,4 +1980,4 @@ export const api = { ({ range }) => overviewService.getDashboard(range), ), }, -}; \ No newline at end of file +}; diff --git a/apps/edr-freight-web/backoffice/src/services/booking-orders.service.ts b/apps/edr-freight-web/backoffice/src/services/booking-orders.service.ts new file mode 100644 index 000000000..26c549385 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/booking-orders.service.ts @@ -0,0 +1,30 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import type { Freight } from "@edr/types"; + +/** + * Drawdown orders placed against a general contract (a booking with + * bookingType = GENERAL_CONTRACT). Each order spawns a child ONE_TIME booking + * that carries its own clearance/approval — managed on the child's detail page. + */ +export const bookingOrdersService = { + /** Orders placed against a general contract, with their lines + child status. */ + listByContract: async ( + contractBookingId: string, + ): Promise => { + const response = await client.get("/booking-orders", { + params: { contractBookingId }, + }); + return unwrap(response.data) as Freight.IBookingOrder[]; + }, + + /** Contracted / ordered / remaining quantities for a general contract. */ + pool: async ( + contractBookingId: string, + ): Promise => { + const response = await client.get( + `/booking-orders/contract/${contractBookingId}/pool`, + ); + return unwrap(response.data) as Freight.ContractQuantityLine[]; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index f761719ae..23f5261c8 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -18,10 +18,10 @@ export interface FirstMileBooking { totalAmount: number; scheduledDate?: string | null; company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null; - serviceType?: { id: string; name?: string } | null; - originYard?: { id: string; name?: string } | null; - destinationYard?: { id: string; name?: string } | null; - cargoType?: { id: string; name?: string } | null; + serviceType?: { id: string; label?: string } | null; + originYard?: { id: string; label?: string } | null; + destinationYard?: { id: string; label?: string } | null; + cargoType?: { id: string; label?: string } | null; } export interface FirstMileVehicle { @@ -29,6 +29,9 @@ export interface FirstMileVehicle { plateNumber: string; manufacturer: string; model: string; + code?: string | null; + powerPlateNo?: string | null; + trailerPlateNo?: string | null; } export interface FirstMileRecord { diff --git a/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts new file mode 100644 index 000000000..6e2fd3474 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts @@ -0,0 +1,33 @@ +import { api as apiClient } from '../auth/http'; + +import { URL_CONSTANTS } from '@/constants/URLS'; +import type { + GenerateInterchangeDocumentPayload, + InterchangeDocument, + InterchangeDocumentFilter, +} from '@/types/interchangeDocument'; + +const cleanParams = (params: object) => + Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null), + ); + +export const interchangeDocumentsService = { + list: (filter?: InterchangeDocumentFilter) => + apiClient.get(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.BASE, { + params: cleanParams(filter ?? {}), + }), + getById: (id: string) => + apiClient.get(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.BY_ID(id)), + generateFromSchedule: (payload: GenerateInterchangeDocumentPayload) => + apiClient.post( + URL_CONSTANTS.INTERCHANGE_DOCUMENTS.GENERATE_FROM_SCHEDULE, + payload, + ), + acknowledge: (id: string, payload: { acknowledgedBy: string; remarks?: string }) => + apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload), + dispute: (id: string, payload: { remarks: string }) => + apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload), + cancel: (id: string) => + apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.CANCEL(id), {}), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 5d8b60df2..779fd96aa 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -29,6 +29,9 @@ export interface LastMileVehicle { plateNumber: string; manufacturer: string; model: string; + code?: string | null; + powerPlateNo?: string | null; + trailerPlateNo?: string | null; } export interface LastMileRecord { @@ -60,5 +63,5 @@ export const lastMileService = { update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) => api.patch(LM.BY_ID(id), data), accept: (bookingReference: string) => - api.post(LM.ACCEPT(bookingReference)), + api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), }; diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index d5f0308f9..b9d5129e3 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -26,6 +26,9 @@ export interface Vehicle { capacity: number; status: VehicleStatus; description?: string | null; + code?: string | null; + powerPlateNo?: string | null; + trailerPlateNo?: string | null; createdAt: string; updatedAt: string; } diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 082a447fa..8a0beafb5 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -37,6 +37,9 @@ import type { BulkInspectResult, ReadyToLoadRow, BulkDispatchResult, + AutoUnloadExportDjiboutiResult, + ExportTrain, + ExportTrainItem, ImportTrain, ImportTrainItem, ImportUnloadedItem, @@ -48,6 +51,7 @@ import type { Warehouse, WarehouseActivityLog, WarehouseDashboard, + WarehouseFacility, WarehouseFilter, WarehouseInventoryItem, WarehouseLoading, @@ -67,15 +71,19 @@ export const warehouseService = { params: cleanParams(filter ?? {}), }), dashboard: () => apiClient.get(URL_CONSTANTS.WAREHOUSES.DASHBOARD), + getDashboardSummary: (_filter?: InventoryFilter) => + apiClient.get(URL_CONSTANTS.WAREHOUSES.DASHBOARD), getById: (id: string) => apiClient.get(URL_CONSTANTS.WAREHOUSES.BY_ID(id)), create: (payload: SaveWarehousePayload) => apiClient.post(URL_CONSTANTS.WAREHOUSES.BASE, payload), update: (id: string, payload: Partial) => apiClient.patch(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload), + listFacilities: () => apiClient.get(URL_CONSTANTS.RULE_ENGINE.YARDS), // ── Yards ──────────────────────────────────────────────────────────────── listYards: (warehouseId: string) => apiClient.get(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)), + listAllYards: () => apiClient.get(URL_CONSTANTS.WAREHOUSE_YARDS.BASE), createYard: (warehouseId: string, payload: SaveYardPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload), getYard: (id: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)), @@ -85,6 +93,7 @@ export const warehouseService = { // ── Zones ────────────────────────────────────────────────────────────── listZones: (yardId: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)), + listAllZones: () => apiClient.get(URL_CONSTANTS.WAREHOUSE_ZONES.BASE), createZone: (yardId: string, payload: SaveZonePayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload), getZone: (id: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)), @@ -124,6 +133,10 @@ export const warehouseService = { apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)), release: (id: string, payload: ReleaseOrderPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload), + downloadReleaseDocument: (id: string) => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), { + responseType: 'blob', + }), deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), @@ -157,6 +170,17 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE), importPickupReadyQueue: () => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_PICKUP_READY_QUEUE), + exportDjiboutiArrivalQueue: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.EXPORT_DJIBOUTI_ARRIVAL_QUEUE), + exportDjiboutiTrainItems: (scheduleId: string) => + apiClient.get( + URL_CONSTANTS.WAREHOUSE_INVENTORY.EXPORT_DJIBOUTI_TRAIN_ITEMS(scheduleId), + ), + autoUnloadExportAtDjibouti: (scheduleId: string) => + apiClient.post( + URL_CONSTANTS.WAREHOUSE_INVENTORY.EXPORT_AUTO_UNLOAD_AT_DJIBOUTI, + { scheduleId }, + ), move: (id: string, payload: MoveInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload), movements: (id: string) => @@ -227,8 +251,10 @@ export const warehouseService = { updateFeeRule: (id: string, payload: Partial) => apiClient.patch(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload), deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)), - feePreview: (inventoryId: string) => - apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)), + feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') => + apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), { + params: cleanParams({ billingCurrency }), + }), // ── Batch 6: Warehouse fee invoices ──────────────────────────────────────── listInvoices: (filter?: WarehouseInvoiceFilter) => @@ -241,8 +267,11 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)), invoicesForBooking: (bookingId: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)), - generateInvoice: (inventoryId: string, confirmZero = false) => - apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero }), + generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD') => + apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { + confirmZero, + billingCurrency, + }), cancelInvoice: (id: string) => apiClient.patch(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)), payInvoice: (id: string, payload: PayInvoicePayload) => diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index e8e6a6912..77521de64 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -2,6 +2,7 @@ export const BOOKING_STATUSES = [ "DRAFT", "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", "CHANGES_REQUESTED", "PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE", @@ -12,6 +13,8 @@ export const BOOKING_STATUSES = [ "CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED", + "SELECTED_FOR_BATCH", + "EXPIRED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", @@ -21,6 +24,18 @@ export const BOOKING_STATUSES = [ "CANCELLED", "PENDING_CONSOLIDATION", "CONSOLIDATED", + "CONTRACT_ACTIVE", + "CONTRACT_CLOSED", + // Post counter-sign document-clearance gate. + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + "ROAD_DISPATCH_PENDING", + "OPERATION_REQUESTED", + // Operations review gate. + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + "OPERATION_PRICE_PENDING_CONFIRM", ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; @@ -117,6 +132,10 @@ export interface BookingDetail { isGovernment?: boolean; governmentInstitution?: string | null; status: BookingStatus; + /** ONE_TIME shipment vs an umbrella GENERAL_CONTRACT drawn down by orders. */ + bookingType?: "ONE_TIME" | "GENERAL_CONTRACT"; + /** General contracts only: when the ordering window closes. */ + expiresAt?: string | null; scheduledDate: string; totalAmount: number; adjustedTotalAmount?: number | null; @@ -157,6 +176,8 @@ export interface BookingDetail { firstMilePickupAddress?: string | null; lastMileDeliveryAddress?: string | null; equipmentReturn?: string; + customsClearingEnabled?: boolean; + customsClearingAgent?: string | null; contractSummary?: string | null; latestChangeRequestNote?: string | null; nextStep?: BookingNextStep | null; @@ -167,7 +188,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; - serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number }; + serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean }; cargoType?: BookingNamedRef; shippingLine?: BookingNamedRef; bookingContainers?: BookingContainerLine[]; @@ -204,5 +225,6 @@ export interface BookingListRow { governmentInstitution?: string | null; consolidationPartnerId?: string | null; consolidationPartnerReference?: string | null; + customsClearingEnabled?: boolean; createdAt: string; } diff --git a/apps/edr-freight-web/backoffice/src/types/interchangeDocument.ts b/apps/edr-freight-web/backoffice/src/types/interchangeDocument.ts new file mode 100644 index 000000000..a0b4b2658 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/interchangeDocument.ts @@ -0,0 +1,90 @@ +export type InterchangeDirection = 'IMPORT' | 'EXPORT'; +export type InterchangeDocumentStatus = + | 'DRAFT' + | 'GENERATED' + | 'ACKNOWLEDGED' + | 'DISPUTED' + | 'CANCELLED'; +export type InterchangeItemType = 'CONTAINER' | 'CARGO'; +export type InterchangeConditionStatus = + | 'GOOD' + | 'DAMAGED' + | 'SHORTAGE' + | 'EXCESS' + | 'HOLD' + | 'UNKNOWN'; + +export interface InterchangeDocumentItem { + id: string; + interchangeDocumentId: string; + bookingId: string | null; + bookingReference: string | null; + itemType: InterchangeItemType; + bookingContainerId: string | null; + bookingCargoId: string | null; + containerNumber: string | null; + sealNumber: string | null; + cargoId: string | null; + cargoType: string | null; + cargoDescription: string | null; + weight: number | null; + quantity: number | null; + packageCount: number | null; + wagonNumber: string | null; + conditionStatus: InterchangeConditionStatus; + damageDescription: string | null; + remarks: string | null; +} + +export interface InterchangeDocument { + id: string; + documentNo: string; + direction: InterchangeDirection; + scheduleId: string | null; + trainNo: string | null; + routeId: string | null; + originFacilityId: string | null; + destinationFacilityId: string | null; + handoverLocation: string; + handoverFrom: string; + handoverTo: string; + operatorName: string | null; + portOperatorName: string | null; + shippingLineName: string | null; + customsReference: string | null; + manifestReference: string | null; + status: InterchangeDocumentStatus; + generatedAt: string | null; + acknowledgedAt: string | null; + generatedBy: string | null; + acknowledgedBy: string | null; + remarks: string | null; + createdAt: string; + updatedAt: string; + items?: InterchangeDocumentItem[]; +} + +export interface InterchangeDocumentFilter { + direction?: InterchangeDirection; + status?: InterchangeDocumentStatus; + scheduleId?: string; + documentNo?: string; + dateFrom?: string; + dateTo?: string; + search?: string; +} + +export interface GenerateInterchangeDocumentPayload { + scheduleId: string; + direction: InterchangeDirection; + handoverLocation: string; + handoverFrom: string; + handoverTo: string; + operatorName?: string; + portOperatorName?: string; + shippingLineName?: string; + customsReference?: string; + manifestReference?: string; + generatedBy?: string; + remarks?: string; +} diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index db23e0de5..6bbc235c7 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -146,12 +146,21 @@ export interface TrainScheduleListItem { origin: string | null; destination: string | null; freightType?: FreightType | null; - locomotive: { + locomotive: + | { + id: string; + code: string; + name?: string | null; + currentYardId?: string | null; + } + | null; + /** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */ + locomotives?: Array<{ id: string; code: string; name?: string | null; currentYardId?: string | null; - } | null; + }>; wagonCount: number; totalWeightTons: number; totalLengthMeters: number; @@ -351,6 +360,16 @@ export interface TrainScheduleDetail { maxPullWeightTons: number; maxTrainLengthMeters?: number; } | null; + /** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */ + locomotives?: Array<{ + id: string; + code: string; + name?: string | null; + status: string; + currentYardId?: string | null; + maxPullWeightTons: number; + maxTrainLengthMeters?: number; + }>; wagons: Array<{ id: string; sequenceNo: number; @@ -460,7 +479,8 @@ export interface ReschedulePlan { export interface CreateTrainSchedulePayload { routeId: string; scheduleDate: string; - locomotiveId: string; + /** Locomotives pulling the train (minimum 2 — front and back). */ + locomotiveIds: string[]; maxTrainWeightTons?: number; maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 182baf63c..d5141f23e 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -24,9 +24,12 @@ export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number]; export const INVENTORY_STATUSES = [ 'UNLOADED', + 'UNLOADED_AT_DJIBOUTI_PORT', 'RECEIVED', 'STORED', 'RESERVED', + 'ARRIVED_AT_WAREHOUSE', + 'UNDER_INSPECTION', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED', @@ -54,9 +57,12 @@ export type InventoryAction = */ export const INVENTORY_NEXT_ACTION: Record = { UNLOADED: 'store', + UNLOADED_AT_DJIBOUTI_PORT: null, RECEIVED: 'store', STORED: 'reserve', RESERVED: 'ready-for-loading', + ARRIVED_AT_WAREHOUSE: null, + UNDER_INSPECTION: null, READY_FOR_LOADING: 'load', LOADED: 'dispatch', DISPATCHED: null, @@ -122,6 +128,7 @@ export interface WarehouseYard { currentVolume: number; status: WarehouseStatus; isActive: boolean; + warehouse?: Pick | null; zones?: WarehouseZone[]; } @@ -146,6 +153,8 @@ export interface Facility { isActive?: boolean; } +export type WarehouseFacility = Facility; + export interface Warehouse { id: string; name: string; @@ -229,12 +238,16 @@ export interface InventoryMovement { export const ACTIVITY_TYPES = [ 'INVENTORY_RECEIVED', + 'INVENTORY_UNLOADED', 'INVENTORY_STORED', 'INVENTORY_MOVED', 'INVENTORY_RESERVED', 'READY_FOR_LOADING', 'INVENTORY_LOADED', 'INVENTORY_DISPATCHED', + 'READY_FOR_PICKUP', + 'INVENTORY_RELEASED', + 'INVENTORY_DELIVERED', ] as const; export type ActivityType = (typeof ACTIVITY_TYPES)[number]; @@ -412,6 +425,7 @@ export interface ImportTrain { route: string | null; origin: string | null; destination: string | null; + departureTime?: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -426,6 +440,49 @@ export interface AutoUnloadArrivedResult { results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; } +export type ExportTrain = ImportTrain & { + departureTime: string | null; +}; + +export interface ExportTrainItem { + 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 AutoUnloadExportDjiboutiResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + interchangeDocument?: { + id: string; + documentNo: string; + status: string; + }; + results: Array<{ + bookingId: string; + itemType: 'CONTAINER' | 'CARGO'; + itemId?: string | null; + inventoryId?: string; + containerNumber?: string | null; + status: string; + message?: string; + reason?: string; + }>; +} + export interface ImportUnloadedItem { id: string; bookingId: string | null; @@ -459,8 +516,11 @@ export interface ImportTrainItem { export interface InventoryInquiryResult { id: string; - bookingId: string; + inventoryId: string | null; + bookingId: string | null; + bookingReference: string | null; bookingNumber: string | null; + bookingStatus: string | null; customerName: string | null; containerNumber: string | null; cargoType: string | null; @@ -469,7 +529,11 @@ export interface InventoryInquiryResult { warehouse: { id: string; name: string; code: string } | null; yard: { id: string; name: string; code: string } | null; zone: { id: string; name: string; code: string } | null; - status: InventoryStatus; + status: InventoryStatus | null; + trainNumber: string | null; + trainStatus: string | null; + route: string | null; + locationSummary: string | null; quantity: number; weight: number; arrivedAt: string | null; @@ -601,11 +665,15 @@ export interface FeePreview { freeDays: number; ratePerDay: number; currency: string; + ruleCurrency?: string | null; + billingCurrency?: string; startDate: string | null; endDate: string; endIsOpen: boolean; elapsedDays: number; chargeableDays: number; + containerCount: number; + billableUnits: number; amount: number; } @@ -757,6 +825,28 @@ export interface ReceiveInventoryPayload { notes?: string; } +export interface MoveInventoryPayload { + warehouseId: string; + yardId: string; + zoneId: string; + remarks?: string; +} + +export interface ReserveInventoryPayload { + bookingId: string; +} + +export interface WarehouseDashboardSummary { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + export interface WarehouseFilter { search?: string; type?: WarehouseType; @@ -765,6 +855,7 @@ export interface WarehouseFilter { } export interface InventoryFilter { + facilityId?: string; warehouseId?: string; yardId?: string; zoneId?: string; @@ -774,9 +865,12 @@ export interface InventoryFilter { goodsId?: string; status?: InventoryStatus; search?: string; + dateFrom?: string; + dateTo?: string; } export interface InventoryInquiryFilter { + bookingReference?: string; bookingNumber?: string; containerNumber?: string; cargoType?: string; diff --git a/apps/edr-freight-web/backoffice/user-management-config/fhc.theme.ts b/apps/edr-freight-web/backoffice/user-management-config/fhc.theme.ts deleted file mode 100644 index 04bfcdcde..000000000 --- a/apps/edr-freight-web/backoffice/user-management-config/fhc.theme.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * fhc.theme.ts — Federal Housing Corporation (FHC) look & feel preset. - * - * ┌─────────────────────────────────────────────────────────────────────────┐ - * │ HOST-OWNED config. Lives in app-config/, NOT inside the user-management │ - * │ module. At submodule-split time this whole folder moves to the host repo. │ - * │ It is fully self-contained — no imports from the module. │ - * └─────────────────────────────────────────────────────────────────────────┘ - * - * WHAT IT GIVES YOU - * - The FHC Mantine color palettes: fhcBlue, fhcBrick, fhcGold, fhcGray - * - The FHC layout design tokens (brick-gradient sidebar, glassy header, - * page background, brand colors, sizes) under `theme.other.fhcLayout` - * (light) and `theme.other.fhcLayoutDark` (dark) — the classic shell reads - * these via useFhcLayout() - * - FHC typography (Plus Jakarta Sans), radii, shadows and component defaults - * - * HOW TO USE — in app-config/project.theme.ts: - * - * import { fhcMantineTheme } from "./fhc.theme"; - * - * export const projectTheme: DesignConfig = { - * typography: { fontFamily: "Plus Jakarta Sans, sans-serif" }, - * mantineTheme: fhcMantineTheme, // escape hatch — merges the FHC theme in - * }; - * - * Load the font once in index.html: - * - */ - -import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core"; - -/** Mantine 10-shade color scales used across the FHC UI. */ -export const FHC_COLORS = { - fhcBlue: [ - "#EEF4FC", - "#D9E8FA", - "#BCD5F5", - "#96BDEB", - "#6FA4E0", - "#4A90E2", - "#357ABD", - "#2C669D", - "#224F7A", - "#173654", - ], - fhcBrick: [ - "#F6ECE8", - "#EACFC4", - "#DBAD99", - "#C9876B", - "#B86B49", - "#A85735", - "#8C462B", - "#703622", - "#55281A", - "#3D1E14", - ], - fhcGold: [ - "#FFFBE6", - "#FFF3BF", - "#FEE98A", - "#FCDD57", - "#F9CF2F", - "#FFD700", - "#D9B700", - "#B39400", - "#8C7300", - "#665300", - ], - fhcGray: [ - "#F8FAFC", - "#F1F5F9", - "#E2E8F0", - "#CBD5E1", - "#94A3B8", - "#64748B", - "#475569", - "#334155", - "#1E293B", - "#0F172A", - ], -} as const; - -/** - * Layout design tokens — the brick-gradient sidebar, glassy header, page - * surfaces, brand colors and sizes. Mirrored under `theme.other.fhcLayout`. - */ -export const FHC_LAYOUT = { - sidebar: { - bg: "linear-gradient(180deg, #5D2E1F 0%, #3D1E14 100%)", - headerBg: "rgba(93, 46, 31, 0.82)", - footerBg: "rgba(61, 30, 20, 0.62)", - border: "rgba(255,255,255,0.10)", - text: "rgba(255,255,255,0.76)", - mutedText: "rgba(255,255,255,0.42)", - childText: "rgba(255,255,255,0.68)", - activeText: "#FFFFFF", - iconBg: "rgba(255,255,255,0.06)", - iconActiveBg: "rgba(255,255,255,0.12)", - hoverBg: "rgba(255,255,255,0.08)", - activeBg: "rgba(255,255,255,0.15)", - activeBorder: "rgba(255,255,255,0.14)", - sectionLine: "rgba(255,255,255,0.10)", - rail: "linear-gradient(180deg, #FFD700 0%, #4A90E2 100%)", - }, - header: { - bg: "rgba(255,255,255,0.92)", - border: "rgba(15, 23, 42, 0.08)", - searchBg: "#F9FAFB", - searchBorder: "#E5E7EB", - title: "#1F2937", - subtitle: "#6B7280", - }, - page: { - bg: "#F8FAFC", - cardBg: "rgba(255,255,255,0.92)", - }, - brand: { - brick: "#5D2E1F", - brickDark: "#3D1E14", - blue: "#4A90E2", - blueDark: "#357ABD", - gold: "#FFD700", - text: "#1F2937", - }, - sizes: { - sidebarExpanded: 288, - sidebarCollapsed: 80, - headerHeight: 64, - }, -} as const; - -/** - * Dark-mode counterpart of FHC_LAYOUT. The brick-gradient sidebar, accent rail - * and sizes are intentionally kept (they already read well on dark), while the - * glassy white header, page background, card surfaces and dark text are flipped - * to dark equivalents. - */ -export const FHC_LAYOUT_DARK = { - ...FHC_LAYOUT, - header: { - bg: "rgba(26, 27, 30, 0.92)", - border: "rgba(255,255,255,0.08)", - searchBg: "#25262B", - searchBorder: "#2C2E33", - title: "#F1F5F9", - subtitle: "#9CA3AF", - }, - page: { - bg: "#141517", - cardBg: "rgba(26, 27, 30, 0.92)", - }, - brand: { - ...FHC_LAYOUT.brand, - text: "#F1F5F9", - }, -} as const; - -/** - * Full Mantine theme override carrying the FHC palettes, layout tokens, - * typography, radii, shadows and component defaults. Pass this as the - * `mantineTheme` escape hatch in project.theme.ts. - * - * Note: BOTH `fhcLayout` (light) and `fhcLayoutDark` (dark) are published under - * `other` — the module's useFhcLayout() reads the matching one per color scheme. - */ -export const fhcMantineTheme: MantineThemeOverride = { - fontFamily: "Plus Jakarta Sans, sans-serif", - headings: { - fontFamily: "Plus Jakarta Sans, sans-serif", - }, - defaultRadius: "md", - radius: { - xs: "6px", - sm: "8px", - md: "10px", - lg: "14px", - xl: "18px", - }, - shadows: { - xs: "0 1px 2px rgba(15, 23, 42, 0.04)", - sm: "0 2px 8px rgba(15, 23, 42, 0.06)", - md: "0 4px 20px rgba(15, 23, 42, 0.08)", - lg: "0 8px 30px rgba(15, 23, 42, 0.12)", - }, - colors: { - fhcBlue: FHC_COLORS.fhcBlue as unknown as MantineColorsTuple, - fhcBrick: FHC_COLORS.fhcBrick as unknown as MantineColorsTuple, - fhcGold: FHC_COLORS.fhcGold as unknown as MantineColorsTuple, - fhcGray: FHC_COLORS.fhcGray as unknown as MantineColorsTuple, - }, - other: { - fhcLayout: FHC_LAYOUT, - fhcLayoutDark: FHC_LAYOUT_DARK, - }, - components: { - Paper: { - defaultProps: { - radius: "lg", - shadow: "sm", - }, - }, - NavLink: { - defaultProps: { - radius: "md", - }, - }, - }, -}; - -/** - * Optional convenience: the bits of a DesignConfig that carry the FHC look. - * Spread this into your projectTheme if you also want FHC as the primary brand - * (this re-tints buttons/links to fhcBlue). Leave it out to keep your own brand - * color while still getting the fhc* palettes + layout tokens via `mantineTheme`. - */ -export const fhcDesignPreset = { - colors: { primary: "#357ABD" }, - typography: { fontFamily: "Plus Jakarta Sans, sans-serif" }, - shape: { radius: "10px" }, - mantineTheme: fhcMantineTheme, -}; diff --git a/apps/edr-freight-web/backoffice/user-management-config/index.html b/apps/edr-freight-web/backoffice/user-management-config/index.html deleted file mode 100644 index 30392eb04..000000000 --- a/apps/edr-freight-web/backoffice/user-management-config/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - User Management - - -
- - - diff --git a/apps/edr-freight-web/backoffice/user-management-config/main.tsx b/apps/edr-freight-web/backoffice/user-management-config/main.tsx deleted file mode 100644 index f04f34c86..000000000 --- a/apps/edr-freight-web/backoffice/user-management-config/main.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -// Consume the reusable module via its public barrel (@/ → ../user-management/src). -import { UserManagementApp } from "@/index"; -// Your project's config lives HERE in the host folder (resolved via @app-config). -// The module never imports it; the host passes it in. -import { projectTheme } from "@app-config/project.theme"; - -createRoot(document.getElementById("root")!).render( - - - -); diff --git a/apps/edr-freight-web/backoffice/user-management-config/package-lock.json b/apps/edr-freight-web/backoffice/user-management-config/package-lock.json deleted file mode 100644 index 87f006ce4..000000000 --- a/apps/edr-freight-web/backoffice/user-management-config/package-lock.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "user-management-host", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "user-management-host", - "version": "0.0.0" - } - } -} diff --git a/apps/edr-freight-web/backoffice/user-management-config/package.json b/apps/edr-freight-web/backoffice/user-management-config/package.json deleted file mode 100644 index fa66964e3..000000000 --- a/apps/edr-freight-web/backoffice/user-management-config/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "user-management-host", - "private": true, - "version": "0.0.0", - "type": "module", - "description": "Host wrapper for the user-management module. Owns branding/theme (project.theme.ts / fhc.theme.ts), the Vite build (vite.config.ts), the HTML shell (index.html) and the entry (main.tsx). Consumes the module from ../user-management/src.", - "scripts": { - "dev": "vite --port 4202 --host 0.0.0.0", - "build": "vite build", - "preview": "vite preview --port 4202 --host" - } -} diff --git a/apps/edr-freight-web/backoffice/user-management-config/postcss.config.js b/apps/edr-freight-web/backoffice/user-management-config/postcss.config.js deleted file mode 100644 index 1f93a2965..000000000 --- a/apps/edr-freight-web/backoffice/user-management-config/postcss.config.js +++ /dev/null @@ -1,7 +0,0 @@ -// Tailwind is handled by the @tailwindcss/vite plugin (see vite.config.ts), so -// PostCSS needs no plugins here. This local config exists to stop Vite from -// walking up to the monorepo root postcss.config.js (Tailwind v3), which would -// conflict with this package's Tailwind v4 setup. -export default { - plugins: {}, -}; diff --git a/apps/edr-freight-web/backoffice/user-management-config/project.theme.ts b/apps/edr-freight-web/backoffice/user-management-config/project.theme.ts deleted file mode 100644 index f87e687ba..000000000 --- a/apps/edr-freight-web/backoffice/user-management-config/project.theme.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * project.theme.ts — HOST-OWNED config for THIS project / organisation (FHC). - * - * ┌─────────────────────────────────────────────────────────────────────────┐ - * │ Lives in app-config/, OUTSIDE the user-management module. The module │ - * │ never imports this file — the host passes it in via │ - * │ (see ../src/main.tsx). │ - * │ At submodule-split time, this whole folder moves to the host repo. │ - * └─────────────────────────────────────────────────────────────────────────┘ - * - * Every field is optional — remove lines you don't need to override. - * - * Flow: - * project.theme.ts → design.config.ts (module engine) → CSS vars + Mantine theme - * TenantConfig.ts → overrides --primary at runtime per hostname - * - * The TenantConfig layer runs AFTER this, so per-hostname primary-color overrides - * still work on top of whatever you set here. - */ - -import type { DesignConfig } from "@/config/design.config"; -import { fhcMantineTheme } from "./fhc.theme"; - -export const projectTheme: DesignConfig = { - // ───────────────────────────────────────────────────────────────────────── - // BRANDING - // Replace with your organisation's assets. - // ───────────────────────────────────────────────────────────────────────── - brand: { - appName: "Federal Housing Corporation", // FHC — shown in the browser tab - // Drop the FHC logo at this path in /public to show it in the sidebar brand - // and as the favicon. Until then the sidebar falls back to a building icon. - // logoUrl: "/assets/logo/fhc.png", - // faviconUrl: "/favicon.ico", // optional — defaults to logoUrl - }, - - // ───────────────────────────────────────────────────────────────────────── - // COLORS - // Change `primary` to your brand hex and everything cascades automatically. - // Shades primary-50 → primary-950 are computed via CSS color-mix in index.css. - // TenantConfig overrides this per-hostname, so localhost vs edrsc.com can - // still have different colors. - // ───────────────────────────────────────────────────────────────────────── - colors: { - primary: "#357ABD", // FHC blue (fhcBlue-6) — buttons, links, active states - // // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below - // // "#2563eb" blue | "#7c3aed" purple - // // "#16a34a" green | "#dc2626" red - // // "#f59e0b" amber | "#0284c7" sky - - // primaryForeground: "#ffffff", // text on primary-colored bg — rarely needs changing - - // secondary: "#f1f5f9", // TODO: subtle secondary UI color - // background: "#ffffff", // TODO: page background - // foreground: "#0f172a", // TODO: main text color - // border: "#e2e8f0", // TODO: input / card borders - // muted: "#f8fafc", // TODO: disabled input / tag backgrounds - // mutedForeground: "#94a3b8", // TODO: placeholder / helper text - // card: "#ffffff", // TODO: card background (if different from page) - // sidebar: "#f8fafc", // TODO: sidebar background - // danger: "#dc2626", // TODO: error / destructive color - }, - - // ───────────────────────────────────────────────────────────────────────── - // TYPOGRAPHY - // Load the font FIRST in index.html (Google Fonts link or @font-face) then - // set fontFamily here. The fallback chain is used if the custom font fails. - // ───────────────────────────────────────────────────────────────────────── - typography: { - fontFamily: "Plus Jakarta Sans, Inter, ui-sans-serif, system-ui, sans-serif", - // // TODO: "Poppins, Inter, sans-serif" - // // TODO: "Cairo, Inter, sans-serif" (Arabic) - // // TODO: "Noto Serif Ethiopic, serif" (Amharic) - - // headingFontFamily: undefined, // TODO: separate heading font if desired - // baseFontSize: "16px", // TODO: "14px" for compact dashboards - }, - - // ───────────────────────────────────────────────────────────────────────── - // SHAPE - // ───────────────────────────────────────────────────────────────────────── - shape: { - radius: "0.625rem", // TODO: "0" sharp | "0.5rem" subtle | "1rem" very rounded - }, - - // ───────────────────────────────────────────────────────────────────────── - // SHADOWS - // Leave commented to use Mantine/Tailwind defaults. - // ───────────────────────────────────────────────────────────────────────── - // shadows: { - // card: "0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06)", - // dropdown: "0 8px 30px rgba(0,0,0,0.12)", - // modal: "0 20px 60px rgba(0,0,0,0.16)", - // }, - - // ───────────────────────────────────────────────────────────────────────── - // MANTINE COMPONENT DEFAULTS - // These become the defaults for every component. - // ───────────────────────────────────────────────────────────────────────── - components: { - buttonDefaultVariant: "filled", // TODO: "light" | "outline" | "subtle" - inputDefaultSize: "sm", // TODO: "xs" | "md" | "lg" - inputRadius: "md", // TODO: "xs" | "lg" | "xl" - modalRadius: "lg", // TODO: "md" | "xl" - tableHighlightOnHover: true, - tableStriped: false, // TODO: "odd" | "even" | true - }, - - // ───────────────────────────────────────────────────────────────────────── - // USER-MANAGEMENT LAYOUT / NAVIGATION - // Pick the navigation chrome and style the side menu — all from here. - // "classic" → app-wide SIDE MENU, no top tabs - // "legacy" → top TAB bar, no side menu - // Each value is also exposed as a --um-* CSS var, so tweaks apply instantly. - // ───────────────────────────────────────────────────────────────────────── - layout: { - userManagementView: "classic", // TODO: "legacy" for the top-tab UI - // showTopBar: false, // TODO: overrides VITE_SHOW_TOP_BAR - - // ── Dimensions ────────────────────────────────────────────────────────── - sidebarWidth: "288px", // TODO: expanded side-menu width - sidebarCollapsedWidth: "80px", // TODO: icon-only width - headerHeight: "64px", // TODO: top bar height - // contentMaxWidth: "1440px", // TODO: cap the content column - - // ── Side-menu skin (defaults follow the FHC brick theme) ─────────────── - sidebarBackground: "linear-gradient(180deg, #5D2E1F 0%, #3D1E14 100%)", - sidebarColor: "rgba(255,255,255,0.76)", - sidebarMutedColor: "rgba(255,255,255,0.42)", - sidebarActiveBackground: "rgba(255,255,255,0.15)", - sidebarActiveColor: "#FFFFFF", - sidebarHoverBackground: "rgba(255,255,255,0.08)", - sidebarBorder: "rgba(255,255,255,0.10)", - sidebarRail: "linear-gradient(180deg, #FFD700 0%, #4A90E2 100%)", - sidebarBrandLabel: "User Management", - sidebarBrandSublabel: "Federal Housing", - - // ── THE MENU (data, shared by the side menu AND the top tabs) ────────── - // Edit/add/remove freely. `icon` is a name from the registry in - // navConfig.tsx (users, dashboard, content, position, settings, excel, - // archive, units, activity, organizations, …). `label` is an i18n key - // under "organization.
+ completed ? null : ( + + + + {stepMeta.icon} + {stepMeta.title} + + + {stepMeta.description} + + + + + ) } > + {completed ? ( + + ) : ( {phase === "nationality" ? ( @@ -438,10 +501,65 @@ export default function OnboardingWizardDialog({ )} + )} ); } +/** + * Replaces the wizard body once onboarding is submitted: congratulates the user + * and sets the expectation that their company is now under review, and that + * bookings unlock per profile as the team approves each one. + */ +function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { + return ( + + + + + + + You're all set! + + Thanks for completing your company profile. Your application has been + submitted and is now with our team for review. + + + + + + + + Each operational profile (importer, exporter, freight forwarder) is + reviewed and approved individually. + + + + + + You can start creating bookings under a profile as soon as it's + approved — we'll let you know the moment that happens. + + + + + + + ); +} + /** * Continuous progress pill: a single rounded track that fills left-to-right as * the user advances, with faint ticks marking each step boundary. diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6a22ef954..3d30b7d15 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -89,6 +89,7 @@ export const URL_CONSTANTS = { ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", + ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements", DASHBOARD: "/api/companies/dashboard", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index ec54a9a30..9d17b1fed 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -74,13 +74,6 @@ const useAuth = () => { setCookie("auth-token", res.token, 7); setCookie("refresh-token", res.refreshToken, 7); await authQuery.refetch(); - const otpCode = res.otp?.split(" ")?.[6] ?? ""; - localStorage.setItem("otp", otpCode); - localStorage.setItem("otp-phone", payload.phoneNumber); - localStorage.setItem("otp-email", payload.email); - api.auth.sendOTP - .call({ phone: payload.phoneNumber, otp: otpCode }) - .catch(() => { }); return { success: true, data: res }; } catch (err) { return { success: false, error: extractApiError(err) }; @@ -164,6 +157,15 @@ const useAuth = () => { companyInfo?.profile?.onboardingCompleted ?? false; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + // Booking is gated on backoffice approval of the active operational profile: + // a customer can only book under a profile once its status is "active". + const activeProfile = + companyInfo?.company?.companyProfiles?.find( + (p) => p.id === activeCompanyProfileId, + ) ?? null; + const activeProfileStatus = activeProfile?.status ?? null; + const canBook = activeProfileStatus === "active"; + /** Refetch everything scoped to the active operational profile. */ const invalidateScopedData = async () => { await Promise.all([ @@ -232,6 +234,8 @@ const useAuth = () => { customer: isAuthenticated ? (companyQuery.data ?? null) : null, activeProfileType, activeCompanyProfileId, + activeProfileStatus, + canBook, companyType, companyStatus, isCompanyApproved, diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 4da182283..23cc908a6 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -9,7 +9,6 @@ import { HelloSection, InvoicesSection, RecentActivitySection, - SetupPrompt, ShipmentsSection, StatsSection, } from "./components"; @@ -21,7 +20,6 @@ export default function MyPortalPage() { null, ); const { - customer, companyProfiles, bookingsQuery, dashboardQuery, @@ -67,8 +65,6 @@ export default function MyPortalPage() { )} - - {canPay ? ( + ) : canSign ? ( + + ) : hasInlineAction ? ( + ) : ( - + !profile[field]); -} - -interface SetupPromptProps { - show: boolean; -} - -export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), - ); - - const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data); - - if (!show && !incomplete) return null; - - return ( - - - - - {incomplete && } - - {incomplete ? "Complete Your Profile" : "Setup your Company Profile"} - - - - {incomplete - ? "Your company profile is incomplete. Fill in the missing details to unlock all features." - : "Complete your company information to unlock all features and start booking shipments."} - - - - - {incomplete ? "Complete Profile" : "Complete Setup"} - - - - - - - - - - - ); -}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx index 413caafc4..a17af733d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx @@ -1,15 +1,30 @@ import { Box, Group, Text } from "@mantine/core"; -import { memo } from "react"; import type { LucideIcon } from "lucide-react"; +import { memo } from "react"; import { cv } from "../constants"; +/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */ +type Accent = "green" | "amber" | "blue" | "slate"; + +const ACCENTS: Record = { + green: { soft: cv("edr-soft"), ink: cv("edr-green.7") }, + amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") }, + blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") }, + slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") }, +}; + interface StatKpiProps { icon: LucideIcon; label: string; value: string; delta: string; - deltaColor: string; + /** Color family for the icon chip. */ + accent: Accent; + /** Tint of the delta pill — defaults to the card accent. */ + deltaTone?: Accent | "muted"; + /** Draw a separating border on the left (on wide layouts). */ divider?: boolean; + loading?: boolean; } export const StatKpi = memo(function StatKpi({ @@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({ label, value, delta, - deltaColor, + accent, + deltaTone, divider, + loading, }: StatKpiProps) { + const a = ACCENTS[accent]; + const tone = deltaTone ?? accent; + const pill = + tone === "muted" + ? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") } + : { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink }; + return ( - - - - {label} - - - - - {value} - - - {delta} - + {/* Icon chip + metric label, aligned on one line. */} + + + + + + + + {loading ? "—" : value} + + {delta && !loading && ( + + + {delta} + + + )} + + + {label} + + + + {/* Value + its trend pill, grouped together at the bottom of the cell. */} + ); }); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index 8a0545fb7..dbd12546b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,7 +1,7 @@ +import { formatCurrency } from "@/pages/billing/invoices.mock"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react"; import { memo } from "react"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; import { formatPct } from "../constants"; import { Card } from "./Card"; import { StatKpi } from "./StatKpi"; @@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({ completionRate, spendYtd, spendYtdChangePct, + dashboardLoading, }: StatsSectionProps) { return ( - - + + 0 ? `+${newActiveThisWeek} this week` : ""} + loading={bookingsLoading} /> diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts index de3ddb448..4bf94f45f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection"; export { HelloSection } from "./HelloSection"; export { InvoicesSection } from "./InvoicesSection"; export { RecentActivitySection } from "./RecentActivitySection"; -export { SetupPrompt } from "./SetupPrompt"; export { ShipmentsSection } from "./ShipmentsSection"; export { StatKpi } from "./StatKpi"; export { StatsSection } from "./StatsSection"; export { Stepper } from "./Stepper"; + diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index d1231406c..70e58f77c 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -1,10 +1,13 @@ import { ArrowRight, + CalendarClock, CheckCircle2, Clock3, FileCheck2, FilePen, + FileUp, MapPin, + ShieldCheck, Truck, Wallet, type LucideIcon, @@ -161,6 +164,110 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + AWAITING_DOCUMENTS: { + stage: 3, + icon: FileUp, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Clearance documents needed", + step: "edr-accent", + badgeLabel: "Docs needed", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Upload documents", kind: "amber", icon: ArrowRight }, + }, + DOCUMENTS_UNDER_REVIEW: { + stage: 3, + icon: ShieldCheck, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Clearance under review · re-upload any queried docs", + step: "edr-blue-dot", + badgeLabel: "In review", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "Review documents", kind: "outline" }, + }, + CLEARANCE_READY: { + stage: 3, + icon: CalendarClock, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Cleared · choose a shipment day to proceed", + step: "edr-green.5", + badgeLabel: "Cleared", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "Schedule & proceed", kind: "amber", icon: ArrowRight }, + }, + OPERATION_REQUEST_PENDING: { + stage: 3, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Operation request under review by operations", + step: "edr-blue-dot", + badgeLabel: "Op. review", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + OPERATION_REQUESTED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Operation requested · operator taking it forward", + step: "edr-green.5", + badgeLabel: "Operation requested", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + OPERATION_CHANGES_REQUESTED: { + stage: 3, + icon: FilePen, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Operations requested changes · please review", + step: "edr-accent", + badgeLabel: "Revise", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Review", kind: "dark" }, + }, + OPERATION_PRICE_PENDING_CONFIRM: { + stage: 3, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Price adjusted · confirm to proceed", + step: "edr-accent", + badgeLabel: "Confirm price", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Confirm", kind: "amber", icon: ArrowRight }, + }, + ROAD_DISPATCH_PENDING: { + stage: 3, + icon: Truck, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Accepted · awaiting truck dispatch", + step: "edr-blue-dot", + badgeLabel: "Awaiting dispatch", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, PNR_GENERATED: { stage: 3, icon: FileCheck2, @@ -317,6 +424,84 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + PRICE_CHANGED_PENDING_CONFIRM: { + stage: 1, + icon: Wallet, + iconColor: "edr-amber-text", + tile: "edr-amber-soft", + hint: "Price changed · confirm to continue", + step: "edr-accent", + badgeLabel: "Confirm price", + badgeBg: "edr-amber-soft", + badgeText: "edr-amber-text", + badgeDot: "edr-accent", + action: { label: "Confirm", kind: "amber", icon: ArrowRight }, + }, + READY_FOR_ASSIGNMENT: { + stage: 2, + icon: FileCheck2, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Approved · awaiting wagon assignment", + step: "edr-blue-dot", + badgeLabel: "Assigning", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + WAGON_ASSIGNED: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Wagon assigned · preparing for loading", + step: "edr-green.5", + badgeLabel: "Wagon assigned", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + INVOICED: { + stage: 3, + icon: Wallet, + iconColor: "edr-blue", + tile: "edr-blue-soft", + hint: "Invoice issued · awaiting payment", + step: "edr-blue-dot", + badgeLabel: "Invoiced", + badgeBg: "edr-blue-soft", + badgeText: "edr-blue", + badgeDot: "edr-blue-dot", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_ACTIVE: { + stage: 3, + icon: CheckCircle2, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Contract active · accepting orders", + step: "edr-green.5", + badgeLabel: "Active", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, + CONTRACT_CLOSED: { + stage: 4, + icon: CheckCircle2, + iconColor: "edr-slate", + tile: "edr-slate-soft2", + hint: "Contract closed · quantity used or window elapsed", + step: "edr-step", + badgeLabel: "Closed", + badgeBg: "edr-slate-soft2", + badgeText: "edr-slate", + badgeDot: "edr-step", + action: { label: "View", kind: "outline" }, + }, }; export const ACTION_PROPS: Record< diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index ab7cb0f40..4f61c7a61 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,10 +1,10 @@ import { Alert, Button, - Checkbox, Divider, Group, Loader, + PinInput, SimpleGrid, Stack, Text, @@ -16,7 +16,11 @@ import { AlertCircle, ArrowLeft, ArrowRight, - // UserCheck, + CheckCircle2, + RotateCw, + ShieldCheck, + Smartphone, + UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,18 +41,29 @@ import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; +import { extractApiError } from "@/utils/result"; type CompanyStep = | "company" | "personnel" | "contact" + | "verify" | "poa" | "documents" | "additional"; +/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */ +const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9); +const samePhone = (a?: string | null, b?: string | null) => { + const da = phoneDigits(a); + return da.length === 9 && da === phoneDigits(b); +}; +/** Mask all but the first 7 chars of an E.164 phone for display. */ +const maskPhone = (p: string) => + p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; + const onboardingSchema = z.object({ - companyFirstName: z.string().min(1, "First name is required"), - companyLastName: z.string().min(1, "Last name is required"), + companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), companyPhone: z .string() @@ -58,10 +73,7 @@ const onboardingSchema = z.object({ // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. companyAddress: z.string().optional(), - tinNumber: z - .string() - .length(10, "TIN must be exactly 10 digits") - .regex(/^00\d{8}$/, "TIN must be 10 digits starting with 00"), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z .string() .min(1, "VAT number is required") @@ -73,18 +85,15 @@ const onboardingSchema = z.object({ renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - region: z.string().optional(), - zone: z.string().optional(), - woreda: z.string().optional(), - kebele: z.string().optional(), - houseNo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + woreda: z.string().min(1, "Woreda is required"), + kebele: z.string().min(1, "Kebele is required"), + houseNo: z.string().min(1, "House number is required"), etradePhone: z.string().optional(), - contactPersonFirstName: z - .string() - .min(1, "Contact person first name is required"), - contactPersonLastName: z - .string() - .min(1, "Contact person last name is required"), + contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonPosition: z.string().optional(), contactPersonEmail: z .string() @@ -95,15 +104,13 @@ const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - generalManagerFirstName: z.string().min(1, "GM first name is required"), - generalManagerLastName: z.string().min(1, "GM last name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), generalManagerPhone: z .string() - .min(1, "GM phone is required") + .min(1, "Manager phone is required") .refine(isValidPhone, "Enter a valid phone number"), - poaFirstName: z.string().optional(), - poaLastName: z.string().optional(), + poaName: z.string().optional(), poaPhone: z .string() .optional() @@ -117,8 +124,7 @@ type FormData = z.infer; const stepFields: Record = { company: [ - "companyFirstName", - "companyLastName", + "companyName", "companyEmail", "companyPhone", "companyLocation", @@ -140,40 +146,25 @@ const stepFields: Record = { "etradePhone", ], personnel: [ - "generalManagerFirstName", - "generalManagerLastName", + "generalManagerName", "generalManagerEmail", "generalManagerPhone", ], contact: [ - "contactPersonFirstName", - "contactPersonLastName", + "contactPersonName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", ], + verify: [], poa: [], documents: [], additional: [], }; -/** Join first + last into the single name the API stores. */ -function joinName(first?: string, last?: string): string { - return [first?.trim(), last?.trim()].filter(Boolean).join(" "); -} - -/** Split a stored single name into first (first token) + last (the rest). */ -function splitName(full?: string | null): { first: string; last: string } { - const trimmed = (full ?? "").trim(); - if (!trimmed) return { first: "", last: "" }; - const idx = trimmed.indexOf(" "); - if (idx === -1) return { first: trimmed, last: "" }; - return { first: trimmed.slice(0, idx), last: trimmed.slice(idx + 1).trim() }; -} - function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { - companyName: joinName(data.companyFirstName, data.companyLastName), + companyName: data.companyName, companyEmail: data.companyEmail, companyPhone: data.companyPhone, companyLocation: data.companyLocation, @@ -182,20 +173,14 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { vatNumber: data.vatNumber, fanNumber: data.fanNumber, attributes: { - contactPersonName: joinName( - data.contactPersonFirstName, - data.contactPersonLastName, - ), + contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, contactPersonPhone: data.contactPersonPhone, - generalManagerName: joinName( - data.generalManagerFirstName, - data.generalManagerLastName, - ), + generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, generalManagerPhone: data.generalManagerPhone, - poaName: joinName(data.poaFirstName, data.poaLastName) || undefined, + poaName: data.poaName || undefined, poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, @@ -205,11 +190,14 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { } /** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload(step: CompanyStep, d: FormData): Partial { +function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { switch (step) { case "company": return { - companyName: joinName(d.companyFirstName, d.companyLastName), + companyName: d.companyName, companyEmail: d.companyEmail, companyPhone: d.companyPhone, companyLocation: d.companyLocation, @@ -232,26 +220,20 @@ function stepPayload(step: CompanyStep, d: FormData): Partial + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -327,6 +315,7 @@ export default function CompanyProfileForm({ roleProfiles, licenseFiles, onLicenseChange, + submitError, }: { documentSettingCode: string; documentFiles?: Record; @@ -354,6 +343,8 @@ export default function CompanyProfileForm({ /** Newly-selected license files per profile id. */ licenseFiles?: Record; onLicenseChange?: (value: Record) => void; + /** Server error from the final submit (uploads/complete), shown verbatim. */ + submitError?: string | null; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -400,8 +391,7 @@ export default function CompanyProfileForm({ } = useForm({ resolver: zodResolver(onboardingSchema), defaultValues: { - companyFirstName: "", - companyLastName: "", + companyName: "", companyEmail: "", companyPhone: "", companyLocation: "", @@ -421,17 +411,14 @@ export default function CompanyProfileForm({ kebele: "", houseNo: "", etradePhone: "", - contactPersonFirstName: "", - contactPersonLastName: "", + contactPersonName: "", contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "", - generalManagerFirstName: "", - generalManagerLastName: "", + generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", - poaFirstName: "", - poaLastName: "", + poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", @@ -441,25 +428,47 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // eTrade carries no email, so the company/contact email fields start blank. + // Seed them from the registering user's account email — but only while empty, + // so a typed or rehydrated value is never overwritten. + useEffect(() => { + if (!user?.email) return; + if (!watch("companyEmail")) { + setValue("companyEmail", user.email, { shouldValidate: true }); + } + if (!watch("contactPersonEmail")) { + setValue("contactPersonEmail", user.email); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.email, rehydrate]); + + // Keep the (hidden, derived) company address in sync with the editable address + // fields — so it reflects both the eTrade auto-fill and any later user edits, + // instead of only whatever was composed at lookup time. + const region = watch("region"); + const zone = watch("zone"); + const woreda = watch("woreda"); + const kebele = watch("kebele"); + const houseNo = watch("houseNo"); + useEffect(() => { + const composed = [houseNo, kebele, woreda, zone, region] + .filter((part) => part && part.trim()) + .join(", "); + setValue("companyAddress", composed); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [region, zone, woreda, kebele, houseNo]); + // The business owner/manager pulled from eTrade — powers "Use owner as - // manager" on the General Manager step. + // manager" on the General Manager step. Null until a TIN lookup succeeds. const [etradeOwner, setEtradeOwner] = useState<{ name: string; phone: string; - email?: string; } | null>(null); - // Mirror the three "copy from previous person" checkboxes. - const [ownerIsGm, setOwnerIsGm] = useState(false); - const [gmIsContact, setGmIsContact] = useState(false); - const [contactIsPoa, setContactIsPoa] = useState(false); - const handleETradeDataLoaded = (data: CompanyRegistrationData) => { // Company name comes from the eTrade manager/owner name on the license. if (data.managerName) { - const { first, last } = splitName(data.managerName); - setValue("companyFirstName", first, { shouldValidate: true }); - setValue("companyLastName", last, { shouldValidate: true }); + setValue("companyName", data.managerName, { shouldValidate: true }); } setValue("licenceNumber", data.licenceNumber); setValue("statusDescription", data.statusDescription); @@ -476,18 +485,9 @@ export default function CompanyProfileForm({ "etradePhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); - - // Compose a readable company address from the granular eTrade parts. - const addressParts = [ - data.houseNo, - data.kebele, - data.woreda, - data.zone, - data.region, - ].filter((part) => part && part.trim()); - if (addressParts.length) { - setValue("companyAddress", addressParts.join(", ")); - } + // companyAddress is composed reactively from the address fields below, so + // setting region/zone/woreda/kebele/houseNo above is enough — no need to + // compose it here. // Pre-fill the company contact phone from eTrade's mobile number. const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); @@ -500,53 +500,149 @@ export default function CompanyProfileForm({ phone: toEthiopianE164( data.managerPhone || data.regularPhone || data.mobilePhone, ), - email: data.managerEmail || undefined, }); }; /** Fill the General Manager from the eTrade business owner. */ - const toggleOwnerAsGm = (checked: boolean) => { - setOwnerIsGm(checked); - if (!checked || !etradeOwner) return; - const { first, last } = splitName(etradeOwner.name); - setValue("generalManagerFirstName", first, { shouldValidate: true }); - setValue("generalManagerLastName", last, { shouldValidate: true }); - setValue("generalManagerEmail", etradeOwner.email ?? "", { - shouldValidate: true, - }); + const useOwnerAsManager = () => { + if (!etradeOwner) return; + setValue("generalManagerName", etradeOwner.name); setValue("generalManagerPhone", etradeOwner.phone ?? "", { shouldValidate: true, }); }; - /** Copy the General Manager into the Contact Person fields (toggleable). */ - const toggleGmAsContact = (checked: boolean) => { - setGmIsContact(checked); - if (!checked) return; - setValue("contactPersonFirstName", watch("generalManagerFirstName")); - setValue("contactPersonLastName", watch("generalManagerLastName")); + /** Copy the General Manager into the Contact Person fields (still editable). */ + const useGmAsContact = () => { + setValue("contactPersonName", watch("generalManagerName"), { + shouldValidate: true, + }); setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone")); + setValue("contactPersonPhone", watch("generalManagerPhone"), { + shouldValidate: true, + }); }; - /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ - const toggleContactAsPoa = (checked: boolean) => { - setContactIsPoa(checked); - if (!checked) return; - setValue("poaFirstName", watch("contactPersonFirstName")); - setValue("poaLastName", watch("contactPersonLastName")); + /** Copy the Contact Person into the PoA fields (still editable). */ + const useContactAsPoa = () => { + setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); }; + /** Populate the Contact Person from the currently logged-in user. */ + const useLoggedInUserAsContact = () => { + setValue("contactPersonName", user?.name?.en ?? "", { + shouldValidate: true, + }); + if (user?.email) setValue("contactPersonEmail", user.email); + setValue("contactPersonPhone", user?.phoneNumber ?? "", { + shouldValidate: true, + }); + }; + + // --- Contact-phone SMS OTP verification ----------------------------------- + // The phone we verify is the contact-person phone, normalised to E.164 so it + // matches what the backend persists as `contactVerifiedPhone`. + const contactPhoneE164 = toEthiopianE164(watch("contactPersonPhone") ?? ""); + // Source of truth for "already verified" comes from the onboarding/profile + // info (rehydrate) — so a refresh resumes the verify step's "done" state. + const [verifiedPhone, setVerifiedPhone] = useState( + rehydrate?.contactVerifiedPhone ?? null, + ); + useEffect(() => { + if (rehydrate?.contactVerifiedPhone) { + setVerifiedPhone(rehydrate.contactVerifiedPhone); + } + }, [rehydrate?.contactVerifiedPhone]); + const phoneVerified = samePhone(verifiedPhone, contactPhoneE164); + + const [otpSent, setOtpSent] = useState(false); + const [otpCode, setOtpCode] = useState(""); + const [sendingOtp, setSendingOtp] = useState(false); + const [verifyingOtp, setVerifyingOtp] = useState(false); + const [otpError, setOtpError] = useState(null); + const [resendIn, setResendIn] = useState(0); + + // Resend cooldown countdown (no Date.now needed — pure setTimeout ticks). + useEffect(() => { + if (resendIn <= 0) return; + const t = setTimeout(() => setResendIn((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [resendIn]); + + // A changed contact phone invalidates any in-flight code entry (the previous + // code was for a different number). Verified state is handled separately via + // the phone comparison, so this only resets the send/enter UI. + useEffect(() => { + setOtpSent(false); + setOtpCode(""); + setOtpError(null); + }, [contactPhoneE164]); + + const sendContactOtp = async () => { + setOtpError(null); + if (!contactPhoneE164) { + setOtpError("Enter a valid contact phone number first."); + return; + } + setSendingOtp(true); + try { + await api.auth.sendOTP.call({ phone: contactPhoneE164 }); + setOtpSent(true); + setOtpCode(""); + setResendIn(60); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setSendingOtp(false); + } + }; + + const verifyContactOtp = async () => { + setOtpError(null); + if (otpCode.length !== 6) { + setOtpError("Enter the 6-digit code we sent you."); + return; + } + setVerifyingOtp(true); + try { + await api.auth.verifyOTP.call({ phone: contactPhoneE164, otp: otpCode }); + setVerifiedPhone(contactPhoneE164); + setOtpSent(false); + // Persist the verified phone so the step resumes as "done" after a refresh + // (best-effort — the OTP itself already succeeded server-side). + onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {}); + } catch (err) { + setOtpError(extractApiError(err).message); + } finally { + setVerifyingOtp(false); + } + }; + const hasDocuments = Boolean(uploadSetting?.fields?.length); + // The registration/license details come straight from the eTrade lookup and + // are not user-editable — shown as a read-only confirmation once a TIN lookup + // (or rehydration) has filled them in. The address fields below are separate: + // user-entered and required. We watch the values so the display stays current. + const registration = watch([ + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewalDate", + "renewedFrom", + "renewedTo", + ]); + const hasRegistrationDetails = registration.some((v) => v && v.trim()); + // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ "company", "personnel", "contact", + "verify", "poa", "documents", "additional", @@ -589,6 +685,20 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // Contact-phone verification gates advancing past the verify step. The + // verified phone is already persisted (on verify success), so there's + // nothing extra to save here. + if (step === "verify") { + if (!phoneVerified) { + setSaveError( + "Please verify the contact person's phone number to continue.", + ); + return; + } + setSaveError(null); + setStep(stepOrder[currentIdx + 1]); + return; + } // The documents step has nothing to persist; field steps validate + save // before advancing. if (step !== "documents") { @@ -626,23 +736,15 @@ export default function CompanyProfileForm({ + First Name *} - placeholder="Global" - error={errors.companyFirstName?.message} - {...register("companyFirstName")} - /> - Last Name *} - placeholder="Logistics Ltd" - error={errors.companyLastName?.message} - {...register("companyLastName")} - /> - - - Company Email *} + label="Company Email" type="email" placeholder="ops@company.com" error={errors.companyEmail?.message} @@ -656,21 +758,21 @@ export default function CompanyProfileForm({ /> Location *} + label="Location" placeholder="Addis Ababa, Ethiopia" error={errors.companyLocation?.message} {...register("companyLocation")} /> VAT Number *} + label="VAT Number" placeholder="VAT-12345" maxLength={10} error={errors.vatNumber?.message} {...register("vatNumber")} /> FAN Number (16 digits) *} + label="FAN Number (16 digits)" placeholder="1234567890123456" maxLength={16} error={errors.fanNumber?.message} @@ -678,145 +780,126 @@ export default function CompanyProfileForm({ /> - <> + {hasRegistrationDetails && ( + <> - - Registration Details - - - Auto-filled from eTrade — these fields cannot be edited. - + + + Registration Details + + + from eTrade · read-only + + - - - - - - - - - - - - - - Address Information - - - - - - - - - - - - + )} + + + + Address Information + + + + + + + + + + + + + )} {step === "personnel" && ( <> - - General Manager - - toggleOwnerAsGm(e.currentTarget.checked)} + + + General Manager + + {etradeOwner && ( + + )} + + First Name *} - placeholder="Abebe" - error={errors.generalManagerFirstName?.message} - {...register("generalManagerFirstName")} - /> - Last Name *} - placeholder="Bikila" - error={errors.generalManagerLastName?.message} - {...register("generalManagerLastName")} - /> - - - Email *} + label="Email" type="email" placeholder="gm@company.com" error={errors.generalManagerEmail?.message} @@ -834,36 +917,48 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - Contact Person - - toggleGmAsContact(e.currentTarget.checked)} - /> + + + Contact Person + + + + {watch("generalManagerName") && ( + + )} + + First Name *} - placeholder="Jane" - error={errors.contactPersonFirstName?.message} - {...register("contactPersonFirstName")} + label="Name" + placeholder="Jane Smith" + error={errors.contactPersonName?.message} + {...register("contactPersonName")} /> - Last Name *} - placeholder="Smith" - error={errors.contactPersonLastName?.message} - {...register("contactPersonLastName")} - /> - - + + - - )} + {step === "verify" && ( + + + + + Verify the contact person + + + + We'll text a one-time code to the contact person's phone to + confirm it's reachable. This is required before you continue. + + + {!contactPhoneE164 ? ( + } + > + Add a valid contact phone number on the previous step first. + + ) : phoneVerified ? ( + } + title="Phone verified" + > + {maskPhone(contactPhoneE164)} has been verified. + + ) : ( + + + + + {maskPhone(contactPhoneE164)} + + + + {!otpSent ? ( + + ) : ( + + + Enter the 6-digit code we sent to{" "} + {maskPhone(contactPhoneE164)}. + + + + + + + + )} + + {otpError && ( + } + > + {otpError} + + )} + + )} + + )} + {step === "poa" && ( <> - - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. - - toggleContactAsPoa(e.currentTarget.checked)} + + + Power of Attorney details are optional. Fill them in if you + have them, or skip to continue. + + {watch("contactPersonName") && ( + + )} + + - - - - {})} + onChange={onLicenseChange ?? (() => { })} /> )} @@ -983,6 +1176,17 @@ export default function CompanyProfileForm({ )} + {submitError && ( + } + title="Couldn't submit your application" + > + {submitError} + + )} + {showBack ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx new file mode 100644 index 000000000..0599d685e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx @@ -0,0 +1,205 @@ +import { + Alert, + Button, + Group, + Modal, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { AlertCircle, Send, XCircle } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { PriceChangeModal } from "@/pages/bookings/resubmit/PriceChangeModal"; +import { ResubmitDocuments } from "@/pages/bookings/resubmit/ResubmitDocuments"; +import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow"; + +import { CardTitle, PageShell, SectionCard } from "./components/layout"; +import { BodyGrid } from "./components/layout"; +import { ActionRequiredBanner, MutationErrors } from "./components/Notices"; +import { PageHeader } from "./components/PageHeader"; +import { EstimateCard } from "./components/pricing"; +import { ScheduleCard } from "./components/ScheduleCard"; +import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; +import { StatusHero } from "./components/StatusHero"; +import { SupportCard } from "./components/SupportCard"; + +/** + * Detail-page view for a booking staff returned with CHANGES_REQUESTED. + * + * Unlike a fresh draft, this booking already went through submission, so the + * documents shown are exactly the files the customer submitted (`booking.files`) + * — not a fixed required-document list. The customer reviews the staff note, + * replaces any document they need to update, and resubmits in place. + */ +export function ChangesRequestedView({ + booking, + onBookingUpdated, +}: { + booking: Freight.IBooking; + onBookingUpdated: () => void; +}) { + const navigate = useNavigate(); + const flow = useResubmitFlow(booking, { onResubmitted: onBookingUpdated }); + + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + + const { data: generatedPricing } = useQuery( + api.bookings.generatePrice.queryOptions({ + input: { id: booking.id }, + enabled: !booking.pricingBreakdown, + }), + ); + const pricing = (booking.pricingBreakdown ?? + generatedPricing ?? + null) as Freight.PricingBreakdown | null; + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => + api.bookings.cancel.call({ id: booking.id, reason }), + onSuccess: () => { + setCancelDialogOpen(false); + onBookingUpdated(); + }, + }); + + return ( + + setCancelDialogOpen(true), + onSupport: () => navigate("/support"), + }} + /> + + + + + {booking.latestChangeRequestNote ? ( + + {booking.latestChangeRequestNote} + + ) : undefined} + + + + + + + + Your documents + + + Update the documents for this booking, then resubmit for review. + Replace any that changed and attach any that are still required. + + + + + {flow.validationError && ( + } + mt="md" + > + {flow.validationError} + + )} + + + + + } + right={ + <> + + + setCancelDialogOpen(true)} /> + + } + /> + + + + setCancelDialogOpen(false)} + title={Cancel booking} + radius="lg" + centered + > + + + Are you sure you want to cancel {booking.reference}? + This action cannot be undone. + + setCancelReason(e.currentTarget.value)} + radius="md" + data-autofocus + /> + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index 351ff6f5f..1ba37fb85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -31,11 +31,7 @@ import { CardTitle, PageShell, SectionCard } from "./components/layout"; import { CountChip, DocRow, IconSquare } from "./components/Documents"; import { EstimateCard } from "./components/pricing"; import { HeaderButton, PageHeader } from "./components/PageHeader"; -import { - ActionRequiredBanner, - MutationErrors, - NoticeBanner, -} from "./components/Notices"; +import { MutationErrors, NoticeBanner } from "./components/Notices"; import { ScheduleCard } from "./components/ScheduleCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { StatusHero } from "./components/StatusHero"; @@ -77,9 +73,7 @@ export function DraftBookingView({ const { data: generatedPricing } = useQuery( api.bookings.generatePrice.queryOptions({ input: { id: booking.id }, - enabled: - (booking.status === "DRAFT" || booking.status === "CHANGES_REQUESTED") && - !booking.pricingBreakdown, + enabled: booking.status === "DRAFT" && !booking.pricingBreakdown, }), ); const pricing = (booking.pricingBreakdown ?? @@ -87,17 +81,8 @@ export function DraftBookingView({ null) as Freight.PricingBreakdown | null; const uploadMutation = useMutation({ - mutationFn: async (files: Record) => { - if (booking.status === "CHANGES_REQUESTED") { - const result = await api.bookings.update.call({ - id: booking.id, - dto: {}, - documents: files, - }); - return result.booking; - } - return api.bookings.uploadDocuments.call({ id: booking.id, files }); - }, + mutationFn: (files: Record) => + api.bookings.uploadDocuments.call({ id: booking.id, files }), onSuccess: () => { setSelectedFiles({}); setDocError(""); @@ -190,19 +175,7 @@ export function DraftBookingView({ ]} /> - - {booking.status === "CHANGES_REQUESTED" && - booking.latestChangeRequestNote ? ( - - navigate(`/bookings/${booking.id}/edit?section=documents`) - } - > - {booking.latestChangeRequestNote} - - ) : undefined} - + f.code === doc.key); - const allowReplace = - !isUploaded || booking.status === "CHANGES_REQUESTED"; + // In a draft, an already-uploaded doc can still be replaced + // before first submit. + const allowReplace = !isUploaded; return ( {} : undefined, - onRebook: () => navigate("/bookings/new"), + onRebook: () => navigate("/bookings/new", { state: { fresh: true } }), onSupport: () => navigate("/support"), }} /> @@ -110,14 +110,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) : "This booking process has been terminated." } reason={booking.latestChangeRequestNote} - onRebook={() => navigate("/bookings/new")} + onRebook={() => navigate("/bookings/new", { state: { fresh: true } })} /> ) : isExpired ? ( navigate("/bookings/new")} + onRebook={() => navigate("/bookings/new", { state: { fresh: true } })} /> ) : isPendingConsolidation ? ( - - - Approved - - - ); - } - if (doc.reviewStatus === "QUERIED") { - return ( - - - - Queried - - - ); - } - if (doc.file) { - return ( - - - - Pending review - - - ); - } - return ( - - Not uploaded - - ); -} /** - * Customer-facing clearance section: shows the resolved document grid, lets the - * customer (re)upload pending/queried documents plus ad-hoc named documents, and - * proceed to operation once Global Logistics marks the booking CLEARANCE_READY. + * Customer-facing clearance section on the booking detail page: shows the + * resolved document grid, lets the customer (re)upload pending/queried documents + * plus ad-hoc named documents, and proceed to operation once Global Logistics + * marks the booking CLEARANCE_READY. + * + * The flow body, calendar, and mutations are shared with the home-page action + * modal via `useClearanceFlow` / `ClearanceFlow`. */ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { - const queryClient = useQueryClient(); const navigate = useNavigate(); - const status = booking.status as string; + const flow = useClearanceFlow(booking); - const { data: clearance, isLoading } = useQuery( - api.bookings.getClearance.queryOptions({ input: { id: booking.id } }), - ); - - // Pending uploads keyed by fileKey, plus ad-hoc rows (label + file). - const [pending, setPending] = useState>({}); - const [adHoc, setAdHoc] = useState>( - [], - ); - - const refresh = () => { - queryClient.invalidateQueries({ - queryKey: api.bookings.getClearance.queryKey({ id: booking.id }), - }); - queryClient.invalidateQueries({ - queryKey: api.bookings.get.queryKey({ id: booking.id }), - }); - }; - - const uploadMutation = useMutation({ - ...api.bookings.submitClearanceDocuments.mutationOptions(), - onSuccess: () => { - setPending({}); - setAdHoc([]); - refresh(); - }, - }); - - const proceedMutation = useMutation({ - ...api.bookings.proceedToOperation.mutationOptions(), - onSuccess: () => refresh(), - }); - - // Only the customer-input documents are uploadable here; GL output docs are - // shown read-only. - const customerDocs = useMemo( - () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), - [clearance], - ); - const glDocs = useMemo( - () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), - [clearance], - ); - - if (status === "OPERATION_REQUESTED") { + if (flow.status === "OPERATION_REQUESTED") { return ( Operation @@ -132,246 +33,56 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { ); } - if (isLoading || !clearance) { + if (flow.isLoading || !flow.clearance) { return ( Clearance documents - - Loading clearance… - ); } - const isReady = status === "CLEARANCE_READY"; - const canUpload = - status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW"; - - function handleSubmit() { - const files: Record = { ...pending }; - adHoc.forEach((row, i) => { - if (row.file) files[`custom_${Date.now()}_${i}`] = row.file; - }); - if (Object.keys(files).length === 0) return; - uploadMutation.mutate({ id: booking.id, files }); - } - return ( Clearance documents - {clearance.includesCustoms && ( - - Customs clearance - - )} - {isReady ? ( - } mb="md"> - Clearance is ready. You can now proceed to operation. - - ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( - } mb="md"> - Global Logistics is reviewing your documents. Queried documents below - need to be re-uploaded. - - ) : ( - } mb="md"> - Upload the documents below to start the clearance review. - - )} - - - {customerDocs.map((doc) => ( - - - - - - - - - {doc.label} - {doc.required ? " *" : ""} - - {doc.file && ( - - {doc.file.name} - - )} - - - - - {doc.file && ( - } /> - )} - {canUpload && doc.reviewStatus !== "APPROVED" && ( - - f && setPending((p) => ({ ...p, [doc.fileKey]: f })) - } - accept="application/pdf,image/*" - > - {(props) => ( - - )} - - )} - - - {doc.reviewStatus === "QUERIED" && doc.note && ( - - Query: {doc.note} - - )} - {pending[doc.fileKey] && ( - - Ready to upload: {pending[doc.fileKey].name} - - )} - - ))} - - - {/* GL output documents (read-only to the customer). */} - {glDocs.length > 0 && ( - <> - - Customs output documents - - - {glDocs.map((doc) => ( - + {flow.canUpload && ( + + Submit documents + + )} + {flow.isReady && ( + + )} - - {adHoc.map((row, i) => ( - - - setAdHoc((rows) => - rows.map((r, j) => - j === i ? { ...r, name: e.currentTarget.value } : r, - ), - ) - } - style={{ flex: 1 }} - radius="md" - /> - - setAdHoc((rows) => - rows.map((r, j) => (j === i ? { ...r, file: f } : r)), - ) - } - accept="application/pdf,image/*" - > - {(props) => ( - - )} - - - ))} - - - )} - - {uploadMutation.isError && ( - } mt="md"> - {uploadMutation.error instanceof Error - ? uploadMutation.error.message - : "Upload failed. Please try again."} - - )} - - - {canUpload && ( - - )} - {isReady && ( - - )} - + } + /> ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 92eb042a4..67be567db 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -177,6 +177,61 @@ export const STATUS_MAP: Record< description: "Cargo has been consolidated with a partner shipment.", stage: 5, }, + AWAITING_DOCUMENTS: { + title: "Clearance documents needed", + description: + "Upload the required clearance documents so your shipment can be reviewed.", + stage: 5, + }, + DOCUMENTS_UNDER_REVIEW: { + title: "Documents under review", + description: + "Your clearance documents are being reviewed. Re-upload any queried documents to proceed.", + stage: 5, + }, + CLEARANCE_READY: { + title: "Cleared — choose a shipment day", + description: + "Clearance is complete. Pick a shipment day and proceed to operation.", + stage: 5, + }, + OPERATION_REQUESTED: { + title: "Operation requested", + description: "Operation requested. An operator will take your shipment forward.", + stage: 5, + }, + CONTRACT_ACTIVE: { + title: "Contract active", + description: "This general contract is active and accepting drawdown orders.", + stage: 5, + }, + CONTRACT_CLOSED: { + title: "Contract closed", + description: + "This general contract is closed — its reserved quantity has been used or its window has elapsed.", + stage: 7, + }, + PRICE_CHANGED_PENDING_CONFIRM: { + title: "Price changed — confirm to proceed", + description: + "The price for this booking changed. Confirm the new price to continue.", + stage: 1, + }, + READY_FOR_ASSIGNMENT: { + title: "Awaiting wagon assignment", + description: "Approved and queued for wagon assignment.", + stage: 2, + }, + WAGON_ASSIGNED: { + title: "Wagon assigned", + description: "A wagon has been assigned and your cargo is being prepared for loading.", + stage: 5, + }, + INVOICED: { + title: "Invoice issued", + description: "An invoice has been issued for this booking.", + stage: 5, + }, COMPLETED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx index 02e0318fd..46f1d1921 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx @@ -5,6 +5,7 @@ import { useParams } from "react-router-dom"; import { api } from "@/services/api"; +import { ChangesRequestedView } from "./ChangesRequestedView"; import { DraftBookingView } from "./DraftBookingView"; import { PageShell, SectionCard } from "./components/layout"; import { ReadonlyBookingView } from "./ReadonlyBookingView"; @@ -77,6 +78,17 @@ export default function BookingDetailPage() { ); } + // Staff returned the booking for changes: resubmit-with-updated-documents + // flow, driven by the files the customer actually submitted. + if (booking.status === "CHANGES_REQUESTED") { + return ( + + ); + } + // Brand-new draft: collect the required documents before first submit. if (isDraftLike(booking.status)) { return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 89c32d26f..f7c9190b3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -34,8 +34,8 @@ import { import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { PayNowButton } from "./payments/PayNowButton"; -import useAuth from "@/hooks/useAuth"; -import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; +import { BookingActionButton } from "./clearance/BookingActionButton"; +import { bookingHasInlineAction } from "./clearance/bookingNextAction"; import { BookingTypeBadge, CargoModeCell, @@ -65,7 +65,11 @@ import { // ── Status filter options (grouped by lifecycle) ────────────────────────────── const STATUS_FILTERS = [ - { key: "all", label: "All bookings", statuses: undefined as string | undefined }, + { + key: "all", + label: "All bookings", + statuses: undefined as string | undefined, + }, { key: "active", label: "In progress", @@ -81,12 +85,19 @@ const STATUS_FILTERS = [ }, { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, - { key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" }, + { + key: "closed", + label: "Cancelled / rejected", + statuses: "CANCELLED,REJECTED", + }, ] as const; type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"]; -const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label })); +const SELECT_DATA = STATUS_FILTERS.map((f) => ({ + value: f.key, + label: f.label, +})); // ── Summary stat cards (clickable lifecycle filters) ────────────────────────── @@ -97,42 +108,42 @@ const STAT_CARDS: Array<{ iconBg: string; iconColor: string; }> = [ - { - key: "all", - label: "All bookings", - icon: LayoutList, - iconBg: "#ECF6F1", - iconColor: "#0A8A5F", - }, - { - key: "active", - label: "In progress", - icon: Package, - iconBg: "#FDF3E0", - iconColor: "#C77F09", - }, - { - key: "payment", - label: "Awaiting payment", - icon: Wallet, - iconBg: "#FEF6E6", - iconColor: "#F2A516", - }, - { - key: "draft", - label: "Drafts", - icon: FileEdit, - iconBg: "#F1F4F7", - iconColor: "#475569", - }, - { - key: "done", - label: "Completed", - icon: CheckCircle2, - iconBg: "#ECF6F1", - iconColor: "#0A8A5F", - }, -]; + { + key: "all", + label: "All bookings", + icon: LayoutList, + iconBg: "#ECF6F1", + iconColor: "#0A8A5F", + }, + { + key: "active", + label: "In progress", + icon: Package, + iconBg: "#FDF3E0", + iconColor: "#C77F09", + }, + { + key: "payment", + label: "Awaiting payment", + icon: Wallet, + iconBg: "#FEF6E6", + iconColor: "#F2A516", + }, + { + key: "draft", + label: "Drafts", + icon: FileEdit, + iconBg: "#F1F4F7", + iconColor: "#475569", + }, + { + key: "done", + label: "Completed", + icon: CheckCircle2, + iconBg: "#ECF6F1", + iconColor: "#0A8A5F", + }, + ]; // ── Status badge (reuses the shared portal status config) ───────────────────── @@ -140,8 +151,12 @@ function StatusBadge({ status }: { status: string }) { const cfg = STATUS_CONFIG[status]; const label = cfg?.badgeLabel ?? status.replace(/_/g, " "); const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7"; - const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569"; - const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8"; + const text = cfg + ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` + : "#475569"; + const dot = cfg + ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` + : "#94A3B8"; return ( } - style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }} + style={{ + backgroundColor: "var(--mantine-color-edr-ink-0)", + color: "#fff", + }} onClick={go} > Continue ); } - if (status === "CHANGES_REQUESTED") { - return ( - - ); + // CHANGES_REQUESTED + clearance/operation steps are handled in place by a + // modal (update & resubmit, upload clearance docs, schedule & proceed). + if (bookingHasInlineAction(booking)) { + return ; } const payableStatus = isGeneralContract ? "FULLY_EXECUTED" @@ -221,7 +229,14 @@ function PrimaryAction({ return ; } return ( - ); @@ -233,7 +248,11 @@ function ColHeader({ label }: { label: string }) { fz={11} fw={700} c="edr-muted" - style={{ letterSpacing: "0.6px", textTransform: "uppercase", whiteSpace: "nowrap" }} + style={{ + letterSpacing: "0.6px", + textTransform: "uppercase", + whiteSpace: "nowrap", + }} > {label} @@ -248,22 +267,19 @@ function fmtDate(iso?: string | null): string { return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); + year: "numeric", + month: "short", + day: "numeric", + }); } // ── Main component ──────────────────────────────────────────────────────────── // Lightweight count query for a single lifecycle filter (reads only `total`). -function useStatusCount( - statuses: string | undefined, - companyProfileId?: string, -): number | undefined { +function useStatusCount(statuses: string | undefined): number | undefined { const { data } = useQuery( api.bookings.list.queryOptions({ - input: { statuses, companyProfileId, page: 1, pageSize: 1 }, + input: { statuses, page: 1, pageSize: 1 }, staleTime: 30_000, }), ); @@ -339,21 +355,10 @@ export default function MyBookings() { const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState(null); const [freightFilter, setFreightFilter] = useState(null); - const [serviceFilter, setServiceFilter] = useState(null); const [createdFrom, setCreatedFrom] = useState(""); const [createdTo, setCreatedTo] = useState(""); - - // Operational-service options (importer / exporter / freight forwarder) for - // the per-page filter. Empty for non-customer companies. - const { company } = useAuth(); - const companyProfiles = company?.company?.companyProfiles ?? []; - const serviceOptions = companyProfiles.map((p) => ({ - value: p.id, - label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`, - })); - const [trackingBooking, setTrackingBooking] = useState( - null, - ); + const [trackingBooking, setTrackingBooking] = + useState(null); const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; @@ -366,15 +371,10 @@ export default function MyBookings() { }; const hasExtraFilters = - !!typeFilter || - !!freightFilter || - !!serviceFilter || - !!createdFrom || - !!createdTo; + !!typeFilter || !!freightFilter || !!createdFrom || !!createdTo; const clearExtraFilters = () => { setTypeFilter(null); setFreightFilter(null); - setServiceFilter(null); setCreatedFrom(""); setCreatedTo(""); resetPage(); @@ -385,7 +385,6 @@ export default function MyBookings() { statuses, bookingType: typeFilter ?? undefined, freightType: freightFilter ?? undefined, - companyProfileId: serviceFilter ?? undefined, createdFrom: createdFrom || undefined, // include the whole selected end day createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined, @@ -396,7 +395,6 @@ export default function MyBookings() { statuses, typeFilter, freightFilter, - serviceFilter, createdFrom, createdTo, pagination.pageIndex, @@ -408,33 +406,25 @@ export default function MyBookings() { api.bookings.list.queryOptions({ input: filter }), ); - // Per-card lifecycle counts (one cheap query each, total-only). Scoped to the - // selected service so the cards match the filtered table. - const svc = serviceFilter ?? undefined; - const allCount = useStatusCount(undefined, svc); + // Per-card lifecycle counts (one cheap query each, total-only). + const allCount = useStatusCount(undefined); const activeCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "active")!.statuses, - svc, ); const paymentCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "payment")!.statuses, - svc, ); const draftCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "draft")!.statuses, - svc, ); const doneCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "done")!.statuses, - svc, ); const transitCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "transit")!.statuses, - svc, ); const closedCount = useStatusCount( STATUS_FILTERS.find((f) => f.key === "closed")!.statuses, - svc, ); const cardCounts: Record = { all: allCount, @@ -462,8 +452,7 @@ export default function MyBookings() { const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; - const showEmpty = - !isLoading && !isError && rows.length === 0; + const showEmpty = !isLoading && !isError && rows.length === 0; const columns: ColumnDef[] = [ { @@ -473,7 +462,8 @@ export default function MyBookings() { header: () => , cell: ({ row }) => { const b = row.original; - const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container"; + const cargoLabel = + b.freightType === "BULK" ? "Bulk cargo" : "Container"; return ( - + @@ -594,7 +588,12 @@ export default function MyBookings() { const booking = row.original; const trackable = TRACKABLE_STATUSES.has(booking.status); return ( - e.stopPropagation()}> + e.stopPropagation()} + > {trackable && (