From fee4365b7023f02107da4755922a147535735e99 Mon Sep 17 00:00:00 2001 From: hagiye Date: Sat, 27 Jun 2026 21:04:27 +0300 Subject: [PATCH] Import operation gate pass --- apps/edr-freight-api/package.json | 2 + apps/edr-freight-api/src/app.module.ts | 4 + ...00000000-CreateImportDjiboutiOperations.ts | 53 ++ ...3000000000-CreateImportOperationsTables.ts | 95 +++ .../dto/import-operations.dto.ts | 188 ++++++ .../entities/djibouti-incident.entity.ts | 50 ++ .../entities/empty-container-return.entity.ts | 56 ++ .../import-customs-finalization.entity.ts | 48 ++ .../import-operations.controller.ts | 110 ++++ .../import-operations.module.ts | 22 + .../import-operations.service.ts | 211 +++++++ .../dto/import-djibouti-operation.dto.ts | 55 ++ .../import-djibouti-operation.entity.ts | 55 ++ .../train-scheduling.controller.ts | 101 ++++ .../train-scheduling.module.ts | 2 + .../train-scheduling.service.spec.ts | 3 + .../train-scheduling.service.ts | 558 ++++++++++++++++++ .../warehouses/dto/bulk-receive.dto.ts | 9 +- .../warehouses/dto/receive-inventory.dto.ts | 4 + .../warehouse-inventory.controller.ts | 6 + .../warehouses/warehouse-inventory.service.ts | 119 ++-- .../modules/warehouses/warehouses.module.ts | 1 + ...d-approved-first-lastmile-demo-bookings.ts | 28 + .../src/scripts/seed-import-djibouti-demo.ts | 219 +++++++ ...ved-first-lastmile-demo-bookings.seeder.ts | 376 ++++++++++++ .../components/cargoes/CargoFormDialog.tsx | 2 +- .../warehouses/ReceiveInventoryModal.tsx | 291 +++++++-- .../backoffice/src/constants/URLS.ts | 36 ++ .../src/pages/fleet/config/vehicles.ts | 2 - .../TrainScheduleV2DetailPage.tsx | 49 ++ .../backoffice/src/services/api.ts | 7 + .../src/services/importOperations.service.ts | 136 +++++ .../src/services/trainScheduling.service.ts | 99 ++++ .../src/services/warehouse.service.ts | 2 + .../backoffice/src/types/importOperations.ts | 124 ++++ .../backoffice/src/types/trainScheduling.ts | 75 +++ .../backoffice/src/types/warehouse.ts | 2 +- 37 files changed, 3084 insertions(+), 116 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts create mode 100644 apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/entities/djibouti-incident.entity.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/entities/import-customs-finalization.entity.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts create mode 100644 apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts create mode 100644 apps/edr-freight-api/src/scripts/seed-approved-first-lastmile-demo-bookings.ts create mode 100644 apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts create mode 100644 apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/importOperations.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/importOperations.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 290ecec69..b7830f06b 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -19,6 +19,8 @@ "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", + "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", + "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.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", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 02183f390..fd9f3af33 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -55,6 +55,7 @@ import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -68,6 +69,7 @@ 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'; +import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; @Module({ imports: [ @@ -131,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte FirstMileModule, LastMileModule, InterchangeDocumentsModule, + ImportOperationsModule, ], providers: [ EdrOrgSeeder, @@ -147,6 +150,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte Batch8TestDataSeeder, WarehouseDemoSeeder, ExportDjiboutiInterchangeDemoSeeder, + ApprovedFirstLastMileDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts new file mode 100644 index 000000000..981918f25 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface { + name = 'CreateImportDjiboutiOperations1822000000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'import_djibouti_operations', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'train_schedule_id', type: 'uuid', isUnique: true }, + { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true }, + { name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true }, + { name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true }, + { name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true }, + { name: 'load_list_generated_at', type: 'timestamptz', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'notes', 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 }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.import_djibouti_operations', + new TableIndex({ + name: 'idx_import_djibouti_operations_schedule', + columnNames: ['train_schedule_id'], + }), + ); + + await queryRunner.createForeignKey( + 'freight.import_djibouti_operations', + new TableForeignKey({ + columnNames: ['train_schedule_id'], + referencedTableName: 'train_schedules', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.import_djibouti_operations', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts new file mode 100644 index 000000000..1a198e983 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateImportOperationsTables1823000000000 implements MigrationInterface { + name = 'CreateImportOperationsTables1823000000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'djibouti_import_incidents', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'container_number', type: 'varchar', length: '80', isNullable: true }, + { name: 'cargo_id', type: 'uuid', isNullable: true }, + { name: 'facility', type: 'varchar', length: '120', isNullable: true }, + { name: 'station', type: 'varchar', length: '120', isNullable: true }, + { name: 'incident_type', type: 'varchar', length: '40' }, + { name: 'description', type: 'text' }, + { name: 'photos', type: 'jsonb', default: "'[]'::jsonb" }, + { name: 'reported_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'reported_at', type: 'timestamptz' }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] })); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] })); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'import_customs_finalizations', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid', isUnique: true }, + { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true }, + { name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true }, + { name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true }, + { name: 'customs_risk', type: 'varchar', length: '12', isNullable: true }, + { name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true }, + { name: 'completed_at', type: 'timestamptz', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'notes', 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 }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] })); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'empty_container_returns', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'container_number', type: 'varchar', length: '80' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'return_date', type: 'timestamptz' }, + { name: 'facility', type: 'varchar', length: '120', isNullable: true }, + { name: 'yard', type: 'varchar', length: '120', isNullable: true }, + { name: 'zone', type: 'varchar', length: '120', isNullable: true }, + { name: 'condition', type: 'text', isNullable: true }, + { name: 'handover_note', type: 'text', isNullable: true }, + { name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" }, + { name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] })); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] })); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.empty_container_returns', true); + await queryRunner.dropTable('freight.import_customs_finalizations', true); + await queryRunner.dropTable('freight.djibouti_import_incidents', true); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts new file mode 100644 index 000000000..1cf4c72e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -0,0 +1,188 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity'; +import { + EMPTY_CONTAINER_RETURN_STATUSES, + type EmptyContainerReturnStatus, +} from '../entities/empty-container-return.entity'; +import { + IMPORT_CUSTOMS_RISK_LEVELS, + type ImportCustomsDocumentType, + type ImportCustomsRiskLevel, +} from '../entities/import-customs-finalization.entity'; + +export const IMPORT_CUSTOMS_DOCUMENT_TYPES = [ + 'IM4', + 'IM5', + 'T1_CLOSURE_PROOF', + 'TRANSIT_PERMIT_SCREENSHOT', + 'CUSTOMER_PAYMENT_SLIP', + 'IMPORT_RELEASE_PERMIT', +] as const; + +export class CreateDjiboutiIncidentDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + facility?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + station?: string; + + @ApiProperty({ enum: DJIBOUTI_INCIDENT_TYPES }) + @IsIn(DJIBOUTI_INCIDENT_TYPES) + incidentType!: DjiboutiIncidentType; + + @ApiProperty() + @IsString() + description!: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + photos?: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reportedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + reportedAt?: string; +} + +export class UploadImportCustomsDocumentDto { + @ApiProperty({ enum: IMPORT_CUSTOMS_DOCUMENT_TYPES }) + @IsIn(IMPORT_CUSTOMS_DOCUMENT_TYPES) + documentType!: ImportCustomsDocumentType; + + @ApiProperty() + @IsString() + fileId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class RecordDeclarationDto { + @ApiProperty() + @IsString() + declarationSerialNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class AssignCustomsRiskDto { + @ApiProperty({ enum: IMPORT_CUSTOMS_RISK_LEVELS }) + @IsIn(IMPORT_CUSTOMS_RISK_LEVELS) + risk!: ImportCustomsRiskLevel; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class ImportOperationActionDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +export class CreateEmptyContainerReturnDto { + @ApiProperty() + @IsString() + containerNumber!: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + customerId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + returnDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + facility?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + yard?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + zone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + condition?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + handoverNote?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto { + @ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES }) + @IsIn(EMPTY_CONTAINER_RETURN_STATUSES) + status!: EmptyContainerReturnStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + wagonAllocationReference?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + handoverNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/djibouti-incident.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/djibouti-incident.entity.ts new file mode 100644 index 000000000..cda4fbef6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/entities/djibouti-incident.entity.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const DJIBOUTI_INCIDENT_TYPES = [ + 'SEAL_BROKEN', + 'CONTAINER_OPENED', + 'CONTAINER_DAMAGED', + 'FLUID_LEAKING', + 'QUANTITY_MISMATCH', + 'WEIGHT_MISMATCH', + 'OTHER', +] as const; + +export type DjiboutiIncidentType = (typeof DJIBOUTI_INCIDENT_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'djibouti_import_incidents' }) +@Index(['bookingId']) +@Index(['containerNumber']) +@Index(['incidentType']) +export class DjiboutiIncident extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 80, nullable: true }) + containerNumber?: string | null; + + @Column({ name: 'cargo_id', type: 'uuid', nullable: true }) + cargoId?: string | null; + + @Column({ name: 'facility', type: 'varchar', length: 120, nullable: true }) + facility?: string | null; + + @Column({ name: 'station', type: 'varchar', length: 120, nullable: true }) + station?: string | null; + + @Column({ name: 'incident_type', type: 'varchar', length: 40 }) + incidentType!: DjiboutiIncidentType; + + @Column({ name: 'description', type: 'text' }) + description!: string; + + @Column({ name: 'photos', type: 'jsonb', default: () => "'[]'::jsonb" }) + photos!: string[]; + + @Column({ name: 'reported_by', type: 'varchar', length: 120, nullable: true }) + reportedBy?: string | null; + + @Column({ name: 'reported_at', type: 'timestamptz' }) + reportedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts new file mode 100644 index 000000000..727aee322 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -0,0 +1,56 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const EMPTY_CONTAINER_RETURN_STATUSES = [ + 'RETURNED', + 'ASSIGNED_STORAGE', + 'DOCUMENTATION_CLEARED', + 'WAGON_ALLOCATED', + 'TRANSPORTED_TO_DJIBOUTI', + 'HANDOVER_ISSUED', + 'COMPLETED', +] as const; + +export type EmptyContainerReturnStatus = (typeof EMPTY_CONTAINER_RETURN_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'empty_container_returns' }) +@Index(['containerNumber']) +@Index(['bookingId']) +@Index(['status']) +export class EmptyContainerReturn extends BaseEntity { + @Column({ name: 'container_number', type: 'varchar', length: 80 }) + containerNumber!: string; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'return_date', type: 'timestamptz' }) + returnDate!: Date; + + @Column({ name: 'facility', type: 'varchar', length: 120, nullable: true }) + facility?: string | null; + + @Column({ name: 'yard', type: 'varchar', length: 120, nullable: true }) + yard?: string | null; + + @Column({ name: 'zone', type: 'varchar', length: 120, nullable: true }) + zone?: string | null; + + @Column({ name: 'condition', type: 'text', nullable: true }) + condition?: string | null; + + @Column({ name: 'handover_note', type: 'text', nullable: true }) + handoverNote?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 40, default: 'RETURNED' }) + status!: EmptyContainerReturnStatus; + + @Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true }) + wagonAllocationReference?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/import-customs-finalization.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/import-customs-finalization.entity.ts new file mode 100644 index 000000000..9d5bcded7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/entities/import-customs-finalization.entity.ts @@ -0,0 +1,48 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const IMPORT_CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'BLUE', 'RED'] as const; +export type ImportCustomsRiskLevel = (typeof IMPORT_CUSTOMS_RISK_LEVELS)[number]; + +export type ImportCustomsDocumentType = + | 'IM4' + | 'IM5' + | 'T1_CLOSURE_PROOF' + | 'TRANSIT_PERMIT_SCREENSHOT' + | 'CUSTOMER_PAYMENT_SLIP' + | 'IMPORT_RELEASE_PERMIT'; + +@Entity({ schema: 'freight', name: 'import_customs_finalizations' }) +@Index(['bookingId'], { unique: true }) +@Index(['customsRisk']) +export class ImportCustomsFinalization extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" }) + documents!: Partial>; + + @Column({ name: 'declaration_serial_number', type: 'varchar', length: 120, nullable: true }) + declarationSerialNumber?: string | null; + + @Column({ name: 'duties_taxes_notified_at', type: 'timestamptz', nullable: true }) + dutiesTaxesNotifiedAt?: Date | null; + + @Column({ name: 'duties_taxes_paid_at', type: 'timestamptz', nullable: true }) + dutiesTaxesPaidAt?: Date | null; + + @Column({ name: 'customs_risk', type: 'varchar', length: 12, nullable: true }) + customsRisk?: ImportCustomsRiskLevel | null; + + @Column({ name: 'import_release_permitted_at', type: 'timestamptz', nullable: true }) + importReleasePermittedAt?: Date | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts new file mode 100644 index 000000000..d5e31adf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -0,0 +1,110 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + AssignCustomsRiskDto, + CreateDjiboutiIncidentDto, + CreateEmptyContainerReturnDto, + ImportOperationActionDto, + RecordDeclarationDto, + UpdateEmptyContainerReturnStatusDto, + UploadImportCustomsDocumentDto, +} from './dto/import-operations.dto'; +import { ImportOperationsService } from './import-operations.service'; + +@ApiTags('import-operations') +@ApiBearerAuth() +@Controller('import-operations') +export class ImportOperationsController { + constructor(private readonly service: ImportOperationsService) {} + + @Get('djibouti-incidents') + @ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' }) + listIncidents(@Query('bookingId') bookingId?: string) { + return this.service.listIncidents(bookingId); + } + + @Post('djibouti-incidents') + @ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' }) + createIncident(@Body() dto: CreateDjiboutiIncidentDto) { + return this.service.createIncident(dto); + } + + @Get('customs/:bookingId') + @ApiOperation({ summary: 'Batch 12: import customs finalization state' }) + getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.service.getCustoms(bookingId); + } + + @Post('customs/:bookingId/documents') + @ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' }) + uploadCustomsDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: UploadImportCustomsDocumentDto, + ) { + return this.service.uploadCustomsDocument(bookingId, dto); + } + + @Post('customs/:bookingId/declaration') + @ApiOperation({ summary: 'Batch 12: record declaration serial number' }) + recordDeclaration( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: RecordDeclarationDto, + ) { + return this.service.recordDeclaration(bookingId, dto); + } + + @Post('customs/:bookingId/notify-duties-taxes') + @ApiOperation({ summary: 'Batch 12: notify duties and taxes' }) + notifyDutiesTaxes( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ImportOperationActionDto, + ) { + return this.service.notifyDutiesTaxes(bookingId, dto); + } + + @Post('customs/:bookingId/duties-taxes-paid') + @ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' }) + markDutiesTaxesPaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ImportOperationActionDto, + ) { + return this.service.markDutiesTaxesPaid(bookingId, dto); + } + + @Post('customs/:bookingId/risk') + @ApiOperation({ summary: 'Batch 12: assign customs risk' }) + assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) { + return this.service.assignRisk(bookingId, dto); + } + + @Post('customs/:bookingId/release-permitted') + @ApiOperation({ summary: 'Batch 12: mark import release permitted' }) + markReleasePermitted( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ImportOperationActionDto, + ) { + return this.service.markReleasePermitted(bookingId, dto); + } + + @Get('empty-container-returns') + @ApiOperation({ summary: 'Batch 16: list empty container returns' }) + listEmptyReturns() { + return this.service.listEmptyReturns(); + } + + @Post('empty-container-returns') + @ApiOperation({ summary: 'Batch 16: create an empty container return record' }) + createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) { + return this.service.createEmptyReturn(dto); + } + + @Post('empty-container-returns/:id/status') + @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) + updateEmptyReturnStatus( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEmptyContainerReturnStatusDto, + ) { + return this.service.updateEmptyReturnStatus(id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts new file mode 100644 index 000000000..fb4c6e896 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { DjiboutiIncident } from './entities/djibouti-incident.entity'; +import { EmptyContainerReturn } from './entities/empty-container-return.entity'; +import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity'; +import { ImportOperationsController } from './import-operations.controller'; +import { ImportOperationsService } from './import-operations.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + DjiboutiIncident, + ImportCustomsFinalization, + EmptyContainerReturn, + ]), + ], + controllers: [ImportOperationsController], + providers: [ImportOperationsService], + exports: [ImportOperationsService], +}) +export class ImportOperationsModule {} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts new file mode 100644 index 000000000..bbc1228a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -0,0 +1,211 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { + CreateDjiboutiIncidentDto, + CreateEmptyContainerReturnDto, + ImportOperationActionDto, + RecordDeclarationDto, + AssignCustomsRiskDto, + UpdateEmptyContainerReturnStatusDto, + UploadImportCustomsDocumentDto, +} from './dto/import-operations.dto'; +import { + DjiboutiIncident, + type DjiboutiIncidentType, +} from './entities/djibouti-incident.entity'; +import { EmptyContainerReturn } from './entities/empty-container-return.entity'; +import { + ImportCustomsFinalization, + type ImportCustomsDocumentType, +} from './entities/import-customs-finalization.entity'; + +const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [ + 'SEAL_BROKEN', + 'CONTAINER_OPENED', + 'CONTAINER_DAMAGED', + 'FLUID_LEAKING', +]; + +@Injectable() +export class ImportOperationsService { + constructor( + @InjectRepository(DjiboutiIncident) + private readonly incidents: Repository, + @InjectRepository(ImportCustomsFinalization) + private readonly customs: Repository, + @InjectRepository(EmptyContainerReturn) + private readonly emptyReturns: Repository, + ) {} + + listIncidents(bookingId?: string) { + return this.incidents.find({ + where: bookingId ? { bookingId } : {}, + order: { reportedAt: 'DESC', createdAt: 'DESC' } as never, + }); + } + + async createIncident(dto: CreateDjiboutiIncidentDto) { + const photos = dto.photos ?? []; + if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) { + throw new BadRequestException('Photos are required for damage-related Djibouti incidents'); + } + + const incident = await this.incidents.save( + this.incidents.create({ + bookingId: dto.bookingId, + containerNumber: dto.containerNumber ?? null, + cargoId: dto.cargoId ?? null, + facility: dto.facility ?? null, + station: dto.station ?? null, + incidentType: dto.incidentType, + description: dto.description, + photos, + reportedBy: dto.reportedBy ?? null, + reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(), + }), + ); + + console.log( + `[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`, + ); + console.log( + `[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`, + ); + return incident; + } + + async getCustoms(bookingId: string) { + return this.getOrCreateCustoms(bookingId); + } + + async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) { + const row = await this.getOrCreateCustoms(bookingId); + const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId }; + await this.customs.update(row.id, { + documents, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + return this.getCustoms(bookingId); + } + + async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) { + const row = await this.getOrCreateCustoms(bookingId); + await this.customs.update(row.id, { + declarationSerialNumber: dto.declarationSerialNumber, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + return this.getCustoms(bookingId); + } + + async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) { + const row = await this.getOrCreateCustoms(bookingId); + await this.customs.update(row.id, { + dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(), + performedBy: dto.performedBy ?? row.performedBy ?? null, + notes: dto.notes ?? row.notes ?? null, + }); + console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`); + return this.getCustoms(bookingId); + } + + async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) { + const row = await this.getOrCreateCustoms(bookingId); + this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid'); + await this.customs.update(row.id, { + dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(), + performedBy: dto.performedBy ?? row.performedBy ?? null, + notes: dto.notes ?? row.notes ?? null, + }); + return this.getCustoms(bookingId); + } + + async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) { + const row = await this.getOrCreateCustoms(bookingId); + await this.customs.update(row.id, { + customsRisk: dto.risk, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`); + return this.getCustoms(bookingId); + } + + async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) { + const row = await this.getOrCreateCustoms(bookingId); + this.assertReleaseReady(row); + await this.customs.update(row.id, { + importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(), + completedAt: row.completedAt ?? new Date(), + performedBy: dto.performedBy ?? row.performedBy ?? null, + notes: dto.notes ?? row.notes ?? null, + }); + console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`); + return this.getCustoms(bookingId); + } + + listEmptyReturns() { + return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); + } + + async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { + return this.emptyReturns.save( + this.emptyReturns.create({ + containerNumber: dto.containerNumber, + bookingId: dto.bookingId ?? null, + customerId: dto.customerId ?? null, + returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(), + facility: dto.facility ?? null, + yard: dto.yard ?? null, + zone: dto.zone ?? null, + condition: dto.condition ?? null, + handoverNote: dto.handoverNote ?? null, + performedBy: dto.performedBy ?? null, + }), + ); + } + + async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) { + const row = await this.emptyReturns.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException(`Empty container return ${id} not found`); + } + await this.emptyReturns.update(id, { + status: dto.status, + wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null, + handoverNote: dto.handoverNote ?? row.handoverNote ?? null, + performedBy: dto.performedBy ?? row.performedBy ?? null, + }); + return this.emptyReturns.findOneOrFail({ where: { id } }); + } + + private async getOrCreateCustoms(bookingId: string) { + const existing = await this.customs.findOne({ where: { bookingId } }); + if (existing) return existing; + return this.customs.save(this.customs.create({ bookingId, documents: {} })); + } + + private assertDocument( + row: ImportCustomsFinalization, + type: ImportCustomsDocumentType, + message: string, + ) { + if (!row.documents?.[type]) { + throw new BadRequestException(message); + } + } + + private assertReleaseReady(row: ImportCustomsFinalization) { + this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release'); + this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted'); + if (!row.declarationSerialNumber?.trim()) { + throw new BadRequestException('Declaration serial number is required before import release'); + } + if (!row.customsRisk) { + throw new BadRequestException('Customs risk must be assigned before import release'); + } + if (!row.dutiesTaxesPaidAt) { + throw new BadRequestException('Duties and taxes must be paid before import release'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts new file mode 100644 index 000000000..9bd9f3957 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [ + 'DELIVERY_ORDER', + 'PORT_INVOICE', + 'DJIBOUTI_T1', + 'ETHIOPIA_T1', + 'RAILWAY_BILL', +] as const; + +export type ImportDjiboutiDocumentType = (typeof IMPORT_DJIBOUTI_DOCUMENT_TYPES)[number]; + +export class UploadImportDjiboutiDocumentDto { + @ApiProperty({ enum: IMPORT_DJIBOUTI_DOCUMENT_TYPES }) + @IsIn(IMPORT_DJIBOUTI_DOCUMENT_TYPES) + documentType!: ImportDjiboutiDocumentType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class ImportDjiboutiActionDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts new file mode 100644 index 000000000..792792070 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export type ImportDjiboutiDocumentType = + | 'DELIVERY_ORDER' + | 'PORT_INVOICE' + | 'DJIBOUTI_T1' + | 'ETHIOPIA_T1' + | 'RAILWAY_BILL'; + +export interface ImportDjiboutiDocumentRecord { + fileId?: string | null; + fileUrl?: string | null; + reference?: string | null; + uploadedAt: string; + uploadedBy?: string | null; + notes?: string | null; +} + +@Entity({ schema: 'freight', name: 'import_djibouti_operations' }) +@Index(['trainScheduleId'], { unique: true }) +export class ImportDjiboutiOperation extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @OneToOne(() => TrainSchedule) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" }) + documents!: Partial>; + + @Column({ name: 'gatepass_granted_at', type: 'timestamptz', nullable: true }) + gatepassGrantedAt?: Date | null; + + @Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true }) + readyForLoadingAt?: Date | null; + + @Column({ name: 'loaded_on_train_at', type: 'timestamptz', nullable: true }) + loadedOnTrainAt?: Date | null; + + @Column({ name: 'departed_from_djibouti_at', type: 'timestamptz', nullable: true }) + departedFromDjiboutiAt?: Date | null; + + @Column({ name: 'load_list_generated_at', type: 'timestamptz', nullable: true }) + loadListGeneratedAt?: Date | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | 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 08fc74172..1272c8fea 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 @@ -8,9 +8,11 @@ import { Patch, Post, Query, + Res, } from "@nestjs/common"; import { CurrentUser } from "@edr/api-common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; @@ -30,6 +32,10 @@ import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.d import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; +import { + ImportDjiboutiActionDto, + UploadImportDjiboutiDocumentDto, +} from "./dto/import-djibouti-operation.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; @@ -304,6 +310,101 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("schedules/:id/import-djibouti") + @TrainSchedulingView() + @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) + getImportDjiboutiOperation(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportDjiboutiOperation(id); + } + + @Post("schedules/:id/import-djibouti/documents") + @TrainSchedulingManage() + @ApiOperation({ summary: "Upload/check an import Djibouti-side document" }) + uploadImportDjiboutiDocument( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UploadImportDjiboutiDocumentDto, + ) { + return this.trainSchedulingService.uploadImportDjiboutiDocument(id, dto); + } + + @Post("schedules/:id/import-djibouti/gatepass-granted") + @TrainSchedulingManage() + @ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" }) + grantImportDjiboutiGatepass( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.grantImportDjiboutiGatepass(id, dto); + } + + @Post("schedules/:id/import-djibouti/ready-for-loading") + @TrainSchedulingManage() + @ApiOperation({ summary: "Mark import train ready for loading at Djibouti" }) + markImportReadyForLoading( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.markImportReadyForLoading(id, dto); + } + + @Post("schedules/:id/import-djibouti/loaded-on-train") + @TrainSchedulingManage() + @ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" }) + confirmImportLoadedOnTrain( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto); + } + + @Post("schedules/:id/import-djibouti/depart") + @TrainSchedulingManage() + @ApiOperation({ summary: "Depart loaded import train from Djibouti" }) + departImportFromDjibouti( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.departImportFromDjibouti(id, dto); + } + + @Post("schedules/:id/import-djibouti/load-list") + @TrainSchedulingManage() + @ApiOperation({ summary: "Generate import load list / marshalling document summary" }) + generateImportLoadList( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.generateImportLoadList(id, dto); + } + + @Get("schedules/:id/import-djibouti/load-list/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download printable import load list / marshalling PDF" }) + async importLoadListDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.importLoadListDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + + @Get("schedules/:id/export/load-list/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download printable export marshalling / load list PDF" }) + async exportLoadListDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.exportLoadListDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") 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 2ed5eaa42..9b8efd9e0 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 @@ -17,6 +17,7 @@ import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { Wagon } from '../wagons/entities/wagon.entity'; import { WarehousesModule } from '../warehouses/warehouses.module'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainSchedulingController } from './train-scheduling.controller'; @@ -38,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; Container, TrainSchedulingGlobalRules, TrainCheckpointEvent, + ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), NotificationsModule, 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 f59597aaa..b449669ec 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 @@ -151,6 +151,9 @@ describe('TrainSchedulingService', () => { autoUnloadArrivedBookings: jest.fn(), autoUnloadExportAtDjibouti: jest.fn(), } as never, + { + htmlToPdfBuffer: jest.fn(), + } as never, ); const defaultFleetWagons = [ 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 c56dd6dd1..784a2c6a0 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 @@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { + ImportDjiboutiOperation, + type ImportDjiboutiDocumentType, +} from './entities/import-djibouti-operation.entity'; +import { + IMPORT_DJIBOUTI_DOCUMENT_TYPES, + ImportDjiboutiActionDto, + UploadImportDjiboutiDocumentDto, +} from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { buildCappedWagonPlan, @@ -97,6 +106,7 @@ import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service'; +import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { autoFillPlacements, findMissingContainerNumberIssues, @@ -170,6 +180,7 @@ export class TrainSchedulingService { private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly warehouseInventoryService: WarehouseInventoryService, + private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly configService?: ConfigService, ) {} @@ -767,6 +778,7 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + await this.assertImportDjiboutiMayDepart(schedule); const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -806,9 +818,555 @@ export class TrainSchedulingService { .execute(); }); + if (this.isImportDjiboutiSchedule(schedule)) { + const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id); + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now, + }); + console.log( + `[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`, + ); + } + return this.getTrainScheduleById(scheduleId); } + async getImportDjiboutiOperation(scheduleId: string) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + return this.mapImportDjiboutiOperation(schedule, operation); + } + + async uploadImportDjiboutiDocument( + scheduleId: string, + dto: UploadImportDjiboutiDocumentDto, + ) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const documents = { + ...(operation.documents ?? {}), + [dto.documentType]: { + fileId: dto.fileId ?? null, + fileUrl: dto.fileUrl ?? null, + reference: dto.reference ?? null, + uploadedAt: new Date().toISOString(), + uploadedBy: dto.performedBy ?? null, + notes: dto.notes ?? null, + }, + }; + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + documents, + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const missing = this.missingImportDjiboutiDocuments(operation); + if (missing.length) { + throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`); + } + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + console.log( + `[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`, + ); + return this.getImportDjiboutiOperation(schedule.id); + } + + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + readyForLoadingAt: operation.readyForLoadingAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + readyForLoadingAt: operation.readyForLoadingAt ?? new Date(), + loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + if (!operation.loadedOnTrainAt) { + throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); + } + + if (schedule.status === TrainScheduleStatusEnum.Scheduled) { + await this.dispatchSchedule(schedule.id); + } else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti'); + } + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + return this.getImportDjiboutiOperation(schedule.id); + } + + async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const generatedAt = operation.loadListGeneratedAt ?? new Date(); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + loadListGeneratedAt: generatedAt, + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return { + generatedAt: generatedAt.toISOString(), + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? null, + route: schedule.route?.name ?? null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + totalBookings: schedule.scheduleBookings?.length ?? 0, + wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({ + sequenceNo: wagon.sequenceNo, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + allocations: (wagon.allocations ?? []).map((allocation) => ({ + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + loadType: allocation.loadType ?? null, + allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean), + })), + })), + operation: await this.getImportDjiboutiOperation(schedule.id), + }; + } + + async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const loadList = await this.generateImportLoadList(scheduleId, { + performedBy: 'DOCUMENT_GENERATION', + }); + const html = this.buildImportLoadListHtml(loadList); + const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + const reference = loadList.trainNumber ?? loadList.trainScheduleId; + return { + filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.isExportSchedule(schedule)) { + throw new BadRequestException('Export marshalling document applies only to EXPORT schedules'); + } + + const html = this.buildExportLoadListHtml(schedule); + const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + private buildExportLoadListHtml(schedule: TrainSchedule): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); + const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); + const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); + const rows = (schedule.trainSet?.wagons ?? []) + .flatMap((wagon) => + (wagon.allocations ?? []).map((allocation) => { + const booking = allocation.booking ?? bookingById.get(allocation.bookingId); + const company = booking?.company as Record | null | undefined; + const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; + const containerItems = allocation.containerItems ?? []; + const firstContainer = containerItems[0]; + const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', '); + const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', '); + const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); + return ` + ${esc(wagon.sequenceNo)} + ${esc(wagon.physicalWagon?.wagonNumber)} + ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} + ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} + ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} + ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} + ${esc(booking?.companyId)} + ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(containerNumbers || firstContainer?.containerNumber)} + ${esc(chassisNumbers)} + ${esc(sealNumbers)} + `; + }), + ) + .join(''); + const totalWeight = (schedule.trainSet?.wagons ?? []).reduce( + (sum, wagon) => + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + 0, + ); + + return ` + + + + Export Marshalling Document + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Export Marshalling Document / Load List

+
+
+ Train / Schedule + ${esc(schedule.trainNumber ?? schedule.id)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ +
+
Train ID${esc(schedule.trainNumber ?? schedule.id)}
+
Departure date${esc(date(schedule.scheduledDepartureDate))}
+
Departure time${esc(time(schedule.scheduledDepartureDate))}
+
Departure station${esc(schedule.originStation?.label ?? schedule.originStation?.code)}
+
Arrival station${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}
+
Total loaded weight${esc(totalWeight.toFixed(3))} T
+
Prepared person${esc(schedule.preparedByUserId)}
+
Check person${esc(schedule.checkedByUserId)}
+
Wagons${esc(schedule.trainSet?.wagons?.length ?? 0)}
+
Bookings${esc(schedule.scheduleBookings?.length ?? 0)}
+
Status${esc(schedule.status)}
+
Direction${esc(schedule.direction)}
+
+ + + + + + + + + + + + + + + + + + + + ${rows || ''} + +
SeqWagon NoWagon TypeEquated LengthTare WeightLoad CapacityCustomer NameCustomer IDCargo TypeContainer NoChassis NoSeal No
No wagon allocations found for this export train.
+ +
+ Loading and dispatch staff must verify wagon identity, seal number, container number, + cargo type, and customer booking against the physical consist before departure. +
+ +
+
Prepared person / date
+
Check person / date
+
Operations authorization / date
+
+ +`; + } + + private isExportSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return direction === 'EXPORT'; + } + + private buildImportLoadListHtml(loadList: Awaited>): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); + const status = loadList.operation.status; + const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); + const totalWeight = loadList.wagons.reduce( + (sum, wagon) => + sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + 0, + ); + const allocationRows = loadList.wagons + .flatMap((wagon) => + wagon.allocations.map( + (allocation) => ` + ${esc(wagon.sequenceNo)} + ${esc(wagon.wagonNumber)} + ${esc(allocation.bookingReference ?? allocation.bookingId)} + ${esc(allocation.loadType)} + ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} + `, + ), + ) + .join(''); + + return ` + + + + Import Load List / Marshalling Document + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

Import Load List /
Marshalling Document

+
Djibouti-side gatepass, loading, and departure manifest
+
+
+ Train / Schedule + ${esc(loadList.trainNumber ?? loadList.trainScheduleId)} + Generated: ${esc(date(loadList.generatedAt))} +
+
+ +
+
Route${esc(loadList.route)}
+
Origin${esc(loadList.origin)}
+
Destination${esc(loadList.destination)}
+
Total bookings${esc(loadList.totalBookings)}
+
Wagons${esc(loadList.wagons.length)}
+
Allocations${esc(totalAllocations)}
+
Total weight${esc(totalWeight.toFixed(3))} T
+
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
+
+ +
+
Documents
+
Gatepass
+
Ready
+
Loaded
+
Departed
+
Document
+
+ +

Wagon Marshalling Allocation

+ + + + + + + + + + + + + ${allocationRows || ''} + +
SeqWagonBookingLoadContainer numbersWeight T
No wagon allocations found for this train.
+ +
+ Gate and loading staff must verify this document against the granted gatepass, + railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure. +
+ +
+
Prepared by Djibouti operations
+
Train loading supervisor
+
EDR operations authorization
+
+ + +
+ +`; + } + + private safeDocumentName(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); + } + + private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise { + if (!this.isImportDjiboutiSchedule(schedule)) return; + const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({ + where: { trainScheduleId: schedule.id }, + }); + this.assertImportDjiboutiGatepassGranted(operation); + if (!operation?.loadedOnTrainAt) { + throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); + } + } + + private async getImportDjiboutiSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.isImportDjiboutiSchedule(schedule)) { + throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti'); + } + return schedule; + } + + private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return ( + direction === 'IMPORT' && + this.isDjiboutiPortDestination( + `${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`, + ) + ); + } + + private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise { + const repo = this.dataSource.getRepository(ImportDjiboutiOperation); + const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); + if (existing) return existing; + return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} })); + } + + private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] { + const documents = operation?.documents ?? {}; + return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]); + } + + private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void { + if (!operation?.gatepassGrantedAt) { + throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted'); + } + } + + private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) { + const missingDocuments = this.missingImportDjiboutiDocuments(operation); + return { + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, + status: { + documentsComplete: missingDocuments.length === 0, + missingDocuments, + gatepassGranted: Boolean(operation.gatepassGrantedAt), + readyForLoading: Boolean(operation.readyForLoadingAt), + loadedOnTrain: Boolean(operation.loadedOnTrainAt), + departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt), + loadListGenerated: Boolean(operation.loadListGeneratedAt), + }, + documents: operation.documents ?? {}, + gatepassGrantedAt: operation.gatepassGrantedAt ?? null, + readyForLoadingAt: operation.readyForLoadingAt ?? null, + loadedOnTrainAt: operation.loadedOnTrainAt ?? null, + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null, + loadListGeneratedAt: operation.loadListGeneratedAt ?? null, + performedBy: operation.performedBy ?? null, + notes: operation.notes ?? null, + }; + } + /** * 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 diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 4f0ce0012..5e31c8593 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @ApiPropertyOptional() @@ -179,8 +181,11 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; - @ApiProperty({ type: TruckEntranceDto }) - truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional({ type: TruckEntranceDto }) + @IsOptional() + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance?: TruckEntranceDto; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts index e8a73190c..e4df4ce5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ValidateNested } from 'class-validator'; import { TruckEntranceDto } from './bulk-receive.dto'; export class ReceiveWarehouseInventoryDto { @@ -57,6 +59,8 @@ export class ReceiveWarehouseInventoryDto { notes?: string; @ApiProperty({ type: TruckEntranceDto }) + @ValidateNested() + @Type(() => TruckEntranceDto) truckEntrance!: TruckEntranceDto; @ApiPropertyOptional() 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 230192a91..204d0f0d4 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 @@ -86,6 +86,12 @@ export class WarehouseInventoryController { return this.inventoryService.readyToLoadExport(); } + @Get('received-export') + @ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' }) + receivedExport() { + return this.inventoryService.receivedExport(); + } + @Get('loaded-export') @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) loadedExport() { 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 e25286252..2bfddbe37 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 @@ -842,8 +842,12 @@ export class WarehouseInventoryService { const now = new Date(); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); - const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking); - this.assertTruckEntrance(truckEntrance); + const truckEntrance = dto.truckEntrance + ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) + : undefined; + if (dto.direction === 'EXPORT') { + this.assertTruckEntrance(truckEntrance); + } const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, @@ -856,7 +860,7 @@ export class WarehouseInventoryService { yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - quantity: 1, + quantity: Number(booking.containerQuantity) || 1, weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: now, @@ -869,16 +873,18 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`, + description: truckEntrance?.truckPlateNumber + ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` + : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, performedBy: dto.performedBy, }, manager, ); await this.notifyOwnerInventoryReceived({ - phone: truckEntrance.customerPhone, - ownerName: truckEntrance.ownerName, - bookingReference: truckEntrance.edrDigitalBookingId, + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, grnNumber, direction: dto.direction, warehouseId: dto.warehouseId, @@ -982,6 +988,11 @@ export class WarehouseInventoryService { return this.exportInventoryByStatus('READY_FOR_LOADING', true); } + /** EXPORT inventory received at the facility and awaiting inspection. */ + async receivedExport(): Promise { + return this.exportInventoryByStatus('RECEIVED'); + } + /** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */ async loadedExport(): Promise { return this.exportInventoryByStatus('LOADED'); @@ -1592,19 +1603,27 @@ export class WarehouseInventoryService { } async receive(dto: ReceiveWarehouseInventoryDto): Promise { - const weight = Number(dto.weight) || 0; - const volume = Number(dto.volume) || 0; - const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); - if (dto.bookingId) { - await this.assertBookingExists(manager, dto.bookingId); + const bookingSource = dto.bookingId + ? await this.getBookingTruckEntranceSource(manager, dto.bookingId) + : null; + if (dto.bookingId && !bookingSource?.reference) { + throw new NotFoundException(`Booking ${dto.bookingId} not found`); } + const quantity = bookingSource + ? Number(bookingSource.containerQuantity) || 1 + : Number(dto.quantity) || 0; + const weight = bookingSource + ? Number(bookingSource.weight) || 0 + : Number(dto.weight) || 0; + const volume = Number(dto.volume) || 0; + const containerCount = dto.containerId ? Math.round(quantity) : 0; const truckEntrance = dto.bookingId - ? this.mergeSystemTruckEntrance(dto.truckEntrance, await this.getBookingTruckEntranceSource(manager, dto.bookingId)) + ? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {}) : dto.truckEntrance; this.assertTruckEntrance(truckEntrance); @@ -1628,7 +1647,7 @@ export class WarehouseInventoryService { cargoId: dto.cargoId ?? null, containerId: dto.containerId ?? null, goodsId: dto.goodsId ?? null, - quantity: Number(dto.quantity) || 0, + quantity, weight, volume: dto.volume ?? null, status: 'RECEIVED', @@ -2619,16 +2638,6 @@ export class WarehouseInventoryService { return { warehouse, yard, zone }; } - private async assertBookingExists(manager: EntityManager, bookingId: string): Promise { - const rows = await manager.query( - 'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', - [bookingId], - ); - if (!rows || rows.length === 0) { - throw new NotFoundException(`Booking ${bookingId} not found`); - } - } - private appendNote(existing: string | null | undefined, note: string): string { const trimmed = existing?.trim(); return trimmed ? `${trimmed}\n${note}` : note; @@ -2872,42 +2881,42 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; notes?: string | null; - truckEntrance: TruckEntranceDto; + truckEntrance?: TruckEntranceDto; }): string { const truck = input.truckEntrance; const rows = [ `GRN Number: ${input.grnNumber}`, input.direction ? `Direction: ${input.direction}` : null, - truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null, - truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, - truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, - truck.tin ? `TIN: ${truck.tin}` : null, - truck.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null, - `Truck Plate: ${truck.truckPlateNumber}`, - truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, - truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, - truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null, - truck.truckType ? `Truck Type: ${truck.truckType}` : null, - `Driver: ${truck.driverName}`, - `Driver Phone: ${truck.driverPhone}`, - truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, - `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`, - truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, - truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, - truck.incoterms ? `Incoterms: ${truck.incoterms}` : null, - truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, - truck.itemCode ? `Item Code: ${truck.itemCode}` : null, - truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null, - truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null, - truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, - truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, - truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, - truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, - truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, - truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, - truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null, - truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null, - truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null, + truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null, + truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, + truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, + truck?.tin ? `TIN: ${truck.tin}` : null, + truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null, + truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null, + truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, + truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, + truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null, + truck?.truckType ? `Truck Type: ${truck.truckType}` : null, + truck?.driverName ? `Driver: ${truck.driverName}` : null, + truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, + truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, + truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, + truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, + truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, + truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, + truck?.itemCode ? `Item Code: ${truck.itemCode}` : null, + truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null, + truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null, + truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, + truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, + truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, + truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, + truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, + truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null, + truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null, + truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null, input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null, ]; return rows.filter(Boolean).join('\n'); 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 cbdb5522c..1671b63e8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -124,6 +124,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseAllocationService, WarehouseFeeService, WarehouseSchedulingAdapterService, + WarehouseReleaseDocumentService, ], }) export class WarehousesModule {} diff --git a/apps/edr-freight-api/src/scripts/seed-approved-first-lastmile-demo-bookings.ts b/apps/edr-freight-api/src/scripts/seed-approved-first-lastmile-demo-bookings.ts new file mode 100644 index 000000000..7ff90463f --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-approved-first-lastmile-demo-bookings.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { ApprovedFirstLastMileDemoBookingsSeeder } from '../seed/approved-first-lastmile-demo-bookings.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(ApprovedFirstLastMileDemoBookingsSeeder); + await seeder.run(); + console.log('Approved first/last-mile demo bookings seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Approved first/last-mile demo booking seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts b/apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts new file mode 100644 index 000000000..a16ea2ee4 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts @@ -0,0 +1,219 @@ +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 { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.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'; + +const DEMO_TRAINS = [ + { + trainNumber: 'IMP-DJB-NAGAD-01', + bookingRefs: ['IMP-DJB-NGD-001', 'IMP-DJB-NGD-002', 'IMP-DJB-NGD-003'], + departureOffsetHours: 4, + }, + { + trainNumber: 'IMP-DJB-NAGAD-02', + bookingRefs: ['IMP-DJB-NGD-004', 'IMP-DJB-NGD-005', 'IMP-DJB-NGD-006'], + departureOffsetHours: 8, + }, +]; + +function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} + +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 locomotiveRepo = dataSource.getRepository(Locomotive); + const trainSetRepo = dataSource.getRepository(TrainSet); + const scheduleRepo = dataSource.getRepository(TrainSchedule); + const bookingRepo = dataSource.getRepository(Booking); + const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking); + const importOperationRepo = dataSource.getRepository(ImportDjiboutiOperation); + + const originYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const destinationYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'INDODE', + label: 'Indode Dry Port', + country: 'Ethiopia', + isActive: true, + displayOrder: 6, + }), + )); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + + const missing = [ + !originYard ? 'NAGAD/Djibouti origin yard' : '', + !destinationYard ? 'INDODE destination yard' : '', + !serviceType ? 'service type' : '', + ].filter(Boolean); + + if (missing.length) { + throw new Error(`Cannot seed Djibouti-side import demo, missing: ${missing.join(', ')}`); + } + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-IMP-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'ICD-DEMO-IMP-LOCO', + name: 'Djibouti Import Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'IMPORT_READY', + currentYardId: originYard!.id, + }), + )); + + const now = new Date(); + const seededSchedules: TrainSchedule[] = []; + + for (const [trainIndex, demo] of DEMO_TRAINS.entries()) { + const existingSchedule = await scheduleRepo.findOne({ + where: { trainNumber: demo.trainNumber }, + }); + + if (existingSchedule) { + console.log(`Djibouti-side import demo already seeded: ${demo.trainNumber}`); + console.log(`Schedule ID: ${existingSchedule.id}`); + seededSchedules.push(existingSchedule); + continue; + } + + const departure = addHours(now, demo.departureOffsetHours); + const arrival = addHours(departure, 12); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 900 + trainIndex * 120, + totalLengthMeters: 430 + trainIndex * 25, + wagonCount: 18 + trainIndex * 2, + status: 'ASSIGNED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard!.id, + destinationStationId: destinationYard!.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + status: 'SCHEDULED' as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: 'IMPORT', + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }), + ); + + for (const [bookingIndex, reference] of demo.bookingRefs.entries()) { + const weight = 6800 + trainIndex * 900 + bookingIndex * 750; + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'IMPORT', + freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType + ? null + : `Djibouti import demo cargo ${trainIndex + 1}-${bookingIndex + 1}`, + cargoTotalWeightVgm: weight, + priorityScore: 80 - trainIndex * 5 - bookingIndex, + trainScheduleId: schedule.id, + schedulingStatus: 'SCHEDULED', + scheduledAt: now, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + await importOperationRepo.save( + importOperationRepo.create({ + trainScheduleId: schedule.id, + documents: { + DELIVERY_ORDER: { + reference: `DO-${demo.trainNumber}`, + uploadedAt: now.toISOString(), + uploadedBy: 'Demo Seeder', + notes: 'Demo delivery order for Djibouti-side import flow', + }, + RAILWAY_BILL: { + reference: `RB-${demo.trainNumber}`, + uploadedAt: now.toISOString(), + uploadedBy: 'Demo Seeder', + notes: 'Demo railway bill for Djibouti-side import flow', + }, + }, + performedBy: 'Demo Seeder', + notes: '[ICD-DEMO] Nagad to Indode import train for Djibouti-side flow', + }), + ); + + seededSchedules.push(schedule); + } + + console.log('Djibouti-side import demo seeded.'); + console.log(`Corridor: ${originYard!.code} -> ${destinationYard!.code}`); + for (const schedule of seededSchedules) { + console.log(`Train number: ${schedule.trainNumber}`); + console.log(`Schedule ID: ${schedule.id}`); + console.log(`Backoffice URL: /dashboard/operations/train-scheduling-v2/${schedule.id}`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error('Djibouti-side import demo seed failed:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts new file mode 100644 index 000000000..6f86f5c07 --- /dev/null +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -0,0 +1,376 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource, In } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + Company, + CompanyStatus, + CompanyType, +} from '../modules/companies/entities/company.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_FIRST_LAST'; +const COMPANY_TIN = 'DEMO-FIRST-LAST-001'; +const COMPANY_EMAIL = 'first-last-mile-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +const DEMO_BOOKINGS = [ + { + reference: 'DEMO-IMP-FLM-001', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 8, + totalWeightTons: 224, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-01T08:00:00.000Z', + firstMilePickupAddress: 'Doraleh Container Terminal, Djibouti', + firstMilePickupLat: 11.5881, + firstMilePickupLng: 43.1372, + lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa', + lastMileDeliveryLat: 8.8808, + lastMileDeliveryLng: 38.7876, + }, + { + reference: 'DEMO-IMP-FLM-002', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 12, + totalWeightTons: 240, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-02T08:00:00.000Z', + firstMilePickupAddress: 'PK12 Dry Port, Djibouti', + firstMilePickupLat: 11.5536, + firstMilePickupLng: 43.1103, + lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa', + lastMileDeliveryLat: 8.9137, + lastMileDeliveryLng: 38.7815, + }, + { + reference: 'DEMO-IMP-FLM-003', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-03T08:00:00.000Z', + firstMilePickupAddress: 'Djibouti Free Zone Yard', + firstMilePickupLat: 11.5947, + firstMilePickupLng: 43.1471, + lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa', + lastMileDeliveryLat: 8.9806, + lastMileDeliveryLng: 38.8736, + }, + { + reference: 'DEMO-IMP-FLM-004', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 10, + totalWeightTons: 210, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-04T08:00:00.000Z', + firstMilePickupAddress: 'Doraleh Multipurpose Port, Djibouti', + firstMilePickupLat: 11.6062, + firstMilePickupLng: 43.1219, + lastMileDeliveryAddress: 'Addis Ababa Freight Terminal', + lastMileDeliveryLat: 9.0101, + lastMileDeliveryLng: 38.7619, + }, + { + reference: 'DEMO-IMP-FLM-005', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 5, + totalWeightTons: 150, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-05T08:00:00.000Z', + firstMilePickupAddress: 'Djibouti Port Gate 3', + firstMilePickupLat: 11.5999, + firstMilePickupLng: 43.1344, + lastMileDeliveryAddress: 'Sebeta Distribution Center', + lastMileDeliveryLat: 8.9169, + lastMileDeliveryLng: 38.6177, + }, + { + reference: 'DEMO-EXP-FLM-001', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 7, + totalWeightTons: 196, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-01T10:00:00.000Z', + firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa', + firstMilePickupLat: 8.9806, + firstMilePickupLng: 38.8736, + lastMileDeliveryAddress: 'Doraleh Container Terminal, Djibouti', + lastMileDeliveryLat: 11.5881, + lastMileDeliveryLng: 43.1372, + }, + { + reference: 'DEMO-EXP-FLM-002', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 11, + totalWeightTons: 220, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-02T10:00:00.000Z', + firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa', + firstMilePickupLat: 8.8808, + firstMilePickupLng: 38.7876, + lastMileDeliveryAddress: 'Djibouti Free Zone Yard', + lastMileDeliveryLat: 11.5947, + lastMileDeliveryLng: 43.1471, + }, + { + reference: 'DEMO-EXP-FLM-003', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 4, + totalWeightTons: 128, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-03T10:00:00.000Z', + firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa', + firstMilePickupLat: 8.9137, + firstMilePickupLng: 38.7815, + lastMileDeliveryAddress: 'Doraleh Multipurpose Port, Djibouti', + lastMileDeliveryLat: 11.6062, + lastMileDeliveryLng: 43.1219, + }, + { + reference: 'DEMO-EXP-FLM-004', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 9, + totalWeightTons: 180, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-04T10:00:00.000Z', + firstMilePickupAddress: 'Addis Ababa Freight Terminal', + firstMilePickupLat: 9.0101, + firstMilePickupLng: 38.7619, + lastMileDeliveryAddress: 'PK12 Dry Port, Djibouti', + lastMileDeliveryLat: 11.5536, + lastMileDeliveryLng: 43.1103, + }, + { + reference: 'DEMO-EXP-FLM-005', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 168, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-05T10:00:00.000Z', + firstMilePickupAddress: 'Sebeta Distribution Center', + firstMilePickupLat: 8.9169, + firstMilePickupLng: 38.6177, + lastMileDeliveryAddress: 'Djibouti Port Gate 3', + lastMileDeliveryLat: 11.5999, + lastMileDeliveryLng: 43.1344, + }, +] as const; + +@Injectable() +export class ApprovedFirstLastMileDemoBookingsSeeder { + private readonly logger = new Logger(ApprovedFirstLastMileDemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container with First and Last Mile', + description: 'Demo service type for approved first/last-mile bookings', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 10, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'First and Last Mile Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: COMPANY_TIN, + fanNumber: 'FLM0000000000001', + country: 'Ethiopia', + address: 'Addis Ababa', + phone: '251900000101', + email: COMPANY_EMAIL, + website: null, + contactPersonName: 'First Last Mile Demo', + contactPersonPhone: '251900000101', + generalManagerName: 'Demo Manager', + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: '251900000101', + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`approved_first_last_mile_demo_dependency_missing:${demoBooking.reference}`); + } + + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + estimatedShipmentDate: new Date(demoBooking.scheduledDate), + totalAmount: demoBooking.totalWeightTons * 25, + paymentStatus: 'PENDING', + contractType: 'NEW', + serviceTypeId: serviceType.id, + firstMilePickupAddress: demoBooking.firstMilePickupAddress, + firstMilePickupLat: demoBooking.firstMilePickupLat, + firstMilePickupLng: demoBooking.firstMilePickupLng, + lastMileDeliveryAddress: demoBooking.lastMileDeliveryAddress, + lastMileDeliveryLat: demoBooking.lastMileDeliveryLat, + lastMileDeliveryLng: demoBooking.lastMileDeliveryLng, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demoBooking.tradeDirection, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: 'Demo container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + approvedByStaffAt: new Date(), + priorityScore: 20, + wagonsRequired, + schedulingStatus: 'NOT_SCHEDULED', + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + } + + const bookings = await manager.getRepository(Booking).find({ + where: { reference: In(DEMO_BOOKINGS.map((booking) => booking.reference)) }, + select: { id: true }, + }); + const bookingIds = bookings.map((booking) => booking.id); + + await manager.getRepository(FirstMile).delete({ bookingId: In(bookingIds) }); + await manager.getRepository(LastMile).delete({ bookingId: In(bookingIds) }); + + await manager.getRepository(FirstMile).insert( + bookingIds.map((bookingId) => ({ + bookingId, + status: 'READY_TO_TRANSIT', + advancedPayment: 0, + remainingPayment: 0, + estimatedKm: 18, + exactKm: null, + vehicleId: null, + })), + ); + + await manager.getRepository(LastMile).insert( + bookingIds.map((bookingId) => ({ + bookingId, + status: 'READY_TO_TRANSIT', + advancedPayment: 0, + remainingPayment: 0, + estimatedKm: 22, + exactKm: null, + vehicleId: null, + })), + ); + }); + + this.logger.log('Seeded 5 import and 5 export approved bookings with first/last-mile legs'); + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx index ff37edb72..625e32a3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx @@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Loader2 } from 'lucide-react'; -import { API_BASE_URL } from "@/constants/apiConfig"; +import { API_BASE_URL } from '@/constants/apiConfig'; interface Cargo { id: string; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 1503718e5..e2ff9fb3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -88,9 +88,15 @@ interface TruckEntranceFormState { interface LockedTruckEntranceFields { ownerName?: boolean; + consigneeDetails?: boolean; tin?: boolean; edrDigitalBookingId?: boolean; customerPhone?: boolean; + assignedEquipmentNumber?: boolean; + itemDescription?: boolean; + packagingType?: boolean; + unitCount?: boolean; + grossWeightKg?: boolean; } type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED'; @@ -129,23 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ }); const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ - ownerName: form.ownerName.trim() || undefined, - consigneeDetails: form.consigneeDetails.trim() || undefined, - edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, - tin: form.tin.trim() || undefined, - customerPhone: form.customerPhone.trim() || undefined, truckPlateNumber: form.truckPlateNumber.trim(), trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, - assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, incoterms: form.incoterms.trim() || undefined, hsCodes: form.hsCodes.trim() || undefined, itemCode: form.itemCode.trim() || undefined, - itemDescription: form.itemDescription.trim() || undefined, - packagingType: form.packagingType.trim() || undefined, - unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), - grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg), netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg), volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, @@ -221,9 +217,15 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { }, lockedFields: { ownerName: Boolean(ownerName), + consigneeDetails: Boolean(consigneeDetails), tin: Boolean(tin), edrDigitalBookingId: Boolean(edrDigitalBookingId), customerPhone: Boolean(customerPhone), + assignedEquipmentNumber: Boolean(assignedEquipmentNumber), + itemDescription: Boolean(itemDescription), + packagingType: Boolean(packagingType), + unitCount: unitCount !== '', + grossWeightKg: grossWeightKg !== '', }, packagingFreightType, }; @@ -292,6 +294,7 @@ function TruckEntranceFields({ onChange({ ...value, consigneeDetails: e.currentTarget.value })} /> @@ -334,6 +337,7 @@ function TruckEntranceFields({ onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })} /> onChange({ ...value, itemDescription: e.currentTarget.value })} /> @@ -422,6 +427,7 @@ function TruckEntranceFields({ data={packagingOptions} clearable searchable + readOnly={lockedFields?.packagingType} value={value.packagingType} onChange={(v) => onChange({ ...value, packagingType: v ?? '' })} /> @@ -429,6 +435,7 @@ function TruckEntranceFields({ label={quantityLabel} min={0} value={value.unitCount} + readOnly={lockedFields?.unitCount} onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} /> @@ -437,6 +444,7 @@ function TruckEntranceFields({ label="Gross weight (kg)" min={0} value={value.grossWeightKg} + readOnly={lockedFields?.grossWeightKg} onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })} /> 0 && selected.size === selectableRows.length; const someSelected = selected.size > 0 && !allSelected; + const pendingReceiveRows = useMemo( + () => + pendingReceiveIds + .map((id) => rows.find((item) => item.id === id)) + .filter(Boolean) as EligibleBooking[], + [pendingReceiveIds, rows], + ); + const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile); const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); @@ -686,6 +702,29 @@ function EligibleTab({ return next; }); + const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => { + try { + const r = await bulkReceive.mutateAsync({ + direction, + ...location, + bookingIds, + ...(truckEntrance ? { truckEntrance } : {}), + }); + toast({ + title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), + }); + setSelected(new Set()); + setTruckOpen(false); + setPendingReceiveIds([]); + setLockedTruckFields({}); + setPackagingFreightType('MIXED'); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); + } + }; + const openTruckReceive = (bookingIds: string[]) => { if (!locationReady) { toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' }); @@ -704,6 +743,10 @@ function EligibleTab({ const selectedRows = filteredIds .map((id) => rows.find((item) => item.id === id)) .filter(Boolean) as EligibleBooking[]; + if (direction === 'IMPORT') { + void receiveBookings(filteredIds); + return; + } const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); setTruckForm(form); @@ -717,26 +760,7 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); return; } - try { - const r = await bulkReceive.mutateAsync({ - direction, - ...location, - bookingIds: pendingReceiveIds, - truckEntrance: toTruckEntrancePayload(truckForm), - }); - toast({ - title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), - }); - setSelected(new Set()); - setTruckOpen(false); - setPendingReceiveIds([]); - setLockedTruckFields({}); - setPackagingFreightType('MIXED'); - onChanged?.(); - } catch (error) { - toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); - } + await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); }; const loadPassedExport = async () => { @@ -940,11 +964,21 @@ function EligibleTab({ )} - setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg"> + setTruckOpen(false)} + title="Export Truck Arrival / First Mile Receive Form" + centered + size="lg" + > - - Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. - + } color={pendingHasFirstMile ? 'green' : 'blue'} variant="light"> + + {pendingHasFirstMile + ? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.' + : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + + @@ -965,6 +999,161 @@ function EligibleTab({ ); } +/** Export received items awaiting inspection before loading. */ +function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { + const { toast } = useToast(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.receivedExport.queryOptions({ enabled }), + ); + const inspectMutation = useMutation( + api.warehouses.bulkMarkInspected.mutationOptions(), + ); + const [selected, setSelected] = useState>(new Set()); + const [inspectId, setInspectId] = useState(null); + + const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED'); + const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length; + const someSelected = selected.size > 0 && !allSelected; + const toggleAll = () => + setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id))); + const toggleOne = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const markInspected = async () => { + if (selected.size === 0) { + toast({ variant: 'destructive', title: 'Select at least one item' }); + return; + } + try { + const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); + toast({ + title: `${r.inspectedCount} marked inspected`, + description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + }); + setSelected(new Set()); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + + Selected: {selected.size} / {pendingRows.length} received + + + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + + No received export items awaiting inspection. + + ) : ( + + + + + + + + Booking Ref + Booking ID + Customer ID + Customer Name + Container # + Cargo Type + Weight + Route + Inspection + Status + Actions + + + + {rows.map((r: ReadyToLoadRow) => { + const selectable = r.inspectionStatus !== 'PASSED'; + return ( + + + toggleOne(r.id)} + /> + + + {r.bookingReference ?? '—'} + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} + + + {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} + + {r.customerName ?? '—'} + {r.containerNumber ?? '—'} + {r.cargoType ?? '—'} + {formatNumber(Number(r.weight))} + + {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} + + + + {r.inspectionStatus ?? 'PENDING'} + + + + + {r.status} + + + + + + + ); + })} + +
+
+ )} + + setInspectId(null)} + /> +
+ ); +} + /** Export items that passed inspection and are queued to be loaded onto a train. */ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { const { toast } = useToast(); @@ -1765,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal Receive Queue + Received Ready To Load Loaded Dispatch Queue @@ -1773,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal + + + @@ -1849,10 +2042,6 @@ function SingleBookingReceiveModal({ toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' }); return; } - if (form.quantity === '' || form.weight === '') { - toast({ variant: 'destructive', title: 'Quantity and weight are required' }); - return; - } if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); return; @@ -1862,8 +2051,8 @@ function SingleBookingReceiveModal({ warehouseId: form.warehouseId, yardId: form.yardId, zoneId: form.zoneId, - quantity: Number(form.quantity), - weight: Number(form.weight), + quantity: 0, + weight: 0, volume: form.volume === '' ? undefined : Number(form.volume), notes: form.notes.trim() || undefined, truckEntrance: toTruckEntrancePayload(truckForm), @@ -1889,21 +2078,13 @@ function SingleBookingReceiveModal({ setForm((f) => ({ ...f, ...next }))} /> + } color="blue" variant="light"> + + Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated. + + + - setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))} - /> - setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))} - /> `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + IMPORT_DJIBOUTI: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti`, + IMPORT_DJIBOUTI_DOCUMENTS: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti/documents`, + IMPORT_DJIBOUTI_GATEPASS_GRANTED: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`, + IMPORT_DJIBOUTI_READY_FOR_LOADING: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti/ready-for-loading`, + IMPORT_DJIBOUTI_LOADED_ON_TRAIN: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti/loaded-on-train`, + IMPORT_DJIBOUTI_DEPART: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti/depart`, + IMPORT_DJIBOUTI_LOAD_LIST: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti/load-list`, + IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT: (id: string) => + `/train-scheduling/schedules/${id}/import-djibouti/load-list/document`, + EXPORT_LOAD_LIST_DOCUMENT: (id: string) => + `/train-scheduling/schedules/${id}/export/load-list/document`, CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`, ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`, RESCHEDULE_PREVIEW: (id: string) => @@ -322,6 +340,7 @@ export const URL_CONSTANTS = { RECEIVE_BULK: '/warehouse-inventory/receive-bulk', LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export', BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected', + RECEIVED_EXPORT: '/warehouse-inventory/received-export', READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export', LOADED_EXPORT: '/warehouse-inventory/loaded-export', BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export', @@ -377,6 +396,23 @@ export const URL_CONSTANTS = { CANCEL: (id: string) => `/interchange-documents/${id}/cancel`, }, + IMPORT_OPERATIONS: { + DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents', + CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`, + CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`, + CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`, + CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) => + `/import-operations/customs/${bookingId}/notify-duties-taxes`, + CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) => + `/import-operations/customs/${bookingId}/duties-taxes-paid`, + CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`, + CUSTOMS_RELEASE_PERMITTED: (bookingId: string) => + `/import-operations/customs/${bookingId}/release-permitted`, + EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns', + EMPTY_CONTAINER_RETURN_STATUS: (id: string) => + `/import-operations/empty-container-returns/${id}/status`, + }, + VEHICLES: { BASE: '/vehicles', BY_ID: (id: string) => `/vehicles/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 9daa036ae..fc646a8bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -1,5 +1,4 @@ import type { FleetResourceConfig } from "./resources"; -import { API_BASE_URL } from "@/constants/apiConfig"; const VEHICLE_TYPE_OPTIONS = [ { label: "Truck", value: "TRUCK" }, @@ -93,4 +92,3 @@ export const vehiclesConfig: FleetResourceConfig = { }; export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS }; -export { API_BASE_URL }; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 0b35b077e..45735dae9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -19,6 +19,7 @@ import { CheckCircle2, Container as ContainerIcon, Eye, + FileText, LayoutGrid, Navigation, Package, @@ -56,8 +57,10 @@ import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/s import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep"; +import { openPdfBlob } from "@/components/warehouses/pdf"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; +import { trainSchedulingService } from "@/services/trainScheduling.service"; import { useToast } from "@/hooks/use-toast"; import type { ContainerPlacement, @@ -124,6 +127,12 @@ export default function TrainScheduleV2DetailPage() { const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions()); const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions()); + const downloadMarshalling = useMutation({ + mutationFn: ({ id, direction }: { id: string; direction?: string | null }) => + direction === "EXPORT" + ? trainSchedulingService.downloadExportLoadListDocument(id) + : trainSchedulingService.downloadImportDjiboutiLoadListDocument(id), + }); const assignedIds = useMemo( () => (schedule?.bookings ?? []).map((b) => b.id), @@ -304,6 +313,33 @@ export default function TrainScheduleV2DetailPage() { const canDispatch = schedule.status === "SCHEDULED"; const finalizeStep = hasContainerStep ? 3 : 2; const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status); + const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT"; + + const openMarshallingDocument = async () => { + const pdfWindow = window.open("", "_blank"); + try { + const blob = await downloadMarshalling.mutateAsync({ + id: scheduleId, + direction: schedule.direction, + }); + const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling"; + const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`; + const opened = openPdfBlob(blob, filename, pdfWindow); + toast({ + title: "Marshalling document ready", + description: opened + ? "The PDF opened in a browser tab for printing or saving." + : "The browser blocked the preview tab, so the PDF was downloaded.", + }); + } catch (error) { + pdfWindow?.close(); + toast({ + title: "Could not open marshalling document", + description: parseError(error, "Make sure the train has wagon allocations, then try again."), + variant: "destructive", + }); + } + }; const handleAssign = async () => { if (!allSelectedIds.length) return; @@ -766,6 +802,19 @@ export default function TrainScheduleV2DetailPage() { + {canPrintMarshalling ? ( + + ) : null} {["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (