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/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3b69d2812..02183f390 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -52,6 +52,7 @@ 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"; //New Trains, Wagons, Container and Cargo management modules @@ -145,6 +146,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte Batch7TestDataSeeder, Batch8TestDataSeeder, WarehouseDemoSeeder, + ExportDjiboutiInterchangeDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -161,6 +163,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 +182,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/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/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 2feadaccd..4f0ce0012 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 @@ -22,6 +22,11 @@ export class TruckEntranceDto { @IsString() tin?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + customerPhone?: string; + @ApiProperty() @IsString() truckPlateNumber!: string; 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/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.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ab3adb64a..e25286252 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,13 @@ export class WarehouseInventoryService { const now = new Date(); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking); + this.assertTruckEntrance(truckEntrance); const 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({ @@ -783,12 +869,21 @@ 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: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, ); + await this.notifyOwnerInventoryReceived({ + phone: truckEntrance.customerPhone, + ownerName: truckEntrance.ownerName, + bookingReference: truckEntrance.edrDigitalBookingId, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }); + result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); } @@ -1166,7 +1261,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 +1402,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,7 +1592,6 @@ 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; @@ -1495,6 +1603,10 @@ export class WarehouseInventoryService { if (dto.bookingId) { await this.assertBookingExists(manager, dto.bookingId); } + const truckEntrance = dto.bookingId + ? this.mergeSystemTruckEntrance(dto.truckEntrance, await this.getBookingTruckEntranceSource(manager, dto.bookingId)) + : dto.truckEntrance; + this.assertTruckEntrance(truckEntrance); this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); this.assertCapacity('Yard', yard, weight, volume, containerCount); @@ -1505,7 +1617,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({ @@ -1529,14 +1641,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 +1897,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 +1930,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 +1991,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 +2497,7 @@ export class WarehouseInventoryService { zone: string | null; inventoryStatus: string | null; clearanceStatus: string; + exitInspectionSummary?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -2402,6 +2528,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 ` @@ -2522,12 +2649,225 @@ 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; @@ -2542,6 +2882,7 @@ export class WarehouseInventoryService { truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, truck.tin ? `TIN: ${truck.tin}` : null, + truck.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null, `Truck Plate: ${truck.truckPlateNumber}`, truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, 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..cbdb5522c 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 => 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/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/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index a2437fe8f..1503718e5 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,21 @@ interface TruckEntranceFormState { warehouseManagerName: string; } +interface LockedTruckEntranceFields { + ownerName?: boolean; + tin?: boolean; + edrDigitalBookingId?: boolean; + customerPhone?: boolean; +} + +type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED'; + const emptyTruckEntrance = (): TruckEntranceFormState => ({ ownerName: '', consigneeDetails: '', edrDigitalBookingId: '', tin: '', + customerPhone: '', truckPlateNumber: '', trailerPlateNumber: '', assignedEquipmentNumber: '', @@ -122,6 +133,7 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl consigneeDetails: form.consigneeDetails.trim() || undefined, edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, tin: form.tin.trim() || undefined, + customerPhone: form.customerPhone.trim() || undefined, truckPlateNumber: form.truckPlateNumber.trim(), trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, @@ -149,13 +161,124 @@ 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), + tin: Boolean(tin), + edrDigitalBookingId: Boolean(edrDigitalBookingId), + customerPhone: Boolean(customerPhone), + }, + 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,6 +286,7 @@ function TruckEntranceFields({ onChange({ ...value, ownerName: e.currentTarget.value })} /> onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} /> onChange({ ...value, tin: e.currentTarget.value })} /> + onChange({ ...value, customerPhone: e.currentTarget.value })} + /> Transport and equipment tracking @@ -285,13 +417,16 @@ function TruckEntranceFields({ /> - onChange({ ...value, packagingType: e.currentTarget.value })} + onChange={(v) => onChange({ ...value, packagingType: v ?? '' })} /> onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} @@ -466,6 +601,8 @@ function EligibleTab({ const [truckOpen, setTruckOpen] = useState(false); const [pendingReceiveIds, setPendingReceiveIds] = useState([]); 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) => @@ -564,13 +701,14 @@ 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[]; + 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); }; @@ -593,6 +731,8 @@ function EligibleTab({ setSelected(new Set()); setTruckOpen(false); setPendingReceiveIds([]); + setLockedTruckFields({}); + setPackagingFreightType('MIXED'); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); @@ -805,7 +945,12 @@ function EligibleTab({ Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. - +