diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 04edf4fba..78c15cba1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,7 +53,7 @@ jobs: NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" - + DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) if [ -z "$DEPLOYABLE" ]; then echo "Only non-deployable files changed. Skipping deploy." @@ -142,6 +142,31 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: ./scripts/deploy/create-npmrc.sh + - name: Resolve env file path for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + case "${{ matrix.service }}" in + passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;; + payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;; + esac + + - name: Build migration image for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + docker build \ + --secret id=npmrc,src=.npmrc \ + --target migration \ + -f "apps/edr-${{ matrix.service }}/Dockerfile" \ + -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ + . + + - name: Run migrations for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" + - name: Build ${{ matrix.service }} run: | set -euo pipefail diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 4ccffb6a4..e02391ccd 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true +SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 580173cc1..2fd9f6f5a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -17,9 +17,18 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed: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:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", - "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" + "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", + "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", + "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", + "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", + "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", + "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -38,13 +47,15 @@ "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cross-env": "^10.1.0", "dotenv": "^17.4.2", + "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a48d248a5..cc15d49ae 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -52,8 +52,10 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; 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'; @@ -67,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: [ @@ -130,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte FirstMileModule, LastMileModule, InterchangeDocumentsModule, + ImportOperationsModule, ], providers: [ EdrOrgSeeder, @@ -145,6 +149,8 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte Batch7TestDataSeeder, Batch8TestDataSeeder, WarehouseDemoSeeder, + ExportDjiboutiInterchangeDemoSeeder, + ApprovedFirstLastMileDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -161,6 +167,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder, + private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -179,6 +186,7 @@ export class AppModule implements OnApplicationBootstrap { await this.batch7TestDataSeeder.run(); await this.batch8TestDataSeeder.run(); await this.warehouseDemoSeeder.run(); + await this.exportDjiboutiInterchangeDemoSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts index 9e0acc6fb..db2f7693e 100644 --- a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -80,8 +80,15 @@ export class ContractPdfService { this.logger.error( `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, ); + const fallback = this.htmlToBasicPdfBuffer(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } throw new InternalServerErrorException( - 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + 'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', ); } } @@ -113,4 +120,85 @@ export class ContractPdfService { buffer.subarray(0, 5).toString('ascii') === '%PDF-' ); } + + private htmlToBasicPdfBuffer(html: string): Buffer { + const text = this.htmlToPlainText(html); + const lines = this.wrapLines(text, 92).slice(0, 72); + const body = lines + .map((line, index) => { + const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; + return `${prefix} (${this.escapePdfText(line)}) Tj`; + }) + .join('\n'); + const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) { + pdf += '% fallback padding\n'; + } + const xrefOffset = Buffer.byteLength(pdf, 'latin1'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, 'latin1'); + } + + private htmlToPlainText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n'); + } + + private wrapLines(text: string, width: number): string[] { + const wrapped: string[] = []; + for (const rawLine of text.split('\n')) { + const words = rawLine.split(' '); + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > width && line) { + wrapped.push(line); + line = word; + } else { + line = next; + } + } + if (line) wrapped.push(line); + } + return wrapped.length ? wrapped : ['Document']; + } + + private escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + } } diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts index b921a7194..0f9021ff2 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface { weight NUMERIC(14,3) NOT NULL DEFAULT 0, volume NUMERIC(12,3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE', + inspection_status VARCHAR(20) NULL, arrived_at TIMESTAMPTZ NULL, inspected_at TIMESTAMPTZ NULL, ready_for_loading_at TIMESTAMPTZ NULL, diff --git a/apps/edr-freight-api/src/migrations/1791000000005-AddWarehouseInventoryInspectionStatusFix.ts b/apps/edr-freight-api/src/migrations/1791000000005-AddWarehouseInventoryInspectionStatusFix.ts new file mode 100644 index 000000000..4a59e33b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000005-AddWarehouseInventoryInspectionStatusFix.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Fix for fresh deployments: AddWarehouseInspection1750000000003 runs before + * the warehouse_inventory table exists, so it cannot add inspection_status. + */ +export class AddWarehouseInventoryInspectionStatusFix1791000000005 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if ((await queryRunner.hasTable(this.table)) && !(await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if ((await queryRunner.hasTable(this.table)) && (await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.dropColumn(this.table, 'inspection_status'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts new file mode 100644 index 000000000..523772b39 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Catch-up for environments where AddWarehouseInspection ran before the + * warehouse module table existed. Production needs this column for unload and + * inspection flows because the WarehouseInventory entity maps inspectionStatus. + */ +export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ + name: 'inspection_status', + type: 'varchar', + length: '20', + isNullable: true, + }), + ); + } + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status + ON freight.warehouse_inventory(inspection_status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status + `); + + if (await queryRunner.hasColumn(this.table, 'inspection_status')) { + await queryRunner.dropColumn(this.table, 'inspection_status'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts new file mode 100644 index 000000000..98fa8b228 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC, + ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS estimated_distance_km, + DROP COLUMN IF EXISTS actual_distance_km; + `); + } +} 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/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ba06604ce..a06f9eb87 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -44,32 +44,59 @@ export class FirstMileService { * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingId: string): Promise { - const booking = await this.bookingsRepository.findById(bookingId); + async acceptBooking(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId, { + relations: { serviceType: true }, + }); if (!booking) { - return null; + throw new NotFoundException(`Booking ${bookingId} not found`); } - if (booking.paymentStatus !== 'PAID') { - return null; - } - - return this.create({ - bookingId: booking.id, - advancedPayment: 0, - }); + return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { - const booking = await this.bookingsRepository.findByReference(bookingReference); + async acceptBookingByReference(bookingReference: string): Promise { + const [booking] = await this.bookingsRepository.findAll({ + where: { reference: bookingReference }, + relations: { serviceType: true }, + take: 1, + }); if (!booking) { - return null; + throw new NotFoundException(`Booking ${bookingReference} not found`); } + return this.acceptEligibleBooking(booking); + } + + /** + * Shared accept path: validates payment + first-mile eligibility, rejects an + * already-assigned booking, then creates the first-mile record. Throws a + * meaningful HTTP error instead of returning null so the client can surface + * why an accept was refused. + */ + private async acceptEligibleBooking(booking: { + id: string; + reference?: string; + paymentStatus?: string | null; + tradeDirection?: string | null; + firstMilePickupAddress?: string | null; + serviceType?: { includesFirstMile?: boolean | null } | null; + }): Promise { + const label = booking.reference ?? booking.id; + if (booking.paymentStatus !== 'PAID') { - return null; + throw new BadRequestException(`Booking ${label} is not paid`); + } + + if (!this.bookingRequestsFirstMile(booking)) { + throw new BadRequestException(`Booking ${label} does not require a first mile`); + } + + const existing = await this.findByBookingId(booking.id); + if (existing) { + throw new ConflictException(`Booking ${label} already has a first-mile assignment`); } return this.create({ @@ -131,6 +158,11 @@ export class FirstMileService { } async create(dto: CreateFirstMileDto): Promise { + const existing = await this.findByBookingId(dto.bookingId); + if (existing) { + return existing; + } + return this.firstMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', @@ -142,6 +174,32 @@ export class FirstMileService { }); } + private async findByBookingId(bookingId: string): Promise { + const [records] = await this.firstMileRepository.findAndCount({ + where: { bookingId }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, + take: 1, + }); + return records[0] ?? null; + } + + private bookingRequestsFirstMile(booking: { + tradeDirection?: string | null; + firstMilePickupAddress?: string | null; + serviceType?: { includesFirstMile?: boolean | null } | null; + }): boolean { + // Export bookings always need a first mile (pickup → origin yard); the + // pickup address is captured at assignment time, not required upfront. + return Boolean( + booking.tradeDirection === 'EXPORT' || + booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile, + ); + } + async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); @@ -167,6 +225,16 @@ export class FirstMileService { return updated; } + async updateStatus(id: string, status: FirstMileStatus): Promise { + const updated = await this.firstMileRepository.update(id, { status }); + + if (!updated) { + throw new NotFoundException(`First-mile record ${id} not found`); + } + + return updated; + } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); 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/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts index 667f22659..fa84f2d94 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -265,12 +265,12 @@ export class InterchangeDocumentsService { ) SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - 'CONTAINER' AS "itemType", + 'CONTAINER'::varchar AS "itemType", COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId", - NULL AS "bookingCargoId", + NULL::uuid AS "bookingCargoId", COALESCE(c.container_number, bc.container_number) AS "containerNumber", c.seal_number AS "sealNumber", - NULL AS "cargoId", + NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight", @@ -288,12 +288,12 @@ export class InterchangeDocumentsService { UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - 'CONTAINER' AS "itemType", + 'CONTAINER'::varchar AS "itemType", bc.id AS "bookingContainerId", - NULL AS "bookingCargoId", + NULL::uuid AS "bookingCargoId", bc.container_number AS "containerNumber", - NULL AS "sealNumber", - NULL AS "cargoId", + NULL::varchar AS "sealNumber", + NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight", @@ -314,11 +314,11 @@ export class InterchangeDocumentsService { UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - 'CARGO' AS "itemType", - NULL AS "bookingContainerId", + 'CARGO'::varchar AS "itemType", + NULL::uuid AS "bookingContainerId", cg.id AS "bookingCargoId", - NULL AS "containerNumber", - NULL AS "sealNumber", + NULL::varchar AS "containerNumber", + NULL::varchar AS "sealNumber", cg.id AS "cargoId", COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType", COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription", @@ -337,12 +337,12 @@ export class InterchangeDocumentsService { UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", - CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", - NULL AS "bookingContainerId", - NULL AS "bookingCargoId", - NULL AS "containerNumber", - NULL AS "sealNumber", - NULL AS "cargoId", + (CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType", + NULL::uuid AS "bookingContainerId", + NULL::uuid AS "bookingCargoId", + NULL::varchar AS "containerNumber", + NULL::varchar AS "sealNumber", + NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", a.cargo_total_weight_vgm AS "weight", diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index 32c2de721..e4b99a18c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; @@ -13,7 +13,7 @@ import { LastMileService } from './last-mile.service'; @Module({ imports: [ TypeOrmModule.forFeature([LastMile]), - BookingsModule, + forwardRef(() => BookingsModule), VehiclesModule, DriversModule, NotificationsModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 22b96a344..2e8fb2463 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -32,6 +32,7 @@ export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( + private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, 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 af6ece15a..1c15ab801 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"; @@ -323,6 +329,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 e38fc4d75..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 @@ -15,7 +15,9 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module' import { WagonType } from '../wagon-types/entities/wagon-type.entity'; 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'; @@ -37,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; Container, TrainSchedulingGlobalRules, TrainCheckpointEvent, + ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), NotificationsModule, @@ -44,6 +47,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; WagonTypesModule, TrainSetsModule, TrainSchedulesModule, + forwardRef(() => WarehousesModule), RuleEngineModule, ], controllers: [TrainSchedulingController], 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 6f22f7a83..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 @@ -147,6 +147,13 @@ describe('TrainSchedulingService', () => { wagonAllocationBulkLoadsRepository as never, trainCheckpointEventsRepository as never, {} as never, // trainCompositionRemovalLogRepository + { + 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 a74a4326e..c71634ccb 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, @@ -95,6 +104,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; 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, @@ -167,6 +179,8 @@ export class TrainSchedulingService { private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, + private readonly warehouseInventoryService: WarehouseInventoryService, + private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly configService?: ConfigService, ) {} @@ -602,6 +616,74 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + private async runWarehouseArrivalAutomation(scheduleId: string) { + const [schedule]: Array<{ + originCountry: string | null; + destinationCountry: string | null; + destinationCode: string | null; + destinationName: string | null; + }> = await this.dataSource.query( + `SELECT oy.country AS "originCountry", + dy.country AS "destinationCountry", + dy.code AS "destinationCode", + dy.name AS "destinationName" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + + if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' }; + + const direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + + try { + if (direction === 'IMPORT') { + return { + direction, + action: 'IMPORT_AUTO_UNLOAD', + status: 'COMPLETED', + result: await this.warehouseInventoryService.autoUnloadArrivedBookings( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ), + }; + } + + if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + return { + direction, + action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD', + status: 'COMPLETED', + result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ), + }; + } + + return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' }; + } catch (error) { + return { + direction, + status: 'FAILED', + reason: error instanceof Error ? error.message : String(error), + }; + } + } + + private isDjiboutiPortDestination(value: string | null | undefined): boolean { + const normalized = (value ?? '').toUpperCase(); + return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) => + normalized.includes(token), + ); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -696,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) => { @@ -735,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 @@ -1063,7 +1692,9 @@ export class TrainSchedulingService { } }); - return this.getTrainScheduleById(scheduleId); + const detail = await this.getTrainScheduleById(scheduleId); + const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); + return Object.assign(detail, { warehouseAutomation }); } async getContainerTrainSchedules() { diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 55e12047b..b5f9abcb8 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -49,4 +49,12 @@ export class CreateVehicleDto { @IsOptional() @IsString() trailerPlateNo?: string; + + @IsOptional() + @IsNumber() + estimatedDistanceKm?: number; + + @IsOptional() + @IsNumber() + actualDistanceKm?: number; } diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 005118b0a..fef078ef8 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -62,4 +62,10 @@ export class Vehicle extends BaseEntity { @Column({ name: 'assigned_driver_name', nullable: true }) assignedDriverName?: string; + + @Column({ name: 'estimated_distance_km', type: 'numeric', nullable: true }) + estimatedDistanceKm?: number; + + @Column({ name: 'actual_distance_km', type: 'numeric', nullable: true }) + actualDistanceKm?: number; } 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 9bb734512..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,161 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +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() + @IsOptional() + @IsString() + ownerName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + consigneeDetails?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + edrDigitalBookingId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + tin?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerPhone?: string; + + @ApiProperty() + @IsString() + truckPlateNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + trailerPlateNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + assignedEquipmentNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + customsSealNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + declarationNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + incoterms?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + hsCodes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + itemCode?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + itemDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + packagingType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + unitCount?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + grossWeightKg?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + netWeightKg?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + volumeDimensions?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + conditionAtReceipt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + damagedRejectedQuantity?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + warehouseCodeLocation?: string; + + @ApiProperty() + @IsString() + driverName!: string; + + @ApiProperty() + @IsString() + driverPhone!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverLicenseNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + truckType?: string; + + @ApiProperty() + @IsNumber() + @Min(0) + entranceTareWeightKg!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + exitTareWeightKg?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverSignatoryName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + warehouseManagerName?: string; +} /** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ export class BulkReceiveDto { @@ -25,6 +181,12 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + @ApiPropertyOptional({ type: TruckEntranceDto }) + @IsOptional() + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance?: TruckEntranceDto; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 9c62aeae5..6d7084a96 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -33,4 +33,14 @@ export class PayInvoiceBodyDto { @IsOptional() @IsString() reference?: string; + + @ApiPropertyOptional({ description: 'Pickup driver name to notify after payment' }) + @IsOptional() + @IsString() + driverName?: string; + + @ApiPropertyOptional({ description: 'Pickup driver phone to notify after payment' }) + @IsOptional() + @IsString() + driverPhone?: string; } 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 46fecb044..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,8 @@ 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 { @ApiProperty({ format: 'uuid' }) @@ -55,6 +58,11 @@ export class ReceiveWarehouseInventoryDto { @IsString() notes?: string; + @ApiProperty({ type: TruckEntranceDto }) + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts index 9d4e3eb4f..681b30284 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsOptional, IsString } from 'class-validator'; +import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; /** Records a DO / release order being sent to the customer for import pickup. */ export class ReleaseOrderDto { @@ -17,4 +17,77 @@ export class ReleaseOrderDto { @IsOptional() @IsString() performedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + bookingId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + truckPlateNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + trailerPlateNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverLicense?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + driverPhone?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + truckType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + gateInTime?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + tareWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + grossWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(0) + netWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + gateOutTime?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 2a61d9d78..ca16bcaa5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -21,6 +21,9 @@ export interface ImportTrainRow { totalBookings: number; totalContainers: number; totalCargoes: number; + unloadedBookings: number; + pendingUnloadBookings: number; + fullyUnloaded: boolean; status: string; } @@ -34,6 +37,7 @@ export interface ImportTrainItemRow { weight: number | null; arrivalTime: string | null; currentStatus: string | null; + inspectionStatus: string | null; lastMileRequested: boolean; pickupOption: string; } @@ -133,7 +137,7 @@ export class SchedulingReadFacade { `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" FROM freight.wagons WHERE deleted_at IS NULL - AND status NOT IN ('RETIRED', 'MAINTENANCE') + AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE') ORDER BY wagon_number ASC`, ); } @@ -205,7 +209,24 @@ export class SchedulingReadFacade { WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers", (SELECT count(*) FROM freight.cargoes cg JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL - WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes" + WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes", + (SELECT count(*) FROM freight.train_schedule_bookings tsbp + JOIN freight.bookings bp ON bp.id = tsbp.booking_id AND bp.deleted_at IS NULL + WHERE tsbp.train_schedule_id = ts.id + AND tsbp.deleted_at IS NULL + AND bp.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED') + AND ( + NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory invp + WHERE invp.booking_id = bp.id AND invp.deleted_at IS NULL + ) + OR EXISTS ( + SELECT 1 FROM freight.warehouse_inventory invr + WHERE invr.booking_id = bp.id + AND invr.deleted_at IS NULL + AND invr.status = 'RECEIVED' + ) + )) AS "pendingUnloadBookings" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -219,13 +240,22 @@ export class SchedulingReadFacade { (r) => deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', ) - .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({ - ...rest, - totalBookings: Number(rest.totalBookings) || 0, - totalContainers: Number(rest.totalContainers) || 0, - totalCargoes: Number(rest.totalCargoes) || 0, - route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null, - })); + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => { + const totalBookings = Number(rest.totalBookings) || 0; + const pendingUnloadBookings = Number(rest.pendingUnloadBookings) || 0; + const unloadedBookings = Math.max(totalBookings - pendingUnloadBookings, 0); + + return { + ...rest, + totalBookings, + totalContainers: Number(rest.totalContainers) || 0, + totalCargoes: Number(rest.totalCargoes) || 0, + unloadedBookings, + pendingUnloadBookings, + fullyUnloaded: totalBookings > 0 && pendingUnloadBookings === 0, + route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null, + }; + }); } /** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */ @@ -242,6 +272,7 @@ export class SchedulingReadFacade { b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", + inv.inspection_status AS "inspectionStatus", (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", CASE WHEN b.last_mile_delivery_address IS NOT NULL THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption" @@ -281,7 +312,15 @@ export class SchedulingReadFacade { ]; params.push( filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'], - ['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'], + [ + 'LOADED', + 'DISPATCHED', + 'IN_TRANSIT', + 'ARRIVED_AT_DJIBOUTI', + 'ARRIVED_AT_PORT', + 'ARRIVED_AT_DESTINATION', + 'UNLOADED_AT_DJIBOUTI_PORT', + ], ); if (filter.scheduleId) { @@ -290,7 +329,7 @@ export class SchedulingReadFacade { } if (filter.destination) { params.push(`%${filter.destination}%`); - where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`); + where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`); } if (filter.dateFrom) { params.push(filter.dateFrom); @@ -312,7 +351,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", - dy.name AS "destinationName", + dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", ts.scheduled_departure_date AS "departureTime", 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 8171983df..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 @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; @@ -6,9 +6,9 @@ import { Cargo } from '../cargoes/entities/cargoes.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; -import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; @@ -36,10 +36,20 @@ import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; +import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; +const normalizeWagonStatus = (status: string | null | undefined) => + (status ?? '') + .trim() + .replace(/[\s-]+/g, '_') + .toUpperCase(); + +const isLoadableWagonStatus = (status: string | null | undefined) => + LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); + export interface InventoryInquiryResult { id: string; inventoryId: string | null; @@ -182,6 +192,13 @@ export interface EligibleBookingRow { reference: string; customerId: string | null; customer: string | null; + customerTin: string | null; + customerPhone: string | null; + containerNumber: string | null; + containerQuantity: number | null; + containerPackagingType: string | null; + cargoDescription: string | null; + lastMileRequested: boolean; direction: string; origin: string | null; destination: string | null; @@ -190,12 +207,22 @@ export interface EligibleBookingRow { weight: string | null; paymentStatus: string; status: string; + hasFirstMile: boolean; + firstMileRequestId: string | null; + firstMileStatus: string | null; + firstMileVehicleId: string | null; + firstMileTruckPlateNumber: string | null; + firstMileTrailerPlateNumber: string | null; + firstMileDriverName: string | null; + firstMileDriverPhone: string | null; + firstMileDriverLicenseNumber: string | null; + firstMileTruckType: string | null; } export interface BulkReceiveResult { receivedCount: number; skippedCount: number; - results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; + results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; } export interface LoadPassedExportResult { @@ -274,6 +301,8 @@ export interface ImportUnloadedRow { @Injectable() export class WarehouseInventoryService { + private readonly logger = new Logger(WarehouseInventoryService.name); + constructor( private readonly dataSource: DataSource, private readonly inventoryRepository: WarehouseInventoryRepository, @@ -283,9 +312,10 @@ export class WarehouseInventoryService { private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, - private readonly pdfService: ContractPdfService, + private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, + private readonly notifications: NotificationsService, ) {} /** @@ -294,18 +324,53 @@ export class WarehouseInventoryService { * inspection / storage / loading steps — only the final release. */ async gateClearance(id: string, performedBy?: string): Promise { - const item = await this.findById(id); + const [item]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" + FROM freight.warehouse_inventory + WHERE id = $1 AND deleted_at IS NULL + LIMIT 1`, + [id], + ); + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + const blocking = await this.invoices.findBlockingInvoice(id); if (blocking) { throw new BadRequestException( 'Warehouse demurrage/storage fee must be paid before terminal release.', ); } + const now = new Date(); - await this.inventoryRepository.update(id, { - gateClearedAt: now, - releaseDate: item.releaseDate ?? now, - }); + const [gateColumn]: Array<{ exists: boolean }> = await this.dataSource.query( + `SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'warehouse_inventory' + AND column_name = 'gate_cleared_at' + ) AS "exists"`, + ); + if (gateColumn?.exists) { + await this.dataSource.query( + `UPDATE freight.warehouse_inventory + SET gate_cleared_at = $2, + release_date = COALESCE(release_date, $2), + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [id, now], + ); + } else { + await this.dataSource.query( + `UPDATE freight.warehouse_inventory + SET release_date = COALESCE(release_date, $2), + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [id, now], + ); + } + await this.activityLog.record({ activityType: 'INVENTORY_DISPATCHED', inventoryId: id, @@ -594,21 +659,69 @@ export class WarehouseInventoryService { b.reference AS "reference", b.company_id AS "customerId", company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", b.freight_type AS "freightType", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", b.cargo_total_weight_vgm AS "weight", b.payment_status AS "paymentStatus", - b.status AS "status" + b.status AS "status", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus", + fm.vehicle_id AS "firstMileVehicleId", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL @@ -644,11 +757,61 @@ export class WarehouseInventoryService { }; const [booking] = await manager.query( - `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", - oy.country AS "originCountry", dy.country AS "destinationCountry" + `SELECT b.reference AS "reference", + b.payment_status AS "paymentStatus", + b.cargo_total_weight_vgm AS "weight", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", + oy.country AS "originCountry", dy.country AS "destinationCountry", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); @@ -663,21 +826,45 @@ export class WarehouseInventoryService { skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); continue; } + if (dto.direction === 'EXPORT' && booking.hasFirstMile) { + if (!booking.firstMileRequestId) { + skip('First-mile request not created'); + continue; + } + if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { + skip('First-mile truck has not arrived'); + continue; + } + } const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); if (existing) { skip('Already received'); continue; } + const now = new Date(); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + 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, + notes: `Bulk received (${dto.direction})`, + truckEntrance, + }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - quantity: 1, + quantity: Number(booking.containerQuantity) || 1, weight: Number(booking.weight) || 0, status: 'RECEIVED', - arrivedAt: new Date(), - notes: `Bulk received (${dto.direction})`, + arrivedAt: now, + notes: receiveNote, }), ); @@ -686,14 +873,25 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `Bulk received ${dto.direction} booking`, + 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 ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }); + result.receivedCount += 1; - result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id }); + result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); } }); @@ -790,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'); @@ -892,6 +1095,7 @@ export class WarehouseInventoryService { /** Booking statuses that must never be unloaded into warehouse inventory. */ private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [ + 'LOADED', 'DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', @@ -1068,7 +1272,7 @@ export class WarehouseInventoryService { oy.country AS "originCountry", dy.country AS "destinationCountry", dy.code AS "destinationCode", - dy.name AS "destinationName" + dy.label AS "destinationName" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -1209,6 +1413,20 @@ export class WarehouseInventoryService { } const currentStatus = item.inventoryStatus ?? item.bookingStatus; + if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') { + seenInventory.add(item.inventoryId); + result.unloadedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Already unloaded at Djibouti Port', + }); + continue; + } if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) { skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`); continue; @@ -1263,16 +1481,22 @@ export class WarehouseInventoryService { }); if (result.unloadedCount > 0) { - const document = await this.interchangeDocuments.generateFromSchedule({ + let document = await this.interchangeDocuments.generateFromSchedule({ scheduleId, direction: 'EXPORT', handoverLocation: schedule.destinationName ?? 'Djibouti Port', handoverFrom: 'EDR', handoverTo: 'Djibouti Port Operator', portOperatorName: 'Doraleh Multipurpose Port', - generatedBy: performedBy, - remarks: 'Generated after export unloading at Djibouti Port', + generatedBy: performedBy ?? 'EDR Operations', + remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', }); + if (document.status !== 'ACKNOWLEDGED') { + document = await this.interchangeDocuments.acknowledge(document.id, { + acknowledgedBy: 'Djibouti Port Operator', + remarks: 'Auto acknowledged after Djibouti export unloading.', + }); + } result.interchangeDocument = { id: document.id, documentNo: document.documentNo, @@ -1379,22 +1603,41 @@ 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, bookingSource ?? {}) + : dto.truckEntrance; + this.assertTruckEntrance(truckEntrance); this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); this.assertCapacity('Yard', yard, weight, volume, containerCount); this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); + const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const receiveNote = this.buildReceiveNote({ + grnNumber, + notes: dto.notes?.trim() || 'Single booking received', + truckEntrance, + }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1404,12 +1647,12 @@ 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', arrivedAt: now, - notes: dto.notes?.trim() ?? null, + notes: receiveNote, }), ); @@ -1417,14 +1660,23 @@ export class WarehouseInventoryService { await this.activityLog.record( { - activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, - warehouseId: dto.warehouseId, - description: `Received ${weight}kg at warehouse location`, - performedBy: dto.performedBy, - }, - manager, - ); + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + performedBy: dto.performedBy, + }, + manager, + ); + + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance.customerPhone, + ownerName: truckEntrance.ownerName, + bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId, + grnNumber, + direction: bookingDirection, + warehouseId: dto.warehouseId, + }); return saved.id; }); @@ -1664,11 +1916,13 @@ export class WarehouseInventoryService { const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); const reference = dto.reference?.trim() || null; + const exitInspectionNote = this.buildExitInspectionNote(dto); await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, + notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'), }); await this.activityLog.record( { @@ -1688,25 +1942,21 @@ export class WarehouseInventoryService { } async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { - const item = await this.findById(id); - if (!item.releaseDate) { - throw new BadRequestException('A release order must be issued before downloading the exit paper'); - } - const [row] = await this.dataSource.query( `SELECT inv.id, - inv.release_order_reference AS "releaseOrderReference", inv.release_date AS "releaseDate", + inv.release_order_reference AS "releaseOrderReference", inv.quantity, inv.weight, inv.status, + inv.notes, b.id AS "bookingId", b.reference AS "bookingReference", b.status AS "bookingStatus", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", company.name AS "customerName", - container.container_number AS "containerNumber", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -1720,23 +1970,29 @@ export class WarehouseInventoryService { LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id - LEFT JOIN freight.containers container ON ( - (inv.container_id IS NOT NULL AND container.id = inv.container_id) - OR (inv.container_id IS NULL AND container.booking_id = b.id) - ) AND container.deleted_at IS NULL - LEFT JOIN freight.cargoes cargo ON ( - (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) - OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) - ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [id], ); + if (!row) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + if (!row.releaseDate) { + throw new BadRequestException('A release order must be issued before downloading the exit paper'); + } - const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`; - const bookingReference = row?.bookingReference || item.bookingId || 'N/A'; - const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date(); + const bookingReference = row?.bookingReference || 'N/A'; + const reference = + row?.releaseOrderReference || + (row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A'); + const issuedAt = new Date(row.releaseDate); const html = this.buildReleaseDocumentHtml({ reference, issuedAt, @@ -1747,17 +2003,19 @@ export class WarehouseInventoryService { tradeDirection: row?.tradeDirection ?? null, containerNumber: row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, - quantity: Number(row?.quantity ?? item.quantity ?? 0), - weight: Number(row?.weight ?? item.weight ?? 0), + quantity: Number(row?.quantity ?? 0), + weight: Number(row?.weight ?? 0), warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null, yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, - inventoryStatus: row?.status ?? item.status, + inventoryStatus: row?.status ?? null, + clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', + exitInspectionSummary: this.extractExitInspectionNote(row?.notes), }); return { filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.pdfService.htmlToPdfBuffer(html), + buffer: await this.releaseDocuments.htmlToPdfBuffer(html), }; } @@ -1842,7 +2100,7 @@ export class WarehouseInventoryService { // 4. wagon must be available, or already selected by an existing train schedule. const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId); - if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) { + if (!isLoadableWagonStatus(wagon.status) && !scheduled) { throw new BadRequestException( `Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`, ); @@ -2257,6 +2515,8 @@ export class WarehouseInventoryService { yard: string | null; zone: string | null; inventoryStatus: string | null; + clearanceStatus: string; + exitInspectionSummary?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -2273,71 +2533,86 @@ export class WarehouseInventoryService { minute: '2-digit', }); const rows = [ - ['Booking reference', data.bookingReference], - ['Customer', data.customerName], - ['Booking status', data.bookingStatus], - ['Freight type', data.freightType], - ['Trade direction', data.tradeDirection], - ['Container number', data.containerNumber], - ['Cargo / goods', data.cargoDescription], + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName], + ['Booking Status', data.bookingStatus], + ['Freight Type', data.freightType], + ['Trade Direction', data.tradeDirection], + ['Container Number', data.containerNumber], + ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Weight', `${data.weight.toLocaleString()} kg`], + ['Declared Weight', `${data.weight.toLocaleString()} kg`], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], - ['Inventory status', data.inventoryStatus], + ['Inventory Status', data.inventoryStatus], + ['Clearance Status', data.clearanceStatus], + ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), ]; return ` - Warehouse Release Exit Paper + Warehouse Gate Clearance / Release Order
-
EDR Warehouse Operations
-

Warehouse Release / Exit Paper

+
Ethio-Djibouti Railway S.C.
+

Warehouse Gate Clearance / Release Order

+
Official warehouse release and exit authorization
- Release reference + Document / Release No. ${esc(data.reference)} Issued: ${esc(issuedAt)}
+
- This document authorizes the listed booking/goods to leave the warehouse after release checks. + This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
+
Release Particulars
${rows.map(([label, value]) => ``).join('')}
${esc(label)}${esc(value)}
-
-
Warehouse officer name / signature / date
-
Customer or driver name / signature / date
+
Authorization Clause
+
+ The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, + cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
- @@ -2363,21 +2638,290 @@ 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; } + private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void { + if (!truckEntrance?.truckPlateNumber?.trim()) { + throw new BadRequestException('Truck plate number is required for entrance registration'); + } + if (!truckEntrance.driverName?.trim()) { + throw new BadRequestException('Driver name is required for entrance registration'); + } + if (!truckEntrance.driverPhone?.trim()) { + throw new BadRequestException('Driver phone is required for entrance registration'); + } + if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { + throw new BadRequestException('Entrance tare weight is required for entrance registration'); + } + } + + private mergeSystemTruckEntrance( + submitted: TruckEntranceDto, + booking: { + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + containerNumber?: string | null; + containerQuantity?: number | string | null; + containerPackagingType?: string | null; + cargoDescription?: string | null; + weight?: number | string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }, + ): TruckEntranceDto { + return { + ...submitted, + ownerName: booking.customer?.trim() || submitted.ownerName, + edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId, + tin: booking.customerTin?.trim() || submitted.tin, + customerPhone: booking.customerPhone?.trim() || submitted.customerPhone, + assignedEquipmentNumber: booking.containerNumber?.trim() || submitted.assignedEquipmentNumber, + itemDescription: booking.cargoDescription?.trim() || submitted.itemDescription, + packagingType: booking.containerPackagingType?.trim() || submitted.packagingType, + unitCount: + booking.containerQuantity !== undefined && booking.containerQuantity !== null + ? Number(booking.containerQuantity) + : submitted.unitCount, + grossWeightKg: + booking.weight !== undefined && booking.weight !== null + ? Number(booking.weight) + : submitted.grossWeightKg, + truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, + trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, + driverName: booking.firstMileDriverName?.trim() || submitted.driverName, + driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, + driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, + truckType: booking.firstMileTruckType?.trim() || submitted.truckType, + }; + } + + private async getBookingTruckEntranceSource( + manager: EntityManager, + bookingId: string, + ): Promise<{ + reference?: string | null; + customer?: string | null; + customerTin?: string | null; + customerPhone?: string | null; + containerNumber?: string | null; + containerQuantity?: number | string | null; + containerPackagingType?: string | null; + cargoDescription?: string | null; + weight?: number | string | null; + firstMileTruckPlateNumber?: string | null; + firstMileTrailerPlateNumber?: string | null; + firstMileDriverName?: string | null; + firstMileDriverPhone?: string | null; + firstMileDriverLicenseNumber?: string | null; + firstMileTruckType?: string | null; + }> { + const [booking] = await manager.query( + `SELECT b.reference AS "reference", + company.name AS "customer", + company.tin AS "customerTin", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + b.cargo_total_weight_vgm AS "weight", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id + WHERE b.id = $1 AND b.deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + return booking ?? {}; + } + + private async notifyOwnerInventoryReceived(params: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }): Promise { + const phone = params.phone?.trim(); + if (!phone) return; + + const ownerName = params.ownerName?.trim() || 'Customer'; + const bookingReference = params.bookingReference?.trim(); + const message = + `Dear ${ownerName}, your cargo has been received by EDR warehouse. ` + + (bookingReference ? `Booking: ${bookingReference}. ` : '') + + `GRN: ${params.grnNumber}. ` + + (params.direction ? `Direction: ${params.direction}. ` : '') + + `Thank you.`; + + try { + await this.notifications.directSend('sms', phone, message); + } catch (error) { + // Receiving inventory must not be rolled back because an SMS provider is unavailable. + this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`); + } + } + + private generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + } + + private buildExitInspectionNote(dto: ReleaseOrderDto): string | null { + const hasExitInspection = + Boolean(dto.truckPlateNumber?.trim()) || + Boolean(dto.trailerPlateNumber?.trim()) || + Boolean(dto.driverName?.trim()) || + Boolean(dto.driverLicense?.trim()) || + Boolean(dto.driverPhone?.trim()) || + Boolean(dto.truckType?.trim()) || + Boolean(dto.containerNumber?.trim()) || + dto.tareWeight !== undefined || + dto.grossWeight !== undefined || + dto.netWeight !== undefined || + Boolean(dto.gateInTime) || + Boolean(dto.gateOutTime); + + if (!hasExitInspection) return null; + + if (!dto.truckPlateNumber?.trim()) { + throw new BadRequestException('Truck plate number is required for exit inspection'); + } + if (!dto.driverName?.trim()) { + throw new BadRequestException('Driver name is required for exit inspection'); + } + if (dto.tareWeight === undefined || dto.grossWeight === undefined) { + throw new BadRequestException('Tare weight and gross weight are required for exit inspection'); + } + + const tareWeight = Number(dto.tareWeight); + const grossWeight = Number(dto.grossWeight); + const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3)); + const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight); + + if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) { + throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.'); + } + + const rows = [ + '[Exit Inspection]', + dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null, + dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null, + `Truck Plate: ${dto.truckPlateNumber.trim()}`, + dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null, + `Driver: ${dto.driverName.trim()}`, + dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null, + dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null, + dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, + dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, + dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, + `Tare Weight: ${tareWeight} kg`, + `Gross Weight: ${grossWeight} kg`, + `Net Weight: ${computedNetWeight} kg`, + dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, + ]; + + return rows.filter(Boolean).join('\n'); + } + + private extractExitInspectionNote(notes?: string | null): string | null { + if (!notes) return null; + const marker = '[Exit Inspection]'; + const index = notes.lastIndexOf(marker); + if (index < 0) return null; + return notes.slice(index + marker.length).trim() || null; + } + + private buildReceiveNote(input: { + grnNumber: string; + direction?: string | null; + notes?: string | null; + 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?.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'); + } + private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise { const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index da818b5a3..a66cf91d0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -54,6 +55,26 @@ export class WarehouseInvoiceController { return this.invoiceService.findById(id); } + @Get('warehouse-fee-invoices/:id/document') + @ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' }) + async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.invoiceService.document(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + @Get('warehouse-fee-invoices/:id/receipt') + @ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' }) + async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.invoiceService.receipt(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Patch('warehouse-fee-invoices/:id/cancel') @ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) cancel(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 29493bc9a..1fe184662 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,6 +1,7 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { NotificationsService } from '../notifications/notifications.service'; import { WarehouseFeeInvoice, WarehouseInvoiceStatus, @@ -10,6 +11,7 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; interface GenerateOptions { confirmZero?: boolean; @@ -21,19 +23,41 @@ export interface PayInvoiceDto { amount: number; method?: string; reference?: string; + driverName?: string; + driverPhone?: string; } /** Invoices that still owe money and therefore block terminal release. */ const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; +export interface InvoiceDocumentDetails { + bookingReference: string | null; + customerName: string | null; + inventoryReference: string | null; + inventoryInfo: string | null; + inventoryStatus: string | null; + containerNumber: string | null; + cargoDescription: string | null; + clearanceStatus: string; + warehouseName: string | null; + yardName: string | null; + zoneName: string | null; +} + +export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial; + @Injectable() export class WarehouseInvoiceService { + private readonly logger = new Logger(WarehouseInvoiceService.name); + constructor( private readonly dataSource: DataSource, private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, + private readonly documents: WarehouseReleaseDocumentService, + private readonly notifications: NotificationsService, ) {} // ── Generation ─────────────────────────────────────────────────────────── @@ -132,7 +156,9 @@ export class WarehouseInvoiceService { await this.itemRepository.create({ invoiceId: invoice.id, ...it }); } - return this.findById(invoice.id); + const saved = await this.findById(invoice.id); + await this.notifyWarehouseFeeIssued(saved); + return saved; } /** WHF-YYYYMMDD-00001 — sequential per day. */ @@ -150,11 +176,35 @@ export class WarehouseInvoiceService { } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { + async findById(id: string): Promise { const invoice = await this.invoiceRepository.findById(id); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); - return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] }; + const details = await this.getInvoiceDocumentDetails(invoice); + return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] }; + } + + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + const details = await this.getInvoiceDocumentDetails(invoice); + const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details); + return { + filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, + buffer: await this.documents.htmlToPdfBuffer(html), + }; + } + + async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + if (Number(invoice.paidAmount) <= 0) { + throw new BadRequestException('A receipt is available only after payment is recorded.'); + } + const details = await this.getInvoiceDocumentDetails(invoice); + const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details); + return { + filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, + buffer: await this.documents.htmlToPdfBuffer(html), + }; } listForInventory(inventoryId: string): Promise { @@ -204,7 +254,9 @@ export class WarehouseInvoiceService { paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, payments, }); - return updated as WarehouseFeeInvoice; + const paidInvoice = updated as WarehouseFeeInvoice; + await this.notifyWarehouseFeePayment(paidInvoice, dto); + return paidInvoice; } // ── Release blocking ────────────────────────────────────────────────────── @@ -213,4 +265,326 @@ export class WarehouseInvoiceService { const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; } + + async assertClearanceAllowed(inventoryId: string): Promise { + const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); + const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)); + if (blocking) { + throw new BadRequestException( + `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, + ); + } + + if (invoices.some((inv) => inv.status === 'PAID')) return; + + const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); + const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + if (payableAmount > 0) { + throw new BadRequestException( + 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + ); + } + } + + private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise { + const [row] = await this.dataSource.query( + `SELECT b.reference AS "bookingReference", + company.name AS "customerName", + COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference", + inv.status AS "inventoryStatus", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + CONCAT_WS( + ' / ', + NULLIF(inv.status, ''), + NULLIF(COALESCE(container.container_number, booking_container.container_number), ''), + NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '') + ) AS "inventoryInfo", + wh.name AS "warehouseName", + yard.name AS "yardName", + zone.name AS "zoneName", + CASE + WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED' + WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE' + ELSE 'PENDING PAYMENT' + END AS "clearanceStatus" + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL + LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id + WHERE fee.id = $1 + LIMIT 1`, + [invoice.id, invoice.status], + ); + + return { + bookingReference: row?.bookingReference ?? null, + customerName: row?.customerName ?? null, + inventoryReference: row?.inventoryReference ?? null, + inventoryInfo: row?.inventoryInfo ?? null, + inventoryStatus: row?.inventoryStatus ?? null, + containerNumber: row?.containerNumber ?? null, + cargoDescription: row?.cargoDescription ?? null, + warehouseName: row?.warehouseName ?? null, + yardName: row?.yardName ?? null, + zoneName: row?.zoneName ?? null, + clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'), + }; + } + + private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{ + bookingReference: string | null; + customerName: string | null; + customerPhone: string | null; + driverName: string | null; + driverPhone: string | null; + containerNumber: string | null; + cargoDescription: string | null; + }> { + const [row] = await this.dataSource.query( + `SELECT b.reference AS "bookingReference", + company.name AS "customerName", + COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''), + last_vehicle.assigned_driver_name, + NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''), + first_vehicle.assigned_driver_name + ) AS "driverName", + COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription" + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL + LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN LATERAL ( + SELECT lm.vehicle_id + FROM freight.last_mile lm + WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL + ORDER BY lm.created_at DESC + LIMIT 1 + ) latest_last_mile ON true + LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id + LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id + LEFT JOIN LATERAL ( + SELECT fm.vehicle_id + FROM freight.first_mile fm + WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL + ORDER BY fm.created_at DESC + LIMIT 1 + ) latest_first_mile ON true + LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id + LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id + WHERE fee.id = $1 + LIMIT 1`, + [invoice.id], + ); + + return { + bookingReference: row?.bookingReference ?? null, + customerName: row?.customerName ?? null, + customerPhone: row?.customerPhone ?? null, + driverName: row?.driverName ?? null, + driverPhone: row?.driverPhone ?? null, + containerNumber: row?.containerNumber ?? null, + cargoDescription: row?.cargoDescription ?? null, + }; + } + + private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise { + const phone = recipient?.trim(); + if (!phone) return; + try { + await this.notifications.directSend('sms', phone, message); + } catch (error) { + this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`); + } + } + + private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice); + const customerName = contacts.customerName?.trim() || 'Customer'; + const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + const cargo = contacts.containerNumber || contacts.cargoDescription; + const cargoText = cargo ? ` Cargo: ${cargo}.` : ''; + const message = + `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` + + `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` + + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`; + + await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); + } + + private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise { + const contacts = await this.getInvoiceNotificationContacts(invoice); + const customerName = contacts.customerName?.trim() || 'Customer'; + const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + const statusText = + invoice.status === 'PAID' + ? 'fully paid and ready for pickup release' + : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; + const customerMessage = + `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + + `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`; + + await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`); + + if (invoice.status !== 'PAID') return; + + const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone; + const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; + const cargo = contacts.containerNumber || contacts.cargoDescription; + const driverMessage = + `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + + (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + + (cargo ? ` Cargo: ${cargo}.` : '') + + ' Proceed with pickup after gate verification.'; + + await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); + } + + private buildInvoiceDocumentHtml( + invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, + kind: 'INVOICE' | 'RECEIPT', + details: InvoiceDocumentDetails, + ): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const money = (amount: unknown, currency = invoice.currency) => + `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; + const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); + const items = invoice.items as Array<{ + id?: string; + description?: string; + feeType?: string; + quantity?: number; + unitRate?: number; + amount?: number; + currency?: string; + chargeableDays?: number | null; + }>; + const lastPayment = [...(invoice.payments ?? [])].pop(); + const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR'; + + return ` + + + + Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

+
+
+ Document no. + ${esc(invoice.invoiceNumber)} + Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} +
+
+
${esc(sealText)}
+
+
Status${esc(invoice.status.replace(/_/g, ' '))}
+
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
+
Booking reference${esc(details.bookingReference)}
+
Customer${esc(details.customerName)}
+
Inventory reference${esc(details.inventoryReference)}
+
Inventory info${esc(details.inventoryInfo)}
+
Clearance${esc(details.clearanceStatus)}
+
Warehouse${esc(details.warehouseName)}
+
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
+
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
+
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
+
+ + + + + + + + + + + + ${items + .map( + (item) => ` + + + + + + `, + ) + .join('')} + +
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
+
+
Subtotal${esc(money(invoice.subtotalAmount))}
+
Tax${esc(money(invoice.taxAmount))}
+
Total${esc(money(invoice.totalAmount))}
+
Paid${esc(money(invoice.paidAmount))}
+
Balance${esc(money(invoice.balanceAmount))}
+
+ +
+ +`; + } + + private safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts new file mode 100644 index 000000000..a77a46c29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -0,0 +1,302 @@ +import { existsSync } from 'fs'; + +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; + +const MIN_VALID_PDF_BYTES = 2_000; + +const RELEASE_DOCUMENT_PRINT_STYLES = ` +`; + +@Injectable() +export class WarehouseReleaseDocumentService { + private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + + async htmlToPdfBuffer(html: string): Promise { + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import('puppeteer'); + const launchOptions: import('puppeteer').LaunchOptions = { + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 }); + await page.emulateMediaType('print'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`); + } + this.logger.log( + `Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error( + `Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`, + ); + const fallback = this.htmlToBasicPdfBuffer(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + 'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes('warehouse-release-document-print-fix')) return html; + if (html.includes('')) { + return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); + } + return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ]; + return candidates.find((path) => existsSync(path)); + } + + private isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-'; + } + + private htmlToBasicPdfBuffer(html: string): Buffer { + const doc = this.extractReleaseDocument(html); + const body: string[] = [ + this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), + this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), + this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'), + this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), + this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), + this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), + this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), + this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8), + this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2), + ...this.wrapLines(doc.notice, 68) + .slice(0, 4) + .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), + this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), + ]; + + let y = 586; + const rowHeight = 20; + for (const [label, value] of doc.rows.slice(0, 14)) { + body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6)); + body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6)); + body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16')); + body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16')); + y -= rowHeight; + } + + body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18')); + body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7)); + body.push( + ...this.wrapLines(doc.clause, 92) + .slice(0, 4) + .map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')), + ); + + const stream = [ + ...body, + this.lineOp(36, 60, 218, 60, '0 0 0', 1), + this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'), + this.circularSealOps(286, 62, 38), + this.lineOp(341, 60, 559, 60, '0 0 0', 1), + this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'), + ].join('\n'); + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>', + `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) { + pdf += '% fallback padding\n'; + } + const xrefOffset = Buffer.byteLength(pdf, 'latin1'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, 'latin1'); + } + + private extractReleaseDocument(html: string): { + reference: string; + issuedAt: string; + notice: string; + clause: string; + rows: Array<[string, string]>; + } { + const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim(); + const reference = textFromHtml(html.match(/([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO'); + const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-'); + const notice = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.', + ); + const clause = textFromHtml( + html.match(/
([\s\S]*?)<\/div>/i)?.[1] ?? + 'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.', + ); + const rows: Array<[string, string]> = []; + for (const match of html.matchAll(/([\s\S]*?)<\/th>([\s\S]*?)<\/td><\/tr>/gi)) { + rows.push([textFromHtml(match[1]), textFromHtml(match[2])]); + } + return { reference, issuedAt, notice, clause, rows }; + } + + private htmlToPlainText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n'); + } + + private wrapLines(text: string, width: number): string[] { + const wrapped: string[] = []; + for (const rawLine of text.split('\n')) { + const words = rawLine.split(' '); + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > width && line) { + wrapped.push(line); + line = word; + } else { + line = next; + } + } + if (line) wrapped.push(line); + } + return wrapped.length ? wrapped : ['Warehouse release document']; + } + + private escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + } + + private textOp( + text: string, + x: number, + y: number, + size: number, + font: 'F1' | 'F2' = 'F1', + color = '0 0 0', + ): string { + return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`; + } + + private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string { + return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + } + + private rectOp( + x: number, + y: number, + width: number, + height: number, + fillColor = '1 1 1', + strokeColor = '0.08 0.32 0.18', + lineWidth = 0.8, + ): string { + return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`; + } + + private circularSealOps(cx: number, cy: number, radius = 51): string { + return [ + 'q', + '0.08 0.32 0.18 RG', + '0.08 0.32 0.18 rg', + '2.2 w', + this.circlePath(cx, cy, radius), + 'S', + '0.8 w', + this.circlePath(cx, cy, radius - 10), + 'S', + this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'), + this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'), + 'Q', + ].join('\n'); + } + + private circlePath(cx: number, cy: number, r: number): string { + const k = 0.5522847498; + const c = r * k; + return [ + `${cx + r} ${cy} m`, + `${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`, + `${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`, + `${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`, + `${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`, + 'h', + ].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 98c6f0222..1671b63e8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,12 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; @@ -32,6 +32,7 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseInventoryService } from './warehouse-inventory.service'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseLoadingsController } from './warehouse-loadings.controller'; +import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository'; import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; @@ -70,7 +71,8 @@ import { WarehousesService } from './warehouses.service'; ]), FilesModule, InterchangeDocumentsModule, - LastMileModule, + forwardRef(() => LastMileModule), + NotificationsModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => @@ -111,8 +113,8 @@ import { WarehousesService } from './warehouses.service'; WarehouseFeeService, WarehouseInvoiceService, WarehouseSchedulingAdapterService, + WarehouseReleaseDocumentService, SchedulingReadFacade, - ContractPdfService, ], exports: [ WarehousesService, @@ -122,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-export-djibouti-interchange-demo.ts b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts index a91c0293b..c6ec67041 100644 --- a/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts @@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; -const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01'; -const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003']; +const DEMO_TRAINS = [ + { + trainNumber: 'ICD-DEMO-EXP-DJ-01', + bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'], + arrivalOffsetHours: 1, + }, + { + trainNumber: 'ICD-DEMO-EXP-DJ-02', + bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'], + arrivalOffsetHours: 2, + }, +]; async function main() { const app = await NestFactory.createApplicationContext(AppModule, { @@ -44,13 +54,6 @@ async function main() { const scheduleRepo = dataSource.getRepository(TrainSchedule); const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking); - const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } }); - if (existingSchedule) { - console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`); - console.log(`Schedule ID: ${existingSchedule.id}`); - return; - } - const originYard = (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); @@ -83,10 +86,6 @@ async function main() { throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`); } - const now = Date.now(); - const departure = new Date(now - 6 * 60 * 60 * 1000); - const arrival = new Date(now - 60 * 60 * 1000); - const locomotive = (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ?? (await locomotiveRepo.save( @@ -97,83 +96,106 @@ async function main() { }), )); - const trainSet = await trainSetRepo.save( - trainSetRepo.create({ - locomotiveId: locomotive.id, - totalWeightTons: 700, - totalLengthMeters: 360, - wagonCount: 12, - status: 'COMPLETED', - }), - ); + const now = Date.now(); + const seededSchedules: TrainSchedule[] = []; - const schedule = await scheduleRepo.save( - scheduleRepo.create({ - trainSetId: trainSet.id, - originStationId: originYard!.id, - destinationStationId: destinationYard!.id, - scheduledDepartureDate: departure, - scheduledArrivalDate: arrival, - actualArrivalAt: arrival, - status: 'ARRIVED' as TrainSchedule['status'], - trainNumber: TRAIN_NUMBER, - }), - ); + for (const [trainIndex, demo] of DEMO_TRAINS.entries()) { + const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } }); + if (existingSchedule) { + console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`); + console.log(`Schedule ID: ${existingSchedule.id}`); + seededSchedules.push(existingSchedule); + continue; + } - for (const [index, reference] of BOOKING_REFS.entries()) { - const weight = 5200 + index * 800; - const booking = await bookingRepo.save( - bookingRepo.create({ - reference, - originYardId: originYard!.id, - destinationYardId: destinationYard!.id, - serviceTypeId: serviceType!.id, - status: 'IN_TRANSIT', - paymentStatus: 'PAID', - scheduledDate: new Date(), - contractType: 'SPOT', - equipmentReturn: 'TERMINAL', - paymentCurrency: 'ETB', - totalAmount: 0, - isGovernment: false, - tradeDirection: 'EXPORT', - freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK', - cargoTypeId: cargoType?.id ?? null, - cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`, - cargoTotalWeightVgm: weight, + const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000); + const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 700 + trainIndex * 80, + totalLengthMeters: 360 + trainIndex * 20, + wagonCount: 12 + trainIndex, + status: 'COMPLETED', }), ); - await inventoryRepo.save( - inventoryRepo.create({ - warehouseId: warehouse!.id, - yardId: warehouseYard!.id, - zoneId: warehouseZone!.id, - bookingId: booking.id, - quantity: 1, - weight, - status: 'DISPATCHED', - inspectionStatus: 'PASSED', - arrivedAt: new Date(now - 4 * 60 * 60 * 1000), - inspectedAt: new Date(now - 3 * 60 * 60 * 1000), - readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000), - loadedAt: new Date(now - 90 * 60 * 1000), - dispatchedAt: new Date(now - 70 * 60 * 1000), - notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation', + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard!.id, + destinationStationId: destinationYard!.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: 'EXPORT', }), ); - await scheduleBookingRepo.save( - scheduleBookingRepo.create({ - trainScheduleId: schedule.id, - bookingId: booking.id, - }), - ); + for (const [bookingIndex, reference] of demo.bookingRefs.entries()) { + const weight = 5200 + trainIndex * 600 + bookingIndex * 800; + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'EXPORT', + freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType + ? null + : `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`, + cargoTotalWeightVgm: weight, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + warehouseId: warehouse!.id, + yardId: warehouseYard!.id, + zoneId: warehouseZone!.id, + bookingId: booking.id, + quantity: 1, + weight, + status: 'DISPATCHED', + inspectionStatus: 'PASSED', + arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000), + inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000), + readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000), + loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000), + dispatchedAt: departure, + notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation', + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + seededSchedules.push(schedule); } console.log('Export Djibouti interchange demo seeded.'); - console.log(`Train number: ${TRAIN_NUMBER}`); - console.log(`Schedule ID: ${schedule.id}`); + for (const schedule of seededSchedules) { + console.log(`Train number: ${schedule.trainNumber}`); + console.log(`Schedule ID: ${schedule.id}`); + } console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.'); } finally { await app.close(); 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/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts new file mode 100644 index 000000000..732de6262 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -0,0 +1,126 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { AppDataSource } from '../data-source'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; + +const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01'; + +function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} + +async function main() { + const dataSource = await AppDataSource.initialize(); + + try { + await dataSource.transaction(async (manager) => { + const yardRepo = manager.getRepository(Yard); + const locomotiveRepo = manager.getRepository(Locomotive); + const trainSetRepo = manager.getRepository(TrainSet); + const scheduleRepo = manager.getRepository(TrainSchedule); + + const negad = + (await yardRepo.findOne({ where: { code: 'NEGAD' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'NEGAD', + label: 'Negad', + country: 'Djibouti', + isActive: true, + displayOrder: 5, + }), + )); + + const indode = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'INDODE', + label: 'Indode Dry Port', + country: 'Ethiopia', + isActive: true, + displayOrder: 6, + }), + )); + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'NEGAD-INDODE-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'NEGAD-INDODE-LOCO', + name: 'Negad to Indode Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId: indode.id, + }), + )); + + const now = new Date(); + const departure = addHours(now, -12); + const arrival = now; + + let schedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } }); + if (!schedule) { + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 960, + totalLengthMeters: 420, + wagonCount: 18, + status: 'COMPLETED', + }), + ); + + schedule = scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: negad.id, + destinationStationId: indode.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: departure, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: TRAIN_NUMBER, + direction: 'IMPORT', + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }); + } else { + schedule.originStationId = negad.id; + schedule.destinationStationId = indode.id; + schedule.scheduledDepartureDate = departure; + schedule.scheduledArrivalDate = arrival; + schedule.actualDepartureAt = departure; + schedule.actualArrivalAt = arrival; + schedule.status = 'ARRIVED' as TrainSchedule['status']; + schedule.direction = 'IMPORT'; + schedule.bookingWindowStatus = 'CLOSED'; + + if (schedule.trainSetId) { + await trainSetRepo.update(schedule.trainSetId, { status: 'COMPLETED' }); + } + } + + const saved = await scheduleRepo.save(schedule); + console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`); + console.log(`Schedule ID: ${saved.id}`); + console.log(`Route: ${negad.code} -> ${indode.code}`); + }); + } finally { + await dataSource.destroy(); + } +} + +main().catch((err) => { + console.error('Negad to Indode arrived train seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts new file mode 100644 index 000000000..24118d882 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -0,0 +1,40 @@ +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 { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder'; +import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder'; +import { Batch7TestDataSeeder } from '../seed/batch7-test-data.seeder'; +import { Batch8TestDataSeeder } from '../seed/batch8-test-data.seeder'; +import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder'; +import { PricingDataSeeder } from '../seed/pricing-data.seeder'; +import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + await app.get(PricingDataSeeder).run(); + await app.get(IndodeFacilitySeeder).run(); + await app.get(Batch14TestDataSeeder).run(); + await app.get(Batch5TestDataSeeder).run(); + await app.get(Batch7TestDataSeeder).run(); + await app.get(Batch8TestDataSeeder).run(); + await app.get(WarehouseDemoSeeder).run(); + + console.log('Warehouse demo data seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Warehouse demo seed failed:', err); + 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..6b0465112 --- /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 = 'FLMDEMO001'; +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-api/src/seed/export-djibouti-interchange-demo.seeder.ts b/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts new file mode 100644 index 000000000..f4b258718 --- /dev/null +++ b/apps/edr-freight-api/src/seed/export-djibouti-interchange-demo.seeder.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +const SEED_FLAG = 'SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO'; + +const DEMO_TRAINS = [ + { + trainNumber: 'ICD-DEMO-EXP-DJ-01', + bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'], + arrivalOffsetHours: 1, + }, + { + trainNumber: 'ICD-DEMO-EXP-DJ-02', + bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'], + arrivalOffsetHours: 2, + }, +]; + +@Injectable() +export class ExportDjiboutiInterchangeDemoSeeder { + private readonly logger = new Logger(ExportDjiboutiInterchangeDemoSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping export Djibouti interchange demo seed because ${SEED_FLAG} is not enabled`); + return; + } + + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const cargoTypeRepo = this.dataSource.getRepository(CargoType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + const bookingRepo = this.dataSource.getRepository(Booking); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + const locomotiveRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const originYard = + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const destinationYard = + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const warehouseYard = warehouse + ? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }) + : null; + const warehouseZone = warehouseYard + ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) + : null; + + const missing = [ + !originYard ? 'Ethiopian origin yard' : '', + !destinationYard ? 'Djibouti destination yard' : '', + !serviceType ? 'service type' : '', + !warehouse ? 'INDODE_OPEN warehouse' : '', + !warehouseYard ? 'warehouse yard' : '', + !warehouseZone ? 'warehouse zone' : '', + ].filter(Boolean); + + if (missing.length) { + this.logger.warn(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`); + return; + } + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'ICD-DEMO-LOCO', + name: 'Interchange Demo Locomotive', + maxPullWeightTons: 4000, + }), + )); + + const now = Date.now(); + let seeded = 0; + let skipped = 0; + + for (const [trainIndex, demo] of DEMO_TRAINS.entries()) { + const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } }); + if (existingSchedule) { + skipped += 1; + continue; + } + + const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000); + const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 700 + trainIndex * 80, + totalLengthMeters: 360 + trainIndex * 20, + wagonCount: 12 + trainIndex, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard!.id, + destinationStationId: destinationYard!.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: 'EXPORT', + }), + ); + + for (const [bookingIndex, reference] of demo.bookingRefs.entries()) { + const weight = 5200 + trainIndex * 600 + bookingIndex * 800; + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'EXPORT', + freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType + ? null + : `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`, + cargoTotalWeightVgm: weight, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + warehouseId: warehouse!.id, + yardId: warehouseYard!.id, + zoneId: warehouseZone!.id, + bookingId: booking.id, + quantity: 1, + weight, + status: 'DISPATCHED', + inspectionStatus: 'PASSED', + arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000), + inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000), + readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000), + loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000), + dispatchedAt: departure, + notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation', + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + seeded += 1; + } + + this.logger.log(`Export Djibouti interchange demo seed complete: ${seeded} train(s) seeded, ${skipped} skipped`); + } catch (error) { + this.logger.error( + `ExportDjiboutiInterchangeDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 8f0e233b4..93caae0a4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port 5183", + "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", "lint": "eslint src", @@ -19,7 +20,7 @@ "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", - "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz", + "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6d2e723ec..ca6c1b405 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -83,6 +83,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; +import { HealthCheck } from "./features/health/HealthCheck"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -94,7 +95,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "UM", + label: "User Management", href: "/um", icon: , }, @@ -386,6 +387,7 @@ const App = () => { return ( } /> + } /> } /> } /> }> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 6167feb07..efdca3ae4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -134,15 +134,23 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa const pdfWindow = window.open('', '_blank'); try { const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem; - const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); - const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; - const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); - toast({ - title: 'Gate clearance recorded', - description: opened - ? 'The release PDF opened in a browser tab.' - : 'The browser blocked the preview tab, so the PDF was downloaded.', - }); + try { + const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); + const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; + const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The release PDF opened in a browser tab.' + : 'The browser blocked the preview tab, so the PDF was downloaded.', + }); + } catch (documentError) { + pdfWindow?.close(); + toast({ + title: 'Gate clearance recorded', + description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`, + }); + } onClose(); } catch (error) { pdfWindow?.close(); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx index c18f97acc..0dc04e450 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx @@ -69,6 +69,8 @@ export function InterchangeDocumentDetailPanel({ id }: { id: string }) { /> + + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx index ad079577b..b0b864e4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx @@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode }) } export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { + const bookingReference = item?.booking?.reference ?? '-'; + const inventorySummary = [ + item?.status?.replace(/_/g, ' '), + item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null, + item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null, + ] + .filter(Boolean) + .join(' / '); + return ( {!item ? ( @@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM - {item.booking?.reference ?? item.bookingId ?? item.id} + {bookingReference} - Inventory ID: {item.id} + {inventorySummary || 'Inventory information'} @@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM + - - - + + 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 3cd08461f..e2ff9fb3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -18,16 +18,20 @@ import { } from '@mantine/core'; import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { firstMileService } from '@/services/first-mile.service'; import type { + EligibleBooking, ImportTrain, ImportTrainItem, ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, + TruckEntrancePayload, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { InspectionReportModal } from './InspectionReportModal'; @@ -49,6 +53,448 @@ interface Location { zoneId: string; } +interface TruckEntranceFormState { + ownerName: string; + consigneeDetails: string; + edrDigitalBookingId: string; + tin: string; + customerPhone: string; + truckPlateNumber: string; + trailerPlateNumber: string; + assignedEquipmentNumber: string; + customsSealNumber: string; + declarationNumber: string; + incoterms: string; + hsCodes: string; + itemCode: string; + itemDescription: string; + packagingType: string; + unitCount: number | ''; + grossWeightKg: number | ''; + netWeightKg: number | ''; + volumeDimensions: string; + conditionAtReceipt: string; + damagedRejectedQuantity: number | ''; + warehouseCodeLocation: string; + driverName: string; + driverPhone: string; + driverLicenseNumber: string; + truckType: string; + entranceTareWeightKg: number | ''; + exitTareWeightKg: number | ''; + driverSignatoryName: string; + warehouseManagerName: string; +} + +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'; + +const emptyTruckEntrance = (): TruckEntranceFormState => ({ + ownerName: '', + consigneeDetails: '', + edrDigitalBookingId: '', + tin: '', + customerPhone: '', + truckPlateNumber: '', + trailerPlateNumber: '', + assignedEquipmentNumber: '', + customsSealNumber: '', + declarationNumber: '', + incoterms: '', + hsCodes: '', + itemCode: '', + itemDescription: '', + packagingType: '', + unitCount: '', + grossWeightKg: '', + netWeightKg: '', + volumeDimensions: '', + conditionAtReceipt: '', + damagedRejectedQuantity: '', + warehouseCodeLocation: '', + driverName: '', + driverPhone: '', + driverLicenseNumber: '', + truckType: '', + entranceTareWeightKg: '', + exitTareWeightKg: '', + driverSignatoryName: '', + warehouseManagerName: '', +}); + +const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ + truckPlateNumber: form.truckPlateNumber.trim(), + trailerPlateNumber: form.trailerPlateNumber.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, + netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg), + volumeDimensions: form.volumeDimensions.trim() || undefined, + conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, + damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity), + warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined, + driverName: form.driverName.trim(), + driverPhone: form.driverPhone.trim(), + driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, + truckType: form.truckType.trim() || undefined, + entranceTareWeightKg: Number(form.entranceTareWeightKg), + exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg), + driverSignatoryName: form.driverSignatoryName.trim() || undefined, + warehouseManagerName: form.warehouseManagerName.trim() || undefined, +}); + +const commonNonEmptyValue = (values: Array) => { + const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; + return unique.length === 1 ? unique[0] : ''; +}; + +const truckEntranceFromBookings = (bookings: EligibleBooking[]): { + form: TruckEntranceFormState; + lockedFields: LockedTruckEntranceFields; + packagingFreightType: PackagingFreightType; +} => { + const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer)); + const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin)); + const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone)); + const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer)); + const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber)); + const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo)); + const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType)); + const edrDigitalBookingId = + bookings.length === 1 + ? bookings[0]?.reference ?? bookings[0]?.id ?? '' + : commonNonEmptyValue(bookings.map((booking) => booking.reference)); + const firstMileBooking = bookings.length === 1 ? bookings[0] : null; + const unitCount = + bookings.length === 1 && bookings[0]?.containerQuantity != null + ? Number(bookings[0].containerQuantity) + : ''; + const grossWeightKg = + bookings.length === 1 && bookings[0]?.weight != null + ? Number(bookings[0].weight) + : ''; + const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))]; + const packagingFreightType = + freightTypes.length === 1 && freightTypes[0] === 'CONTAINER' + ? 'CONTAINER' + : freightTypes.length === 1 && freightTypes[0] === 'BULK' + ? 'BULK' + : 'MIXED'; + + return { + form: { + ...emptyTruckEntrance(), + ownerName, + consigneeDetails, + tin, + customerPhone, + edrDigitalBookingId, + assignedEquipmentNumber, + itemDescription, + packagingType, + unitCount, + grossWeightKg, + truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '', + trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '', + driverName: firstMileBooking?.firstMileDriverName ?? '', + driverPhone: firstMileBooking?.firstMileDriverPhone ?? '', + driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '', + truckType: firstMileBooking?.firstMileTruckType ?? '', + }, + 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, + }; +}; + +const BULK_PACKAGING_TYPE_OPTIONS = [ + { value: 'BAG', label: 'Bag' }, + { value: 'SACK', label: 'Sack' }, + { value: 'BALE', label: 'Bale' }, + { value: 'CARTON', label: 'Carton' }, + { value: 'CRATE', label: 'Crate' }, + { value: 'DRUM', label: 'Drum' }, + { value: 'BARREL', label: 'Barrel' }, + { value: 'PALLET', label: 'Pallet' }, + { value: 'LOOSE_BULK', label: 'Loose bulk' }, + { value: 'OTHER', label: 'Other' }, +]; + +const CONTAINER_PACKAGING_TYPE_OPTIONS = [ + { value: 'CONTAINER_20FT', label: '20 ft container' }, + { value: 'CONTAINER_40FT', label: '40 ft container' }, + { value: 'CONTAINER_45FT', label: '45 ft container' }, + { value: 'REEFER_CONTAINER', label: 'Reefer container' }, + { value: 'TANK_CONTAINER', label: 'Tank container' }, + { value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' }, + { value: 'OPEN_TOP_CONTAINER', label: 'Open top container' }, + { value: 'OTHER_CONTAINER', label: 'Other container' }, +]; + +const packagingOptionsFor = (freightType: PackagingFreightType) => + freightType === 'CONTAINER' + ? CONTAINER_PACKAGING_TYPE_OPTIONS + : freightType === 'BULK' + ? BULK_PACKAGING_TYPE_OPTIONS + : [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS]; + +function TruckEntranceFields({ + value, + onChange, + lockedFields, + packagingFreightType = 'MIXED', +}: { + value: TruckEntranceFormState; + onChange: (next: TruckEntranceFormState) => void; + lockedFields?: LockedTruckEntranceFields; + packagingFreightType?: PackagingFreightType; +}) { + const packagingOptions = packagingOptionsFor(packagingFreightType); + const quantityLabel = + packagingFreightType === 'CONTAINER' + ? 'Container quantity' + : packagingFreightType === 'BULK' + ? 'Unit count' + : 'Quantity'; + + return ( + + Customer and cargo ownership + + onChange({ ...value, ownerName: e.currentTarget.value })} + /> + onChange({ ...value, consigneeDetails: e.currentTarget.value })} + /> + + + onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} + /> + onChange({ ...value, tin: e.currentTarget.value })} + /> + + onChange({ ...value, customerPhone: e.currentTarget.value })} + /> + + Transport and equipment tracking + + onChange({ ...value, truckPlateNumber: e.currentTarget.value })} + /> + onChange({ ...value, trailerPlateNumber: e.currentTarget.value })} + /> + + + onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })} + /> + onChange({ ...value, customsSealNumber: e.currentTarget.value })} + /> + + + onChange({ ...value, driverName: e.currentTarget.value })} + /> + onChange({ ...value, driverPhone: e.currentTarget.value })} + /> + + + onChange({ ...value, driverLicenseNumber: e.currentTarget.value })} + /> + onChange({ ...value, truckType: e.currentTarget.value })} + /> + + + onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })} + /> + onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })} + /> + + + Customs and compliance + + onChange({ ...value, declarationNumber: e.currentTarget.value })} + /> + onChange({ ...value, incoterms: e.currentTarget.value })} + /> + + onChange({ ...value, hsCodes: e.currentTarget.value })} + /> + + Physical cargo specifications + + onChange({ ...value, itemCode: e.currentTarget.value })} + /> + onChange({ ...value, itemDescription: e.currentTarget.value })} + /> + + +