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 b26764d30..2fd9f6f5a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -19,8 +19,16 @@ "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:*", @@ -39,14 +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.6.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 3b69d2812..fd9f3af33 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/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 1ac78355a..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,30 +44,19 @@ 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 { + 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; - } - - if (!this.bookingRequestsFirstMile(booking)) { - return null; - } - - return this.create({ - bookingId: booking.id, - advancedPayment: 0, - }); + return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference(bookingReference: string): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, @@ -75,15 +64,39 @@ export class FirstMileService { }); 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)) { - return null; + 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({ @@ -174,10 +187,17 @@ export class FirstMileService { } private bookingRequestsFirstMile(booking: { + tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile); + // 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 { @@ -205,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/train-scheduling/dto/import-djibouti-operation.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts new file mode 100644 index 000000000..9bd9f3957 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [ + 'DELIVERY_ORDER', + 'PORT_INVOICE', + 'DJIBOUTI_T1', + 'ETHIOPIA_T1', + 'RAILWAY_BILL', +] as const; + +export type ImportDjiboutiDocumentType = (typeof IMPORT_DJIBOUTI_DOCUMENT_TYPES)[number]; + +export class UploadImportDjiboutiDocumentDto { + @ApiProperty({ enum: IMPORT_DJIBOUTI_DOCUMENT_TYPES }) + @IsIn(IMPORT_DJIBOUTI_DOCUMENT_TYPES) + documentType!: ImportDjiboutiDocumentType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} + +export class ImportDjiboutiActionDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts new file mode 100644 index 000000000..792792070 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export type ImportDjiboutiDocumentType = + | 'DELIVERY_ORDER' + | 'PORT_INVOICE' + | 'DJIBOUTI_T1' + | 'ETHIOPIA_T1' + | 'RAILWAY_BILL'; + +export interface ImportDjiboutiDocumentRecord { + fileId?: string | null; + fileUrl?: string | null; + reference?: string | null; + uploadedAt: string; + uploadedBy?: string | null; + notes?: string | null; +} + +@Entity({ schema: 'freight', name: 'import_djibouti_operations' }) +@Index(['trainScheduleId'], { unique: true }) +export class ImportDjiboutiOperation extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @OneToOne(() => TrainSchedule) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" }) + documents!: Partial>; + + @Column({ name: 'gatepass_granted_at', type: 'timestamptz', nullable: true }) + gatepassGrantedAt?: Date | null; + + @Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true }) + readyForLoadingAt?: Date | null; + + @Column({ name: 'loaded_on_train_at', type: 'timestamptz', nullable: true }) + loadedOnTrainAt?: Date | null; + + @Column({ name: 'departed_from_djibouti_at', type: 'timestamptz', nullable: true }) + departedFromDjiboutiAt?: Date | null; + + @Column({ name: 'load_list_generated_at', type: 'timestamptz', nullable: true }) + loadListGeneratedAt?: Date | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 08fc74172..1272c8fea 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -8,9 +8,11 @@ import { Patch, Post, Query, + Res, } from "@nestjs/common"; import { CurrentUser } from "@edr/api-common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; @@ -30,6 +32,10 @@ import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.d import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; +import { + ImportDjiboutiActionDto, + UploadImportDjiboutiDocumentDto, +} from "./dto/import-djibouti-operation.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; @@ -304,6 +310,101 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("schedules/:id/import-djibouti") + @TrainSchedulingView() + @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) + getImportDjiboutiOperation(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportDjiboutiOperation(id); + } + + @Post("schedules/:id/import-djibouti/documents") + @TrainSchedulingManage() + @ApiOperation({ summary: "Upload/check an import Djibouti-side document" }) + uploadImportDjiboutiDocument( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UploadImportDjiboutiDocumentDto, + ) { + return this.trainSchedulingService.uploadImportDjiboutiDocument(id, dto); + } + + @Post("schedules/:id/import-djibouti/gatepass-granted") + @TrainSchedulingManage() + @ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" }) + grantImportDjiboutiGatepass( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.grantImportDjiboutiGatepass(id, dto); + } + + @Post("schedules/:id/import-djibouti/ready-for-loading") + @TrainSchedulingManage() + @ApiOperation({ summary: "Mark import train ready for loading at Djibouti" }) + markImportReadyForLoading( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.markImportReadyForLoading(id, dto); + } + + @Post("schedules/:id/import-djibouti/loaded-on-train") + @TrainSchedulingManage() + @ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" }) + confirmImportLoadedOnTrain( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto); + } + + @Post("schedules/:id/import-djibouti/depart") + @TrainSchedulingManage() + @ApiOperation({ summary: "Depart loaded import train from Djibouti" }) + departImportFromDjibouti( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.departImportFromDjibouti(id, dto); + } + + @Post("schedules/:id/import-djibouti/load-list") + @TrainSchedulingManage() + @ApiOperation({ summary: "Generate import load list / marshalling document summary" }) + generateImportLoadList( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.generateImportLoadList(id, dto); + } + + @Get("schedules/:id/import-djibouti/load-list/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download printable import load list / marshalling PDF" }) + async importLoadListDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.importLoadListDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + + @Get("schedules/:id/export/load-list/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download printable export marshalling / load list PDF" }) + async exportLoadListDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.exportLoadListDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 2ed5eaa42..9b8efd9e0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -17,6 +17,7 @@ import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { Wagon } from '../wagons/entities/wagon.entity'; import { WarehousesModule } from '../warehouses/warehouses.module'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { TrainSchedulingController } from './train-scheduling.controller'; @@ -38,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; Container, TrainSchedulingGlobalRules, TrainCheckpointEvent, + ImportDjiboutiOperation, ]), forwardRef(() => BookingsModule), NotificationsModule, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index f59597aaa..b449669ec 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -151,6 +151,9 @@ describe('TrainSchedulingService', () => { autoUnloadArrivedBookings: jest.fn(), autoUnloadExportAtDjibouti: jest.fn(), } as never, + { + htmlToPdfBuffer: jest.fn(), + } as never, ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index c56dd6dd1..784a2c6a0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { + ImportDjiboutiOperation, + type ImportDjiboutiDocumentType, +} from './entities/import-djibouti-operation.entity'; +import { + IMPORT_DJIBOUTI_DOCUMENT_TYPES, + ImportDjiboutiActionDto, + UploadImportDjiboutiDocumentDto, +} from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { buildCappedWagonPlan, @@ -97,6 +106,7 @@ import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service'; +import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { autoFillPlacements, findMissingContainerNumberIssues, @@ -170,6 +180,7 @@ export class TrainSchedulingService { private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly warehouseInventoryService: WarehouseInventoryService, + private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly configService?: ConfigService, ) {} @@ -767,6 +778,7 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + await this.assertImportDjiboutiMayDepart(schedule); const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -806,9 +818,555 @@ export class TrainSchedulingService { .execute(); }); + if (this.isImportDjiboutiSchedule(schedule)) { + const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id); + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now, + }); + console.log( + `[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`, + ); + } + return this.getTrainScheduleById(scheduleId); } + async getImportDjiboutiOperation(scheduleId: string) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + return this.mapImportDjiboutiOperation(schedule, operation); + } + + async uploadImportDjiboutiDocument( + scheduleId: string, + dto: UploadImportDjiboutiDocumentDto, + ) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const documents = { + ...(operation.documents ?? {}), + [dto.documentType]: { + fileId: dto.fileId ?? null, + fileUrl: dto.fileUrl ?? null, + reference: dto.reference ?? null, + uploadedAt: new Date().toISOString(), + uploadedBy: dto.performedBy ?? null, + notes: dto.notes ?? null, + }, + }; + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + documents, + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const missing = this.missingImportDjiboutiDocuments(operation); + if (missing.length) { + throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`); + } + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + console.log( + `[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`, + ); + return this.getImportDjiboutiOperation(schedule.id); + } + + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + readyForLoadingAt: operation.readyForLoadingAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + readyForLoadingAt: operation.readyForLoadingAt ?? new Date(), + loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return this.getImportDjiboutiOperation(schedule.id); + } + + async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + this.assertImportDjiboutiGatepassGranted(operation); + if (!operation.loadedOnTrainAt) { + throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); + } + + if (schedule.status === TrainScheduleStatusEnum.Scheduled) { + await this.dispatchSchedule(schedule.id); + } else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) { + throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti'); + } + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(), + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + return this.getImportDjiboutiOperation(schedule.id); + } + + async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); + const generatedAt = operation.loadListGeneratedAt ?? new Date(); + + await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { + loadListGeneratedAt: generatedAt, + performedBy: dto.performedBy ?? operation.performedBy ?? null, + notes: dto.notes ?? operation.notes ?? null, + }); + + return { + generatedAt: generatedAt.toISOString(), + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? null, + route: schedule.route?.name ?? null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + totalBookings: schedule.scheduleBookings?.length ?? 0, + wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({ + sequenceNo: wagon.sequenceNo, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + allocations: (wagon.allocations ?? []).map((allocation) => ({ + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + loadType: allocation.loadType ?? null, + allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean), + })), + })), + operation: await this.getImportDjiboutiOperation(schedule.id), + }; + } + + async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const loadList = await this.generateImportLoadList(scheduleId, { + performedBy: 'DOCUMENT_GENERATION', + }); + const html = this.buildImportLoadListHtml(loadList); + const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + const reference = loadList.trainNumber ?? loadList.trainScheduleId; + return { + filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.isExportSchedule(schedule)) { + throw new BadRequestException('Export marshalling document applies only to EXPORT schedules'); + } + + const html = this.buildExportLoadListHtml(schedule); + const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + private buildExportLoadListHtml(schedule: TrainSchedule): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); + const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); + const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); + const rows = (schedule.trainSet?.wagons ?? []) + .flatMap((wagon) => + (wagon.allocations ?? []).map((allocation) => { + const booking = allocation.booking ?? bookingById.get(allocation.bookingId); + const company = booking?.company as Record | null | undefined; + const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; + const containerItems = allocation.containerItems ?? []; + const firstContainer = containerItems[0]; + const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', '); + const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', '); + const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); + return ` + ${esc(wagon.sequenceNo)} + ${esc(wagon.physicalWagon?.wagonNumber)} + ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} + ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} + ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} + ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} + ${esc(booking?.companyId)} + ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(containerNumbers || firstContainer?.containerNumber)} + ${esc(chassisNumbers)} + ${esc(sealNumbers)} + `; + }), + ) + .join(''); + const totalWeight = (schedule.trainSet?.wagons ?? []).reduce( + (sum, wagon) => + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), + 0, + ); + + return ` + + + + Export Marshalling Document + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Export Marshalling Document / Load List

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

Import Load List /
Marshalling Document

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

Wagon Marshalling Allocation

+ + + + + + + + + + + + + ${allocationRows || ''} + +
SeqWagonBookingLoadContainer numbersWeight T
No wagon allocations found for this train.
+ +
+ Gate and loading staff must verify this document against the granted gatepass, + railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure. +
+ +
+
Prepared by Djibouti operations
+
Train loading supervisor
+
EDR operations authorization
+
+ + +
+ +`; + } + + private safeDocumentName(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); + } + + private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise { + if (!this.isImportDjiboutiSchedule(schedule)) return; + const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({ + where: { trainScheduleId: schedule.id }, + }); + this.assertImportDjiboutiGatepassGranted(operation); + if (!operation?.loadedOnTrainAt) { + throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); + } + } + + private async getImportDjiboutiSchedule(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.isImportDjiboutiSchedule(schedule)) { + throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti'); + } + return schedule; + } + + private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return ( + direction === 'IMPORT' && + this.isDjiboutiPortDestination( + `${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`, + ) + ); + } + + private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise { + const repo = this.dataSource.getRepository(ImportDjiboutiOperation); + const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); + if (existing) return existing; + return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} })); + } + + private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] { + const documents = operation?.documents ?? {}; + return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]); + } + + private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void { + if (!operation?.gatepassGrantedAt) { + throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted'); + } + } + + private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) { + const missingDocuments = this.missingImportDjiboutiDocuments(operation); + return { + trainScheduleId: schedule.id, + trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, + status: { + documentsComplete: missingDocuments.length === 0, + missingDocuments, + gatepassGranted: Boolean(operation.gatepassGrantedAt), + readyForLoading: Boolean(operation.readyForLoadingAt), + loadedOnTrain: Boolean(operation.loadedOnTrainAt), + departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt), + loadListGenerated: Boolean(operation.loadListGeneratedAt), + }, + documents: operation.documents ?? {}, + gatepassGrantedAt: operation.gatepassGrantedAt ?? null, + readyForLoadingAt: operation.readyForLoadingAt ?? null, + loadedOnTrainAt: operation.loadedOnTrainAt ?? null, + departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null, + loadListGeneratedAt: operation.loadListGeneratedAt ?? null, + performedBy: operation.performedBy ?? null, + notes: operation.notes ?? null, + }; + } + /** * Assign a fixed train number on dispatch. The number is drawn from the pool * for the train's dominant cargo type (container vs bulk) and trade direction diff --git a/apps/edr-freight-api/src/modules/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 2feadaccd..5e31c8593 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @ApiPropertyOptional() @@ -22,6 +24,11 @@ export class TruckEntranceDto { @IsString() tin?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerPhone?: string; + @ApiProperty() @IsString() truckPlateNumber!: string; @@ -174,8 +181,11 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; - @ApiProperty({ type: TruckEntranceDto }) - truckEntrance!: TruckEntranceDto; + @ApiPropertyOptional({ type: TruckEntranceDto }) + @IsOptional() + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance?: TruckEntranceDto; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/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 e8a73190c..e4df4ce5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ValidateNested } from 'class-validator'; import { TruckEntranceDto } from './bulk-receive.dto'; export class ReceiveWarehouseInventoryDto { @@ -57,6 +59,8 @@ export class ReceiveWarehouseInventoryDto { notes?: string; @ApiProperty({ type: TruckEntranceDto }) + @ValidateNested() + @Type(() => TruckEntranceDto) truckEntrance!: TruckEntranceDto; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/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 7d73dc6ef..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 @@ -329,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); @@ -351,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 ab3adb64a..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,6 +6,7 @@ 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 { NotificationsService } from '../notifications/notifications.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -191,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; @@ -205,6 +213,10 @@ export interface EligibleBookingRow { firstMileVehicleId: string | null; firstMileTruckPlateNumber: string | null; firstMileTrailerPlateNumber: string | null; + firstMileDriverName: string | null; + firstMileDriverPhone: string | null; + firstMileDriverLicenseNumber: string | null; + firstMileTruckType: string | null; } export interface BulkReceiveResult { @@ -289,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, @@ -301,6 +315,7 @@ export class WarehouseInventoryService { private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, + private readonly notifications: NotificationsService, ) {} /** @@ -644,12 +659,20 @@ 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", @@ -659,7 +682,14 @@ export class WarehouseInventoryService { fm.status AS "firstMileStatus", fm.vehicle_id AS "firstMileVehicleId", v.plate_number AS "firstMileTruckPlateNumber", - v.trailer_plate_no AS "firstMileTrailerPlateNumber" + 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 @@ -667,6 +697,22 @@ export class WarehouseInventoryService { 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 @@ -675,6 +721,7 @@ export class WarehouseInventoryService { 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 @@ -695,7 +742,6 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; - this.assertTruckEntrance(dto.truckEntrance); await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -711,23 +757,61 @@ export class WarehouseInventoryService { }; const [booking] = await manager.query( - `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", + `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" + 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 first_mile.id, first_mile.status + 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], ); @@ -758,11 +842,17 @@ export class WarehouseInventoryService { 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: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -770,7 +860,7 @@ export class WarehouseInventoryService { yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - quantity: 1, + quantity: Number(booking.containerQuantity) || 1, weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: now, @@ -783,12 +873,23 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`, + description: truckEntrance?.truckPlateNumber + ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` + : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, performedBy: dto.performedBy, }, manager, ); + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance?.customerPhone ?? 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, grnNumber }); } @@ -887,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'); @@ -1166,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 @@ -1307,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; @@ -1483,18 +1603,29 @@ export class WarehouseInventoryService { } async receive(dto: ReceiveWarehouseInventoryDto): Promise { - this.assertTruckEntrance(dto.truckEntrance); - 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); @@ -1505,7 +1636,7 @@ export class WarehouseInventoryService { const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', - truckEntrance: dto.truckEntrance, + truckEntrance, }); const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ @@ -1516,7 +1647,7 @@ export class WarehouseInventoryService { cargoId: dto.cargoId ?? null, containerId: dto.containerId ?? null, goodsId: dto.goodsId ?? null, - quantity: Number(dto.quantity) || 0, + quantity, weight, volume: dto.volume ?? null, status: 'RECEIVED', @@ -1529,14 +1660,23 @@ export class WarehouseInventoryService { await this.activityLog.record( { - activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, - warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`, - 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; }); @@ -1776,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( { @@ -1807,6 +1949,7 @@ export class WarehouseInventoryService { inv.quantity, inv.weight, inv.status, + inv.notes, b.id AS "bookingId", b.reference AS "bookingReference", b.status AS "bookingStatus", @@ -1867,6 +2010,7 @@ export class WarehouseInventoryService { zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, inventoryStatus: row?.status ?? null, clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', + exitInspectionSummary: this.extractExitInspectionNote(row?.notes), }); return { @@ -2372,6 +2516,7 @@ export class WarehouseInventoryService { zone: string | null; inventoryStatus: string | null; clearanceStatus: string; + exitInspectionSummary?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -2402,6 +2547,7 @@ export class WarehouseInventoryService { ['Zone', data.zone], ['Inventory Status', data.inventoryStatus], ['Clearance Status', data.clearanceStatus], + ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), ]; return ` @@ -2492,16 +2638,6 @@ export class WarehouseInventoryService { return { warehouse, yard, zone }; } - private async assertBookingExists(manager: EntityManager, bookingId: string): Promise { - const rows = await manager.query( - 'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', - [bookingId], - ); - if (!rows || rows.length === 0) { - throw new NotFoundException(`Booking ${bookingId} not found`); - } - } - private appendNote(existing: string | null | undefined, note: string): string { const trimmed = existing?.trim(); return trimmed ? `${trimmed}\n${note}` : note; @@ -2522,51 +2658,265 @@ export class WarehouseInventoryService { } } + 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; + 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 Plate: ${truck.truckPlateNumber}`, - truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, - truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, - truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null, - truck.truckType ? `Truck Type: ${truck.truckType}` : null, - `Driver: ${truck.driverName}`, - `Driver Phone: ${truck.driverPhone}`, - truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, - `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`, - truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, - truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, - truck.incoterms ? `Incoterms: ${truck.incoterms}` : null, - truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, - truck.itemCode ? `Item Code: ${truck.itemCode}` : null, - truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null, - truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null, - truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, - truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, - truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, - truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, - truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, - truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, - truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null, - truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null, - truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null, + truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null, + truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, + truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, + truck?.tin ? `TIN: ${truck.tin}` : null, + truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null, + truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null, + truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, + truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, + truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null, + truck?.truckType ? `Truck Type: ${truck.truckType}` : null, + truck?.driverName ? `Driver: ${truck.driverName}` : null, + truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, + truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, + truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, + truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, + truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, + truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, + truck?.itemCode ? `Item Code: ${truck.itemCode}` : null, + truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null, + truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null, + truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, + truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, + truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, + truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, + truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, + truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null, + truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null, + truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null, input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null, ]; return rows.filter(Boolean).join('\n'); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 45c471db1..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, @@ -22,6 +23,8 @@ export interface PayInvoiceDto { amount: number; method?: string; reference?: string; + driverName?: string; + driverPhone?: string; } /** Invoices that still owe money and therefore block terminal release. */ @@ -46,12 +49,15 @@ export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial { + 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', 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 index 4398d943e..a77a46c29 100644 --- 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 @@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService { } private htmlToBasicPdfBuffer(html: string): Buffer { - const text = this.htmlToPlainText(html); - const lines = this.wrapLines(text, 86).slice(0, 52); - const body = lines - .map((line, index) => { - const y = 770 - index * 12; - const isTitle = index < 2 || /clearance|release order/i.test(line); - const size = index === 0 ? 13 : isTitle ? 11 : 9.6; - const font = isTitle ? 'F2' : 'F1'; - return this.textOp(line, 48, y, size, font); - }) - .join('\n'); + 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 = [ - this.lineOp(48, 752, 548, 752), - body, - this.circularSealOps(184, 154), - this.lineOp(48, 92, 278, 92, '0 0 0'), - this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'), - this.lineOp(326, 92, 548, 92, '0 0 0'), - this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'), - this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'), + ...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 = [ @@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService { 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, '') @@ -203,24 +253,36 @@ export class WarehouseReleaseDocumentService { 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'): string { - return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + 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 circularSealOps(cx: number, cy: number): string { + 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, 51), + this.circlePath(cx, cy, radius), 'S', '0.8 w', - this.circlePath(cx, cy, 41), + this.circlePath(cx, cy, radius - 10), 'S', - this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'), - this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'), - this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'), + 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'); } 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 116bfabf1..1671b63e8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; 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'; @@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service'; FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), + NotificationsModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => @@ -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/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 67fe4e9cb..93caae0a4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -20,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 4e268c99f..72ada9a6e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -77,6 +77,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[] => [ { @@ -88,7 +89,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "UM", + label: "User Management", href: "/um", icon: , }, @@ -374,6 +375,7 @@ const App = () => { return ( } /> + } /> } /> } /> }> diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx index ff37edb72..625e32a3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx @@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Loader2 } from 'lucide-react'; -import { API_BASE_URL } from "@/constants/apiConfig"; +import { API_BASE_URL } from '@/constants/apiConfig'; interface Cargo { id: string; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index a2437fe8f..e2ff9fb3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -58,6 +58,7 @@ interface TruckEntranceFormState { consigneeDetails: string; edrDigitalBookingId: string; tin: string; + customerPhone: string; truckPlateNumber: string; trailerPlateNumber: string; assignedEquipmentNumber: string; @@ -85,11 +86,27 @@ interface TruckEntranceFormState { 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: '', @@ -118,22 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ }); const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ - ownerName: form.ownerName.trim() || undefined, - consigneeDetails: form.consigneeDetails.trim() || undefined, - edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, - tin: form.tin.trim() || undefined, truckPlateNumber: form.truckPlateNumber.trim(), trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, - assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, incoterms: form.incoterms.trim() || undefined, hsCodes: form.hsCodes.trim() || undefined, itemCode: form.itemCode.trim() || undefined, - itemDescription: form.itemDescription.trim() || undefined, - packagingType: form.packagingType.trim() || undefined, - unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), - grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg), netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg), volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, @@ -149,13 +157,130 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl 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 @@ -163,11 +288,13 @@ function TruckEntranceFields({ onChange({ ...value, ownerName: e.currentTarget.value })} /> onChange({ ...value, consigneeDetails: e.currentTarget.value })} /> @@ -175,14 +302,22 @@ function TruckEntranceFields({ onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} /> onChange({ ...value, tin: e.currentTarget.value })} /> + onChange({ ...value, customerPhone: e.currentTarget.value })} + /> Transport and equipment tracking @@ -202,6 +337,7 @@ function TruckEntranceFields({ onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })} /> onChange({ ...value, itemDescription: e.currentTarget.value })} /> - onChange({ ...value, packagingType: e.currentTarget.value })} + onChange={(v) => onChange({ ...value, packagingType: v ?? '' })} /> onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} /> @@ -302,6 +444,7 @@ function TruckEntranceFields({ label="Gross weight (kg)" min={0} value={value.grossWeightKg} + readOnly={lockedFields?.grossWeightKg} onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })} /> ([]); const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); + const [lockedTruckFields, setLockedTruckFields] = useState({}); + const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -539,6 +684,14 @@ function EligibleTab({ const selectableRows = statusFilteredRows.filter(canReceiveBooking); const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length; const someSelected = selected.size > 0 && !allSelected; + const pendingReceiveRows = useMemo( + () => + pendingReceiveIds + .map((id) => rows.find((item) => item.id === id)) + .filter(Boolean) as EligibleBooking[], + [pendingReceiveIds, rows], + ); + const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile); const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); @@ -549,6 +702,29 @@ function EligibleTab({ return next; }); + const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => { + try { + const r = await bulkReceive.mutateAsync({ + direction, + ...location, + bookingIds, + ...(truckEntrance ? { truckEntrance } : {}), + }); + toast({ + title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), + }); + setSelected(new Set()); + setTruckOpen(false); + setPendingReceiveIds([]); + setLockedTruckFields({}); + setPackagingFreightType('MIXED'); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); + } + }; + const openTruckReceive = (bookingIds: string[]) => { if (!locationReady) { toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' }); @@ -564,13 +740,18 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); return; } - const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; + const selectedRows = filteredIds + .map((id) => rows.find((item) => item.id === id)) + .filter(Boolean) as EligibleBooking[]; + if (direction === 'IMPORT') { + void receiveBookings(filteredIds); + return; + } + const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); - setTruckForm({ - ...emptyTruckEntrance(), - truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', - trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '', - }); + setTruckForm(form); + setLockedTruckFields(lockedFields); + setPackagingFreightType(nextPackagingFreightType); setTruckOpen(true); }; @@ -579,24 +760,7 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); return; } - try { - const r = await bulkReceive.mutateAsync({ - direction, - ...location, - bookingIds: pendingReceiveIds, - truckEntrance: toTruckEntrancePayload(truckForm), - }); - toast({ - title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), - }); - setSelected(new Set()); - setTruckOpen(false); - setPendingReceiveIds([]); - onChanged?.(); - } catch (error) { - toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); - } + await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); }; const loadPassedExport = async () => { @@ -800,18 +964,33 @@ function EligibleTab({ )} - setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg"> + setTruckOpen(false)} + title="Export Truck Arrival / First Mile Receive Form" + centered + size="lg" + > - - Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. - - + } color={pendingHasFirstMile ? 'green' : 'blue'} variant="light"> + + {pendingHasFirstMile + ? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.' + : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + + + @@ -820,6 +999,161 @@ function EligibleTab({ ); } +/** Export received items awaiting inspection before loading. */ +function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { + const { toast } = useToast(); + const { data: rows = [], isLoading } = useQuery( + api.warehouses.receivedExport.queryOptions({ enabled }), + ); + const inspectMutation = useMutation( + api.warehouses.bulkMarkInspected.mutationOptions(), + ); + const [selected, setSelected] = useState>(new Set()); + const [inspectId, setInspectId] = useState(null); + + const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED'); + const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length; + const someSelected = selected.size > 0 && !allSelected; + const toggleAll = () => + setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id))); + const toggleOne = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const markInspected = async () => { + if (selected.size === 0) { + toast({ variant: 'destructive', title: 'Select at least one item' }); + return; + } + try { + const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); + toast({ + title: `${r.inspectedCount} marked inspected`, + description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + }); + setSelected(new Set()); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + + Selected: {selected.size} / {pendingRows.length} received + + + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + + No received export items awaiting inspection. + + ) : ( + + + + + + + + Booking Ref + Booking ID + Customer ID + Customer Name + Container # + Cargo Type + Weight + Route + Inspection + Status + Actions + + + + {rows.map((r: ReadyToLoadRow) => { + const selectable = r.inspectionStatus !== 'PASSED'; + return ( + + + toggleOne(r.id)} + /> + + + {r.bookingReference ?? '—'} + + + {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} + + + {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} + + {r.customerName ?? '—'} + {r.containerNumber ?? '—'} + {r.cargoType ?? '—'} + {formatNumber(Number(r.weight))} + + {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} + + + + {r.inspectionStatus ?? 'PENDING'} + + + + + {r.status} + + + + + + + ); + })} + +
+
+ )} + + setInspectId(null)} + /> +
+ ); +} + /** Export items that passed inspection and are queued to be loaded onto a train. */ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) { const { toast } = useToast(); @@ -1620,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal Receive Queue + Received Ready To Load Loaded Dispatch Queue @@ -1628,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal + + + @@ -1704,10 +2042,6 @@ function SingleBookingReceiveModal({ toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' }); return; } - if (form.quantity === '' || form.weight === '') { - toast({ variant: 'destructive', title: 'Quantity and weight are required' }); - return; - } if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); return; @@ -1717,8 +2051,8 @@ function SingleBookingReceiveModal({ warehouseId: form.warehouseId, yardId: form.yardId, zoneId: form.zoneId, - quantity: Number(form.quantity), - weight: Number(form.weight), + quantity: 0, + weight: 0, volume: form.volume === '' ? undefined : Number(form.volume), notes: form.notes.trim() || undefined, truckEntrance: toTruckEntrancePayload(truckForm), @@ -1744,21 +2078,13 @@ function SingleBookingReceiveModal({ setForm((f) => ({ ...f, ...next }))} /> + } color="blue" variant="light"> + + Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated. + + + - setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))} - /> - setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))} - /> ({ + value: powerPlate, + label: `${index + 1}. ${powerPlate} / ${trailerPlate}`, + trailerPlate, +})); + +const toIsoDateTime = (value: string) => { + if (!value) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +}; + export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) { const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const [reference, setReference] = useState(''); + const [truckPlateNumber, setTruckPlateNumber] = useState(''); + const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); + const [driverName, setDriverName] = useState(''); + const [driverLicense, setDriverLicense] = useState(''); + const [driverPhone, setDriverPhone] = useState(''); + const [truckType, setTruckType] = useState(''); + const [containerNumber, setContainerNumber] = useState(''); + const [gateInTime, setGateInTime] = useState(''); + const [tareWeight, setTareWeight] = useState(''); + const [grossWeight, setGrossWeight] = useState(''); + const [netWeight, setNetWeight] = useState(''); + const [gateOutTime, setGateOutTime] = useState(''); const [downloading, setDownloading] = useState(false); useEffect(() => { - if (opened) setReference(item?.releaseOrderReference ?? ''); + if (opened) { + setReference(item?.releaseOrderReference ?? ''); + setTruckPlateNumber(''); + setTrailerPlateNumber(''); + setDriverName(''); + setDriverLicense(''); + setDriverPhone(''); + setTruckType(''); + setContainerNumber(''); + setGateInTime(''); + setTareWeight(''); + setGrossWeight(''); + setNetWeight(item?.weight != null ? Number(item.weight) : ''); + setGateOutTime(''); + } }, [opened, item]); + const computedNetWeight = + tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; + const weightMismatch = + computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001; + const handleSubmit = async () => { if (!item) return; + if (!truckPlateNumber.trim() || !driverName.trim()) { + toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); + return; + } + if (tareWeight === '' || grossWeight === '') { + toast({ variant: 'destructive', title: 'Tare and gross weight are required' }); + return; + } + if (weightMismatch) { + toast({ + variant: 'destructive', + title: 'Weight mismatch', + description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.', + }); + return; + } const pdfWindow = window.open('', '_blank'); try { const released = await releaseMutation.mutateAsync({ id: item.id, - payload: { reference: reference.trim() || undefined }, + payload: { + reference: reference.trim() || undefined, + bookingId: item.bookingId ?? undefined, + customerId: undefined, + truckPlateNumber: truckPlateNumber.trim(), + trailerPlateNumber: trailerPlateNumber.trim() || undefined, + driverName: driverName.trim(), + driverLicense: driverLicense.trim() || undefined, + driverPhone: driverPhone.trim() || undefined, + truckType: truckType.trim() || undefined, + containerNumber: containerNumber.trim() || undefined, + gateInTime: toIsoDateTime(gateInTime), + tareWeight: Number(tareWeight), + grossWeight: Number(grossWeight), + netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight), + gateOutTime: toIsoDateTime(gateOutTime), + }, }); setDownloading(true); const response = await warehouseService.downloadReleaseDocument(item.id); @@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr }; return ( - + } color="orange" variant="light"> - Creates the warehouse release document with booking, customer, cargo and location details. The - printed paper authorizes the goods to leave the warehouse gate. + Save the exit inspection before generating the exit paper. Gate clearance is blocked when + recorded net weight does not equal gross weight minus tare weight. setReference(e.currentTarget.value)} /> + +
+ ))} +
+ )} + + {/* Dropzone area */} + {!reachedLimit && + (variant === "minimal" ? ( +
+ + { + if (fileInputRefs.current) { + fileInputRefs.current[field.fileKey] = el; + } + }} + multiple={field.isMultiple} + accept={acceptString} + disabled={disabled} + onChange={(e) => handleFileSelect(e, field)} + className="hidden" + /> + + Accepts:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} + +
+ ) : isUploaded ? ( + // Uploaded state: a solid success panel that still doubles as a + // replace target (click anywhere or drag a new file onto it). +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", + isDragOver + ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" + : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + aria-label={`Replace ${field.fileLabel}`} + /> + +
+ {isDragOver ? ( + + ) : ( + + )}
- - - {/* Hidden inputs to represent file details in traditional form submissions */} +
+

+ {isDragOver ? "Drop to replace" : "Document uploaded"} +

+

+ {isDragOver + ? "Release to replace the document on file." + : "Saved to your application. Drag a new file here or click to replace it."} +

+
+ + + + Replace + +
+ ) : ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : "border-border hover:border-primary/50 hover:bg-muted/10", + fieldError && + "border-destructive hover:border-destructive/80", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" /> + +
+ +
+ +

+ Drag & drop your file here, or{" "} + + browse + +

+ +

+ Supported formats:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} +

))} - - )} - {/* Dropzone area */} - {!reachedLimit && ( - variant === "minimal" ? ( -
- - { - if (fileInputRefs.current) { - fileInputRefs.current[field.fileKey] = el; - } - }} - multiple={field.isMultiple} - accept={acceptString} - disabled={disabled} - onChange={(e) => handleFileSelect(e, field)} - className="hidden" - /> - - Accepts: {field.allowedExtensions.join(", ").toUpperCase() || "All"} - + {/* Validation Error Message */} + {fieldError && ( +
+ + {fieldError}
- ) : ( -
handleDrag(e, field.fileKey, true)} - onDragLeave={(e) => handleDrag(e, field.fileKey, false)} - onDrop={(e) => handleDrop(e, field)} - className={cn( - "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", - isDragOver - ? "border-primary bg-primary/5 dark:bg-primary/10" - : "border-border hover:border-primary/50 hover:bg-muted/10", - fieldError && "border-destructive hover:border-destructive/80", - disabled && "opacity-50 pointer-events-none cursor-not-allowed" - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - /> - -
- -
- -

- Drag & drop your file here, or browse -

- -

- Supported formats: {field.allowedExtensions.join(", ").toUpperCase() || "All"} -

-
- ) - )} - - {/* Validation Error Message */} - {fieldError && ( -
- - {fieldError} -
- )} -
- ); - })} + )} + + ); + })} + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 205ca2bd7..bd8c81c83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@mantine/dates': specifier: ^9.3.2 - version: 9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) devDependencies: '@commitlint/cli': specifier: ^19.5.0 @@ -86,10 +86,10 @@ importers: version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) '@tria-plc/iamapi-common': - specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e) + specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz + version: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -105,9 +105,15 @@ importers: class-validator: specifier: ^0.14.1 version: 0.14.4 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 dotenv: specifier: ^17.4.2 version: 17.4.2 + dotenv-cli: + specifier: ^11.0.0 + version: 11.0.0 handlebars: specifier: ^4.7.9 version: 4.7.9 @@ -215,8 +221,8 @@ importers: specifier: ^5.100.11 version: 5.101.0(react@19.2.6) '@tria-plc/iamui': - specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz - version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7) + specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) axios: specifier: ^1.7.7 version: 1.17.0 @@ -327,8 +333,8 @@ importers: specifier: ^5.59.0 version: 5.101.0(react@19.2.6) '@tria-plc/iamui': - specifier: file:../../../local-packages/tria-plc-iamui-0.0.3.tgz - version: file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7) + specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) axios: specifier: ^1.7.7 version: 1.17.0 @@ -1476,6 +1482,9 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -2034,10 +2043,10 @@ packages: react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/core@9.4.0': - resolution: {integrity: sha512-BvTzaJ5Nut4ZiWLCcy1/cRXE8yjCgxsBhyAynY1pEg6XBw/fChEqSgbEB2dybOnkiAxvYt89TrnTrBBo9qJtmg==} + '@mantine/core@9.3.2': + resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==} peerDependencies: - '@mantine/hooks': 9.4.0 + '@mantine/hooks': 9.3.2 react: ^19.2.0 react-dom: ^19.2.0 @@ -2050,11 +2059,11 @@ packages: react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@mantine/dates@9.4.0': - resolution: {integrity: sha512-15iCWxykutEVoFUPTq4z+An3YQZb7UH6Fwzb3stsVhTMLm6FyGLYSSB6PL+9sBV1TPllIJSm1IizY2SSTDGqQQ==} + '@mantine/dates@9.3.2': + resolution: {integrity: sha512-MzHrXGoOb3rCnHBlsI/BPAjC8K5tZ4f8pzg66tsTsupVCQBaBpHSVatQwNRJ6K2JC9pyCyTa9BL803C8AiWycA==} peerDependencies: - '@mantine/core': 9.4.0 - '@mantine/hooks': 9.4.0 + '@mantine/core': 9.3.2 + '@mantine/hooks': 9.3.2 dayjs: '>=1.0.0' react: ^19.2.0 react-dom: ^19.2.0 @@ -2069,8 +2078,8 @@ packages: peerDependencies: react: ^19.2.0 - '@mantine/hooks@9.4.0': - resolution: {integrity: sha512-SUkge8KlhyLoSSKhEHERx7jLotNu632kldhQPlLn5kbOuJixoEbU8fKcuwpXSsZTtzRgCCT2IkoN+rORhtZTHg==} + '@mantine/hooks@9.3.2': + resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==} peerDependencies: react: ^19.2.0 @@ -4087,9 +4096,31 @@ packages: rxjs: ^7.8.0 typeorm: ^0.3.0 - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz': - resolution: {integrity: sha512-aZhIeNq2Uui7TUkG88G9QTBqdhaVB5kvqfd47HLgVE7qKkVyfjlm4nwlSWnHh5MO+CjgP9vRi6ILwMJx1ULO8g==, tarball: file:local-packages/tria-plc-iamui-0.0.3.tgz} - version: 0.0.3 + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz': + resolution: {integrity: sha512-zW7dIEnoai9NSagFsjdH3CyU4dizhFnV9AkO0sOb8MJhFGaifFaMLyWkB9QMQvUIxFRziOcqw3vR0yjqLmTNPQ==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.7.tgz} + version: 0.7.7 + engines: {node: '>=20'} + peerDependencies: + '@nestjs/axios': ^4.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/jwt': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/passport': ^11.0.0 + '@nestjs/swagger': ^11.0.0 + '@nestjs/throttler': ^6.0.0 + '@nestjs/typeorm': ^11.0.0 + '@tria-plc/api-common': '*' + axios: ^1.9.0 + class-transformer: ^0.5.1 + class-validator: ^0.14.1 + reflect-metadata: ^0.2.0 + rxjs: ^7.8.0 + typeorm: ^0.3.0 + + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz': + resolution: {integrity: sha512-FTihoH0lIqKV/0s+FTJZokQw8xX3XztXIqT8eEYEZgDM2xyzVSCHY8pHCUxw0MQtiR8R97zxZJ/o192eWt+E/g==, tarball: file:local-packages/tria-plc-iamui-0.1.1.tgz} + version: 0.1.1 engines: {node: '>=18'} peerDependencies: react: ^18.3.1 || ^19.0.0 @@ -5734,6 +5765,11 @@ packages: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} engines: {node: '>=18.x'} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6069,6 +6105,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dotenv-cli@11.0.0: + resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} + hasBin: true + dotenv-expand@12.0.3: resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} engines: {node: '>=12'} @@ -11778,6 +11818,8 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -12440,10 +12482,10 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.4.0(react@19.2.6) + '@mantine/hooks': 9.3.2(react@19.2.6) clsx: 2.1.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -12453,19 +12495,19 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.3.0(react@19.2.6) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@mantine/dates@9.4.0(@mantine/core@9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.4.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 9.4.0(@mantine/hooks@9.4.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 9.4.0(react@19.2.6) + '@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.2(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 @@ -12483,7 +12525,7 @@ snapshots: dependencies: react: 19.2.6 - '@mantine/hooks@9.4.0(react@19.2.6)': + '@mantine/hooks@9.3.2(react@19.2.6)': dependencies: react: 19.2.6 @@ -15191,6 +15233,50 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e) + argon2: 0.43.1 + axios: 1.17.0 + change-case: 5.4.4 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 16.6.1 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-date: 0.0.6 + exceljs: 4.4.0 + file-type: 21.3.4 + handlebars: 4.7.9 + handlebars-helpers: 0.10.0 + jmespath: 0.16.0 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + libreoffice-convert: 1.8.1 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + style-object-to-css-string: 1.1.3 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + xlsx: 0.18.5 + transitivePeerDependencies: + - '@faker-js/faker' + - debug + - supports-color + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15235,85 +15321,6 @@ snapshots: - debug - supports-color - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e) - argon2: 0.43.1 - axios: 1.17.0 - change-case: 5.4.4 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 16.6.1 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-date: 0.0.6 - exceljs: 4.4.0 - file-type: 21.3.4 - handlebars: 4.7.9 - handlebars-helpers: 0.10.0 - jmespath: 0.16.0 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - libreoffice-convert: 1.8.1 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - style-object-to-css-string: 1.1.3 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - xlsx: 0.18.5 - transitivePeerDependencies: - - '@faker-js/faker' - - debug - - supports-color - - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e) - api-common: 1.2.2 - argon2: 0.43.1 - axios: 1.17.0 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 17.4.2 - ethiopian-date: 0.0.6 - file-type: 21.3.4 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - transitivePeerDependencies: - - '@faker-js/faker' - - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) @@ -15349,7 +15356,42 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.0.3.tgz(0ce39b7e349029277dcd938d06eeb0f7)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.7.tgz(578386f46cf99fd4720e3e99f196f69e)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(bad2eb10df48448775040459098de142) + api-common: 1.2.2 + argon2: 0.43.1 + axios: 1.17.0 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 17.4.2 + ethiopian-date: 0.0.6 + file-type: 21.3.4 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + transitivePeerDependencies: + - '@faker-js/faker' + - supports-color + + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) @@ -15357,7 +15399,7 @@ snapshots: '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -17284,6 +17326,11 @@ snapshots: '@types/luxon': 3.7.1 luxon: 3.7.2 + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -17575,6 +17622,13 @@ snapshots: dependencies: is-obj: 2.0.0 + dotenv-cli@11.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 17.4.2 + dotenv-expand: 12.0.3 + minimist: 1.2.8 + dotenv-expand@12.0.3: dependencies: dotenv: 16.6.1 @@ -20179,7 +20233,7 @@ snapshots: mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6) '@tanstack/match-sorter-utils': 8.19.4 diff --git a/tmp-iam-inspect/package/README.md b/tmp-iam-inspect/package/README.md deleted file mode 100644 index 2d39087c8..000000000 --- a/tmp-iam-inspect/package/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# @tria-plc/iamapi-common - -Standalone IAM NestJS module extracted from the Smart Office monorepo. Provides authentication, user management, organization structure, and record elements functionality as a reusable package for Tria PLC platform services. - -## Installation - -```bash -npm install @tria-plc/iamapi-common --registry=https://npm.pkg.github.com -``` - -Or with a project-level `.npmrc`: - -``` -@tria-plc:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN -``` -## Usage - -```typescript -import { IamModule } from '@tria-plc/iamapi-common'; - -@Module({ - imports: [IamModule], -}) -export class AppModule {} -``` - -The `IamModule` registers all sub-modules: `AuthModule`, `UserModule`, `OrganizationStructureModule`, and `RecordElementModule`. - -Database configuration, migrations, and seeding remain the responsibility of the consuming application. - -## Peer Dependencies - -- `@nestjs/common` ^11 -- `@nestjs/core` ^11 -- `@nestjs/jwt` ^11 -- `@nestjs/passport` ^11 -- `@nestjs/typeorm` ^11 -- `@smart-office/be` ^1.0.0 -- `reflect-metadata` ^0.2 -- `rxjs` ^7.8 -- `typeorm` ^0.3 - -## Publishing - -Releases are published automatically to GitHub Packages when a version tag (`v*`) is pushed: - -```bash -git tag v1.0.1 -git push origin v1.0.1 -``` - -## Development - -```bash -pnpm install -pnpm build -``` - diff --git a/tmp-iam-inspect/package/package.json b/tmp-iam-inspect/package/package.json deleted file mode 100644 index 8befeb41b..000000000 --- a/tmp-iam-inspect/package/package.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "name": "@tria-plc/iamapi-common", - "version": "0.7.3", - "description": "Standalone IAM NestJS module for Tria PLC platform services", - "repository": { - "type": "git", - "url": "https://github.com/Tria-plc/iamapi-common.git" - }, - "author": "Tria PLC", - "license": "UNLICENSED", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist", - "scripts" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.js" - }, - "./package.json": "./package.json", - "./*.js": "./dist/*.js", - "./*": { - "types": "./dist/*.d.ts", - "require": "./dist/*.js" - } - }, - "typesVersions": { - "*": { - "*": [ - "./dist/*.d.ts", - "./dist/*/index.d.ts" - ] - } - }, - "publishConfig": { - "registry": "https://npm.pkg.github.com" - }, - "engines": { - "node": ">=20" - }, - "scripts": { - "prepare": "husky || true", - "test": "echo 'No tests configured' && exit 0", - "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", - "build": "npm run clean && nest build", - "prepublishOnly": "npm run build", - "typeorm:cli": "node ./scripts/typeorm-cli.cjs", - "migration:run": "npm run typeorm:cli migration:run", - "migration:generate": "npm run build && npm run typeorm:cli migration:generate src/db/migrations/IamApiCommonMigration", - "migration:create": "npm run typeorm:cli migration:create", - "migration:revert": "npm run typeorm:cli migration:revert" - }, - "peerDependencies": { - "@nestjs/axios": "^4.0.0", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/jwt": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/passport": "^11.0.0", - "@nestjs/swagger": "^11.0.0", - "@nestjs/throttler": "^6.0.0", - "@nestjs/typeorm": "^11.0.0", - "@tria-plc/api-common": "*", - "axios": "^1.9.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.0", - "typeorm": "^0.3.0" - }, - "dependencies": { - "api-common": "1.2.2", - "argon2": "^0.43.0", - "dotenv": "^17.4.2", - "ethiopian-date": "^0.0.6", - "file-type": "^21.3.0", - "jose": "^5.3.0", - "jsonwebtoken": "^9.0.2", - "libphonenumber-js": "^1.12.9", - "nestjs-minio-client": "^2.2.0", - "passport-jwt": "^4.0.1", - "qrcode": "^1.5.4", - "typeorm-extension": "^3.9.0", - "uuid": "^11.1.0" - }, - "devDependencies": { - "@commitlint/cli": "^19.0.0", - "@commitlint/config-conventional": "^19.0.0", - "@nestjs/axios": "^4.0.0", - "@nestjs/cli": "^11.0.7", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/jwt": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/passport": "^11.0.0", - "@nestjs/schematics": "^10.2.3", - "@nestjs/swagger": "^11.0.0", - "@nestjs/testing": "^11.0.8", - "@nestjs/throttler": "^6.0.0", - "@nestjs/typeorm": "^11.0.0", - "@semantic-release/changelog": "^6.0.3", - "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^12.0.8", - "@semantic-release/npm": "^13.1.5", - "@tria-plc/api-common": "^1.4.3", - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20.17.32", - "@types/passport-jwt": "^4.0.1", - "axios": "^1.9.0", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "husky": "^9.0.0", - "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.0", - "semantic-release": "^25.0.3", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typeorm": "^0.3.0", - "typescript": "^5.8.3" - } -} diff --git a/tmp-iam-inspect/package/scripts/typeorm-cli.cjs b/tmp-iam-inspect/package/scripts/typeorm-cli.cjs deleted file mode 100644 index 025cd3ef0..000000000 --- a/tmp-iam-inspect/package/scripts/typeorm-cli.cjs +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -const { spawnSync } = require("child_process"); -const path = require("path"); - -const tsNodeBin = require.resolve("ts-node/dist/bin.js"); -const typeormCli = require.resolve("typeorm/cli.js"); -const dataSource = path.resolve(__dirname, "../src/typeorm.config.ts"); - -const args = [ - tsNodeBin, - "-r", - "tsconfig-paths/register", - typeormCli, - "-d", - dataSource, - ...process.argv.slice(2), -]; - -const result = spawnSync(process.execPath, args, { stdio: "inherit" }); -process.exit(result.status ?? 1);