From d55f9312bf5d1c1350ec566b1b1cbf69aac0fbdb Mon Sep 17 00:00:00 2001 From: hagiye Date: Mon, 29 Jun 2026 12:24:14 +0300 Subject: [PATCH 01/20] Marshaling document Receive Export and import handover to customer --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/src/app.module.ts | 4 + .../warehouses/scheduling-read.facade.ts | 51 +- .../warehouse-inventory.controller.ts | 21 +- .../warehouses/warehouse-inventory.service.ts | 346 ++++++++- .../modules/warehouses/warehouses.module.ts | 2 + .../auto-unload-arrived-import-trains.ts | 85 +++ .../seed-negad-indode-arrived-train.ts | 266 ++++++- .../seed/marshalling-demo-trains.seeder.ts | 474 ++++++++++++ apps/edr-freight-web/backoffice/src/App.tsx | 123 +++- .../warehouses/InventoryWorkbench.tsx | 21 + .../warehouses/ReceiveInventoryModal.tsx | 694 ++++++++++++++++-- .../warehouses/ReleaseOrderModal.tsx | 9 +- .../warehouses/WarehouseInventoryTable.tsx | 15 +- .../src/components/warehouses/index.ts | 2 +- .../src/components/warehouses/warehousePdf.ts | 72 +- .../backoffice/src/constants/URLS.ts | 1 + .../TrainScheduleV2DetailPage.tsx | 28 +- .../src/pages/warehouses/ArrivalQueuePage.tsx | 4 +- .../ExportDjiboutiUnloadingQueuePage.tsx | 16 +- .../warehouses/ExportWarehouseFlowPage.tsx | 28 + .../warehouses/ImportWarehouseFlowPage.tsx | 28 + .../warehouses/WarehouseInventoryPage.tsx | 24 +- .../src/services/warehouse.service.ts | 4 + .../backoffice/src/types/warehouse.ts | 9 + .../edr-freight-web/backoffice/vite.config.ts | 3 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 53 +- .../portal/src/services/bookings.service.ts | 14 + 28 files changed, 2236 insertions(+), 162 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts create mode 100644 apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 2fd9f6f5a..27737c84c 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -22,6 +22,7 @@ "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", + "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index fd9f3af33..b177429af 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -53,6 +53,7 @@ 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 { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.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"; @@ -150,6 +151,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera Batch8TestDataSeeder, WarehouseDemoSeeder, ExportDjiboutiInterchangeDemoSeeder, + MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, ], }) @@ -168,6 +170,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, + private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -187,6 +190,7 @@ export class AppModule implements OnApplicationBootstrap { await this.batch8TestDataSeeder.run(); await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); + await this.marshallingDemoTrainsSeeder.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/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index ca16bcaa5..c1faeed22 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 @@ -32,6 +32,9 @@ export interface ImportTrainItemRow { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; containerNumber: string | null; cargoType: string | null; weight: number | null; @@ -59,6 +62,9 @@ export interface ExportTrainItemRow { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; itemType: 'CONTAINER' | 'CARGO'; itemId: string | null; inventoryId: string | null; @@ -265,6 +271,9 @@ export class SchedulingReadFacade { b.reference AS "bookingReference", b.company_id AS "customerId", company.name AS "customerName", + w.wagon_number AS "wagonNumber", + tsw.sequence_no AS "sequenceNo", + wba.allocated_weight_tons AS "allocatedWeightTons", (SELECT c.container_number FROM freight.containers c WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", @@ -279,11 +288,24 @@ export class SchedulingReadFacade { FROM freight.train_schedule_bookings tsb JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id + AND wba.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM freight.train_set_wagons tsw_match + WHERE tsw_match.id = wba.train_set_wagon_id + AND tsw_match.train_set_id = ts.train_set_id + AND tsw_match.deleted_at IS NULL + ) + LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + AND tsw.train_set_id = ts.train_set_id + AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL - ORDER BY b.reference ASC NULLS LAST`, + ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`, [scheduleId], ); return rows; @@ -401,6 +423,9 @@ export class SchedulingReadFacade { b.reference, b.company_id, company.name AS customer_name, + w.wagon_number, + tsw.sequence_no, + wba.allocated_weight_tons, COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type, b.cargo_total_weight_vgm AS booking_weight, oy.code AS origin, @@ -412,6 +437,19 @@ export class SchedulingReadFacade { FROM freight.train_schedule_bookings tsb JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id + AND wba.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM freight.train_set_wagons tsw_match + WHERE tsw_match.id = wba.train_set_wagon_id + AND tsw_match.train_set_id = ts.train_set_id + AND tsw_match.deleted_at IS NULL + ) + LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + AND tsw.train_set_id = ts.train_set_id + AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -423,6 +461,9 @@ export class SchedulingReadFacade { a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", + a.wagon_number AS "wagonNumber", + a.sequence_no AS "sequenceNo", + a.allocated_weight_tons AS "allocatedWeightTons", 'CONTAINER' AS "itemType", c.id AS "itemId", a.inventory_id AS "inventoryId", @@ -441,6 +482,9 @@ export class SchedulingReadFacade { a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", + a.wagon_number AS "wagonNumber", + a.sequence_no AS "sequenceNo", + a.allocated_weight_tons AS "allocatedWeightTons", 'CARGO' AS "itemType", cg.id AS "itemId", a.inventory_id AS "inventoryId", @@ -460,6 +504,9 @@ export class SchedulingReadFacade { a.reference AS "bookingReference", a.company_id AS "customerId", a.customer_name AS "customerName", + a.wagon_number AS "wagonNumber", + a.sequence_no AS "sequenceNo", + a.allocated_weight_tons AS "allocatedWeightTons", CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", a.inventory_id AS "itemId", a.inventory_id AS "inventoryId", @@ -474,7 +521,7 @@ export class SchedulingReadFacade { FROM assigned a WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL) - ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`, + ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`, [scheduleId], ); return rows; 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 204d0f0d4..68f536b33 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; @@ -273,6 +273,25 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get(':id/handover-document') + @ApiOperation({ summary: 'View import goods handover document PDF' }) + async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.handoverDocument(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + @Post('bookings/:bookingId/approve-delivery') + @ApiOperation({ summary: "Approve delivery using the current customer's saved signature" }) + approveDeliveryForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Request() req: { user?: { id?: string; sub?: string } }, + ) { + return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 2bfddbe37..618553df3 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 @@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang 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 { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) => const isLoadableWagonStatus = (status: string | null | undefined) => LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); +const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; + export interface InventoryInquiryResult { id: string; inventoryId: string | null; @@ -297,6 +300,9 @@ export interface ImportUnloadedRow { pickupOption: string; lastMileRequested: boolean; currentStatus: string; + releaseDate: string | null; + releaseOrderReference: string | null; + deliveredAt: string | null; } @Injectable() @@ -316,6 +322,7 @@ export class WarehouseInventoryService { private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, private readonly notifications: NotificationsService, + private readonly signatures: SignaturesService, ) {} /** @@ -1020,6 +1027,9 @@ export class WarehouseInventoryService { THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", inv.status AS "currentStatus", + inv.release_date AS "releaseDate", + inv.release_order_reference AS "releaseOrderReference", + inv.delivered_at AS "deliveredAt", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.warehouse_inventory inv @@ -1049,7 +1059,16 @@ export class WarehouseInventoryService { * states), with the columns the inspection screen needs. Read-only. */ importUnloadedQueue(): Promise { - return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']); + return this.importQueueByStatuses([ + 'UNLOADED', + 'DESTINATION_INSPECTION', + 'UNDER_INSPECTION', + 'ARRIVED_AT_WAREHOUSE', + 'STORED', + 'READY_FOR_PICKUP', + 'DISPATCHED', + 'DELIVERED', + ]); } /** @@ -2020,6 +2039,154 @@ export class WarehouseInventoryService { } /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ + async approveDeliveryForBooking( + bookingId: string, + userId?: string, + ): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> { + if (!userId) { + throw new BadRequestException('Authentication is required to approve delivery'); + } + + const signature = await this.signatures.getForUser(userId); + if (!signature?.signatureImageUrl) { + throw new BadRequestException('Please save your signature before approving delivery'); + } + + const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> = + await this.dataSource.query( + `SELECT inv.id, + inv.warehouse_id AS "warehouseId", + inv.notes + FROM freight.warehouse_inventory inv + JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.inspection_status = 'PASSED' + ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC + LIMIT 1`, + [bookingId], + ); + + if (!item) { + throw new BadRequestException('Delivery can be approved after warehouse inspection has passed'); + } + + const approvedAt = new Date(); + const approval = { + approvedAt: approvedAt.toISOString(), + signerDisplayName: signature.signerDisplayName, + signatureImageUrl: signature.signatureImageUrl, + userId, + }; + const existingNotes = this.stripCustomerDeliveryApproval(item.notes); + const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(item.id, { + notes: this.appendNote(existingNotes, approvalNote), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RELEASED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: `Customer approved delivery as ${signature.signerDisplayName}`, + performedBy: signature.signerDisplayName, + }, + manager, + ); + }); + + return { + bookingId, + inventoryId: item.id, + approvedAt: approval.approvedAt, + signerDisplayName: signature.signerDisplayName, + }; + } + + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + const [row] = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + inv.quantity, + inv.weight, + inv.status, + inv.notes, + inv.inspection_status AS "inspectionStatus", + inv.release_date AS "releaseDate", + inv.release_order_reference AS "releaseOrderReference", + COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + company.name AS "customerName", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + wh.name AS "warehouseName", + wh.code AS "warehouseCode", + yard.name AS "yardName", + yard.code AS "yardCode", + zone.name AS "zoneName", + zone.code AS "zoneCode", + ts.train_number AS "trainSchedule" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [id], + ); + if (!row) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + if (row.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Handover document is available after inspection has passed'); + } + + const bookingReference = row.bookingReference || row.bookingId || 'N/A'; + const html = this.buildHandoverDocumentHtml({ + reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`, + handedOverAt: new Date(row.handoverDate ?? Date.now()), + bookingReference, + bookingStatus: row.bookingStatus ?? null, + customerName: row.customerName ?? null, + freightType: row.freightType ?? null, + tradeDirection: row.tradeDirection ?? null, + containerNumber: row.containerNumber ?? null, + cargoDescription: row.cargoDescription ?? null, + quantity: Number(row.quantity ?? 0), + weight: Number(row.weight ?? 0), + warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null, + yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null, + zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null, + inventoryStatus: row.status ?? null, + inspectionStatus: row.inspectionStatus ?? null, + releaseOrderReference: row.releaseOrderReference ?? null, + releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, + trainSchedule: row.trainSchedule ?? null, + customerApproval: this.extractCustomerDeliveryApproval(row.notes), + }); + + return { + filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + }; + } + async deliver(id: string, dto: DeliverInventoryDto): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'DELIVERED'); @@ -2619,6 +2786,183 @@ export class WarehouseInventoryService { `; } + private buildHandoverDocumentHtml(data: { + reference: string; + handedOverAt: Date; + bookingReference: string; + bookingStatus: string | null; + customerName: string | null; + freightType: string | null; + tradeDirection: string | null; + containerNumber: string | null; + cargoDescription: string | null; + quantity: number; + weight: number; + warehouse: string | null; + yard: string | null; + zone: string | null; + inventoryStatus: string | null; + inspectionStatus: string | null; + releaseOrderReference: string | null; + releaseDate: Date | null; + trainSchedule: string | null; + customerApproval: { + approvedAt: string; + signerDisplayName: string; + signatureImageUrl: string; + } | null; + }): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const fmt = (date: Date | string | null) => { + if (!date) return '-'; + const parsed = date instanceof Date ? date : new Date(date); + if (Number.isNaN(parsed.getTime())) return '-'; + return parsed.toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + const rows = [ + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName], + ['Booking Status', data.bookingStatus], + ['Freight Type', data.freightType], + ['Trade Direction', data.tradeDirection], + ['Train Schedule', data.trainSchedule], + ['Container Number', data.containerNumber], + ['Cargo / Goods Description', data.cargoDescription], + ['Quantity', data.quantity], + ['Declared Weight', `${data.weight.toLocaleString()} kg`], + ['Warehouse', data.warehouse], + ['Yard', data.yard], + ['Zone', data.zone], + ['Inventory Status', data.inventoryStatus], + ['Inspection Status', data.inspectionStatus], + ['Release Order', data.releaseOrderReference], + ['Release Date', fmt(data.releaseDate)], + ]; + const approval = data.customerApproval; + + return ` + + + + Import Goods Handover Document + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Import Goods Handover Document

+
EDR to customer warehouse handover
+
+
+ Document No. + ${esc(data.reference)} + Handover: ${esc(fmt(data.handedOverAt))} +
+
+
+
+ This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed. +
+
Handover Particulars
+ + + ${rows.map(([label, value]) => ``).join('')} + +
${esc(label)}${esc(value)}
+
Goods List
+ + + + + + +
1. Goods${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}
Container${esc(data.containerNumber)}
Weight${esc(`${data.weight.toLocaleString()} kg`)}
+
Handover Clause
+
+ The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference, + inspection status, and release records before final physical handover. +
+
+
Officer in charge name / signature / date
+
EDR
Warehouse
Handover
+
+ ${approval?.signatureImageUrl ? `` : ''} +
${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}
+
${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}
+
+
+ +`; + } + + private extractCustomerDeliveryApproval(notes?: string | null): { + approvedAt: string; + signerDisplayName: string; + signatureImageUrl: string; + } | null { + if (!notes) return null; + const line = notes + .split(/\r?\n/) + .find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX)); + if (!line) return null; + try { + const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length)); + if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null; + return { + approvedAt: String(parsed.approvedAt), + signerDisplayName: String(parsed.signerDisplayName), + signatureImageUrl: String(parsed.signatureImageUrl), + }; + } catch { + return null; + } + } + + private stripCustomerDeliveryApproval(notes?: string | null): string | null { + if (!notes?.trim()) return null; + const lines = notes + .split(/\r?\n/) + .filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX)); + return lines.join('\n').trim() || null; + } + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { throw new BadRequestException(`Invalid transition ${from} → ${to}`); 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 1671b63e8..d880a3554 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -7,6 +7,7 @@ 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 { SignaturesModule } from '../signatures/signatures.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'; @@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service'; InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts b/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts new file mode 100644 index 000000000..21750a755 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/auto-unload-arrived-import-trains.ts @@ -0,0 +1,85 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.TYPEORM_LOGGING = 'false'; + +import { AppModule } from '../app.module'; +import { deriveTradeDirection } from '../common/derive-trade-direction.util'; +import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn'], + }); + + try { + const dataSource = app.get(DataSource); + const inventory = app.get(WarehouseInventoryService); + + const schedules: { + id: string; + trainNumber: string | null; + originCountry: string | null; + destinationCountry: string | null; + }[] = await dataSource.query( + `SELECT ts.id, + ts.train_number AS "trainNumber", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.status = 'ARRIVED' + AND ts.deleted_at IS NULL + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST, + ts.created_at DESC`, + ); + + const importSchedules = schedules.filter( + (schedule) => + deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ) === 'IMPORT', + ); + + if (importSchedules.length === 0) { + console.log('No ARRIVED import trains found.'); + return; + } + + for (const schedule of importSchedules) { + const result = await inventory.autoUnloadArrivedBookings( + schedule.id, + 'Demo Auto Unload', + ); + console.log( + `${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`, + ); + for (const item of result.results) { + console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`); + } + } + + const queueRows = await inventory.importUnloadedQueue(); + console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`); + const byStatus = queueRows.reduce>((acc, row) => { + acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1; + return acc; + }, {}); + for (const [status, count] of Object.entries(byStatus)) { + console.log(` ${status}: ${count}`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 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 index 732de6262..ff4a34493 100644 --- 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 @@ -1,16 +1,30 @@ import 'reflect-metadata'; import { config } from 'dotenv'; import { resolve } from 'path'; +import { WagonStatus } from '@edr/types'; config({ path: resolve(__dirname, '../../.env') }); import { AppDataSource } from '../data-source'; +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 { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.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'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01'; +const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const; function addHours(date: Date, hours: number): Date { return new Date(date.getTime() + hours * 60 * 60 * 1000); @@ -25,18 +39,34 @@ async function main() { const locomotiveRepo = manager.getRepository(Locomotive); const trainSetRepo = manager.getRepository(TrainSet); const scheduleRepo = manager.getRepository(TrainSchedule); + const wagonTypeRepo = manager.getRepository(WagonType); + const wagonRepo = manager.getRepository(Wagon); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const serviceTypeRepo = manager.getRepository(ServiceType); + const containerTypeRepo = manager.getRepository(ContainerType); + const companyRepo = manager.getRepository(Company); + const bookingRepo = manager.getRepository(Booking); + const bookingContainerRepo = manager.getRepository(BookingContainer); + const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); + const allocationRepo = manager.getRepository(WagonBookingAllocation); + const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); + const importOperationRepo = manager.getRepository(ImportDjiboutiOperation); const negad = (await yardRepo.findOne({ where: { code: 'NEGAD' } })) ?? (await yardRepo.save( yardRepo.create({ code: 'NEGAD', - label: 'Negad', + label: 'Negad / Nagad', country: 'Djibouti', isActive: true, displayOrder: 5, }), )); + if (negad.label !== 'Negad / Nagad') { + negad.label = 'Negad / Nagad'; + await yardRepo.save(negad); + } const indode = (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? @@ -64,6 +94,78 @@ async function main() { }), )); + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ?? + (await wagonTypeRepo.save( + wagonTypeRepo.create({ + code: 'NEGAD-FLAT', + name: 'Negad Demo Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }), + )); + + const containerType = + (await containerTypeRepo.findOne({ where: { code: '40FT' } })) ?? + (await containerTypeRepo.save( + containerTypeRepo.create({ + code: '40FT', + label: '40FT', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }), + )); + + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.save( + serviceTypeRepo.create({ + code: 'RAIL_CONTAINER', + serviceName: 'Rail Container Service', + description: 'Rail container service for demo marshalling', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }), + )); + + const company = + (await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ?? + (await companyRepo.save( + companyRepo.create({ + name: 'Negad Indode Marshalling Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: 'NEGADIND01', + vatNumber: 'NEGADIND01', + fanNumber: 'NEGADINDODE00001', + country: 'Ethiopia', + address: 'Indode Dry Port', + phone: '251900000202', + email: 'negad-indode-demo@edr.local', + contactPersonName: 'Marshalling Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: 'negad-indode-demo@edr.local', + generalManagerPhone: '251900000202', + }), + )); + const now = new Date(); const departure = addHours(now, -12); const arrival = now; @@ -75,7 +177,7 @@ async function main() { locomotiveId: locomotive.id, totalWeightTons: 960, totalLengthMeters: 420, - wagonCount: 18, + wagonCount: BOOKING_REFS.length, status: 'COMPLETED', }), ); @@ -111,9 +213,169 @@ async function main() { } const saved = await scheduleRepo.save(schedule); + await trainSetRepo.update(saved.trainSetId, { + totalWeightTons: BOOKING_REFS.length * 28, + totalLengthMeters: BOOKING_REFS.length * 14, + wagonCount: BOOKING_REFS.length, + status: 'COMPLETED', + }); + + const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } }); + const existingAllocations = existingSlots.length + ? await allocationRepo.find({ + where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })), + }) + : []; + if (existingAllocations.length) { + await containerItemRepo.delete( + existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })), + ); + } + if (existingSlots.length) { + await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id }))); + await wagonRepo.update( + existingSlots.map((slot) => ({ trainSetWagonId: slot.id })), + { + trainSetWagonId: null, + currentTrainScheduleId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }, + ); + await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId }); + } + + for (const [index, reference] of BOOKING_REFS.entries()) { + const sequenceNo = index + 1; + const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`; + const weightTons = 26 + sequenceNo; + + let booking = await bookingRepo.findOne({ where: { reference } }); + if (!booking) { + booking = bookingRepo.create({ reference }); + } + Object.assign(booking, { + companyId: company.id, + originYardId: negad.id, + destinationYardId: indode.id, + serviceTypeId: serviceType.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: departure, + estimatedShipmentDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: `Negad to Indode demo container ${sequenceNo}`, + cargoTotalWeightVgm: weightTons, + priorityScore: 75 - index, + trainScheduleId: saved.id, + schedulingStatus: 'SCHEDULED', + scheduledAt: now, + wagonsRequired: 1, + }); + booking = await bookingRepo.save(booking); + + await bookingContainerRepo.delete({ bookingId: booking.id }); + const bookingContainer = await bookingContainerRepo.save( + bookingContainerRepo.create({ + bookingId: booking.id, + containerTypeId: containerType.id, + containerNumber, + quantity: 1, + vgmPerUnitTons: weightTons, + totalVgmTons: weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }), + ); + + await scheduleBookingRepo.upsert( + { trainScheduleId: saved.id, bookingId: booking.id }, + { conflictPaths: { trainScheduleId: true, bookingId: true } }, + ); + + const wagon = await wagonRepo.save( + wagonRepo.create({ + wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`, + wagonTypeId: wagonType.id, + trainId: null, + sequenceNumber: sequenceNo, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Assigned, + currentYardId: indode.id, + notes: 'Demo wagon for Negad to Indode marshalling', + trainSetWagonId: null, + currentTrainScheduleId: saved.id, + }), + ); + + const trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + trainSetId: saved.trainSetId, + wagonTypeId: wagonType.id, + physicalWagonId: wagon.id, + sequenceNo, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: weightTons, + status: 'LOADED', + }), + ); + await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: weightTons, + loadType: 'CONTAINER', + status: 'LOADED', + confirmedAt: now, + }), + ); + + await containerItemRepo.save( + containerItemRepo.create({ + wagonBookingAllocationId: allocation.id, + bookingContainerId: bookingContainer.id, + containerId: null, + containerNumber, + containerTypeId: containerType.id, + positionOnWagon: 1, + sealNumber: `SEAL-${containerNumber}`, + grossWeightTons: weightTons, + }), + ); + } + + await importOperationRepo.upsert( + { + trainScheduleId: saved.id, + documents: {}, + gatepassGrantedAt: departure, + readyForLoadingAt: departure, + loadedOnTrainAt: departure, + departedFromDjiboutiAt: departure, + loadListGeneratedAt: now, + performedBy: 'Seed Demo', + notes: 'Seeded marshalling data for Negad to Indode arrived train', + }, + { conflictPaths: { trainScheduleId: true } }, + ); + console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`); console.log(`Schedule ID: ${saved.id}`); console.log(`Route: ${negad.code} -> ${indode.code}`); + console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`); }); } finally { await dataSource.destroy(); diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts new file mode 100644 index 000000000..2d3c47c26 --- /dev/null +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -0,0 +1,474 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { WagonStatus } from '@edr/types'; +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 { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.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'; + +type DemoDirection = 'IMPORT' | 'EXPORT'; + +interface DemoTrain { + trainNumber: string; + direction: DemoDirection; + status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED'; + bookingPrefix: string; + departureOffsetHours: number; +} + +const DEMO_TRAINS: DemoTrain[] = [ + { + trainNumber: 'MSH-DEMO-IMP-01', + direction: 'IMPORT', + status: 'SCHEDULED', + bookingPrefix: 'MSH-IMP-01', + departureOffsetHours: 6, + }, + { + trainNumber: 'MSH-DEMO-IMP-02', + direction: 'IMPORT', + status: 'DISPATCHED', + bookingPrefix: 'MSH-IMP-02', + departureOffsetHours: -3, + }, + { + trainNumber: 'MSH-DEMO-IMP-03', + direction: 'IMPORT', + status: 'ARRIVED', + bookingPrefix: 'MSH-IMP-03', + departureOffsetHours: -14, + }, + { + trainNumber: 'MSH-DEMO-EXP-01', + direction: 'EXPORT', + status: 'SCHEDULED', + bookingPrefix: 'MSH-EXP-01', + departureOffsetHours: 8, + }, + { + trainNumber: 'MSH-DEMO-EXP-02', + direction: 'EXPORT', + status: 'DISPATCHED', + bookingPrefix: 'MSH-EXP-02', + departureOffsetHours: -2, + }, + { + trainNumber: 'MSH-DEMO-EXP-03', + direction: 'EXPORT', + status: 'ARRIVED', + bookingPrefix: 'MSH-EXP-03', + departureOffsetHours: -12, + }, +]; + +@Injectable() +export class MarshallingDemoTrainsSeeder { + private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const cargoTypeRepo = this.dataSource.getRepository(CargoType); + const wagonTypeRepo = this.dataSource.getRepository(WagonType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + + const djiboutiYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const ethiopiaYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ?? + (await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ?? + (await wagonTypeRepo.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 = [ + !djiboutiYard ? 'Djibouti yard' : '', + !ethiopiaYard ? 'Ethiopia yard' : '', + !serviceType ? 'service type' : '', + !wagonType ? 'wagon type' : '', + !warehouse ? 'INDODE_OPEN warehouse' : '', + !warehouseYard ? 'warehouse yard' : '', + !warehouseZone ? 'warehouse zone' : '', + ].filter(Boolean); + if (missing.length) { + this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`); + return; + } + + let seeded = 0; + for (const demo of DEMO_TRAINS) { + const created = await this.seedTrain(demo, { + djiboutiYard: djiboutiYard!, + ethiopiaYard: ethiopiaYard!, + serviceType: serviceType!, + cargoType, + wagonType: wagonType!, + warehouse: warehouse!, + warehouseYard: warehouseYard!, + warehouseZone: warehouseZone!, + }); + if (created) seeded += 1; + } + + this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`); + } catch (error) { + this.logger.error( + `MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async seedTrain( + demo: DemoTrain, + refs: { + djiboutiYard: Yard; + ethiopiaYard: Yard; + serviceType: ServiceType; + cargoType: CargoType | null; + wagonType: WagonType; + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation); + const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem); + + const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } }); + if (existing) { + await this.backfillDispatchQueueInventory(demo, refs); + return false; + } + + const now = new Date(); + const departure = this.addHours(now, demo.departureOffsetHours); + const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10); + const isDispatched = demo.status === 'DISPATCHED'; + const isArrived = demo.status === 'ARRIVED'; + const hasDeparted = isDispatched || isArrived; + const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + + const locomotive = await this.ensureLocomotive(originYard.id); + const bookingWeights = [22.4, 24.8, 18.6, 20.2]; + const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0); + const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; + const wagonLength = Number(refs.wagonType.lengthMeters) || 14; + const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: totalWeight, + totalLengthMeters: wagonLength * bookingWeights.length, + wagonCount: bookingWeights.length, + status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard.id, + destinationStationId: destinationYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: hasDeparted ? departure : null, + actualArrivalAt: isArrived ? arrival : null, + status: demo.status as TrainSchedule['status'], + trainNumber: demo.trainNumber, + direction: demo.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }), + ); + + for (const [index, weightTons] of bookingWeights.entries()) { + const sequence = index + 1; + const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`; + const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`; + + const booking = await bookingRepo.save( + bookingRepo.create({ + reference: bookingReference, + originYardId: originYard.id, + destinationYardId: destinationYard.id, + serviceTypeId: refs.serviceType.id, + status: hasDeparted ? 'IN_TRANSIT' : 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: demo.direction, + freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER', + cargoTypeId: refs.cargoType?.id ?? null, + cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`, + cargoTotalWeightVgm: weightTons * 1000, + trainScheduleId: schedule.id, + schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED', + scheduledAt: now, + }), + ); + await this.ensureDispatchQueueInventory({ + booking, + demo, + refs, + weightKg: weightTons * 1000, + now, + }); + + const physicalWagon = await this.ensureWagon({ + wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, + wagonTypeId: refs.wagonType.id, + yardId: originYard.id, + trainScheduleId: schedule.id, + tareWeight, + capacityTons: wagonCapacity, + dispatched: hasDeparted, + }); + + const trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + trainSetId: trainSet.id, + wagonTypeId: refs.wagonType.id, + physicalWagonId: physicalWagon.id, + sequenceNo: sequence, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: weightTons, + status: hasDeparted ? 'DEPARTED' : 'LOADED', + }), + ); + + await this.dataSource.getRepository(Wagon).update(physicalWagon.id, { + trainSetWagonId: trainSetWagon.id, + }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: weightTons, + loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK', + status: hasDeparted ? 'DEPARTED' : 'LOADED', + confirmedAt: now, + }), + ); + + await containerItemRepo.save( + containerItemRepo.create({ + wagonBookingAllocationId: allocation.id, + containerNumber, + positionOnWagon: 1, + sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`, + chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`, + grossWeightTons: weightTons, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + if (demo.direction === 'IMPORT') { + await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted); + } + return true; + } + + private async backfillDispatchQueueInventory( + demo: DemoTrain, + refs: { + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + for (let sequence = 1; sequence <= 4; sequence++) { + const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`; + const booking = await bookingRepo.findOne({ where: { reference } }); + if (!booking) continue; + await this.ensureDispatchQueueInventory({ + booking, + demo, + refs, + weightKg: Number(booking.cargoTotalWeightVgm) || 0, + now: new Date(), + }); + } + } + + private async ensureDispatchQueueInventory(input: { + booking: Booking; + demo: DemoTrain; + refs: { + warehouse: Warehouse; + warehouseYard: WarehouseYard; + warehouseZone: WarehouseZone; + }; + weightKg: number; + now: Date; + }): Promise { + const repo = this.dataSource.getRepository(WarehouseInventory); + const existing = await repo.findOne({ where: { bookingId: input.booking.id } }); + if (existing) return; + + const exportDispatch = input.demo.direction === 'EXPORT'; + const arrivedAt = this.addHours(input.now, -8); + const inspectedAt = this.addHours(input.now, -6); + const readyAt = this.addHours(input.now, -4); + const loadedAt = this.addHours(input.now, -2); + + await repo.save( + repo.create({ + warehouseId: input.refs.warehouse.id, + yardId: input.refs.warehouseYard.id, + zoneId: input.refs.warehouseZone.id, + bookingId: input.booking.id, + quantity: 1, + weight: input.weightKg, + status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP', + inspectionStatus: 'PASSED', + arrivedAt, + unloadedAt: exportDispatch ? null : arrivedAt, + inspectedAt, + readyForLoadingAt: exportDispatch ? readyAt : null, + loadedAt: exportDispatch ? loadedAt : null, + readyForPickupAt: exportDispatch ? null : readyAt, + notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`, + }), + ); + } + + private async ensureLocomotive(currentYardId: string): Promise { + const repo = this.dataSource.getRepository(Locomotive); + const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } }); + if (existing) return existing; + return repo.save( + repo.create({ + code: 'MSH-DEMO-LOCO', + name: 'Marshalling Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId, + }), + ); + } + + private async ensureWagon(input: { + wagonNumber: string; + wagonTypeId: string; + yardId: string; + trainScheduleId: string; + tareWeight: number; + capacityTons: number; + dispatched: boolean; + }): Promise { + const repo = this.dataSource.getRepository(Wagon); + const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); + if (existing) return existing; + return repo.save( + repo.create({ + wagonNumber: input.wagonNumber, + wagonTypeId: input.wagonTypeId, + currentYardId: input.yardId, + currentTrainScheduleId: input.trainScheduleId, + tareWeight: input.tareWeight, + maxPayloadWeight: input.capacityTons, + status: WagonStatus.Assigned, + notes: 'Marshalling demo seed wagon', + }), + ); + } + + private async seedImportOperation( + trainScheduleId: string, + trainNumber: string, + now: Date, + departure: Date, + dispatched: boolean, + ): Promise { + const repo = this.dataSource.getRepository(ImportDjiboutiOperation); + await repo.save( + repo.create({ + trainScheduleId, + documents: { + DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now), + PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now), + DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now), + ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now), + RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now), + }, + gatepassGrantedAt: now, + readyForLoadingAt: now, + loadedOnTrainAt: now, + departedFromDjiboutiAt: dispatched ? departure : null, + performedBy: 'Marshalling Demo Seeder', + notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow', + }), + ); + } + + private documentRecord(trainNumber: string, type: string, now: Date) { + return { + reference: `${type}-${trainNumber}`, + uploadedAt: now.toISOString(), + uploadedBy: 'Marshalling Demo Seeder', + notes: 'Seeded document for import marshalling workflow', + }; + } + + private addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ab1356a42..48b912a60 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -67,6 +67,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; +import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; +import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage"; import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; @@ -198,6 +200,85 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ // }, ], }, + { + title: "Port & Terminal", + items: [ + { + label: "Import Operations", + href: "/dashboard/import-warehouse", + icon: , + children: [ + { + label: "Import Overview", + href: "/dashboard/import-warehouse", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + ], + }, + { + label: "Export Operations", + href: "/dashboard/export-warehouse", + icon: , + children: [ + { + label: "Export Overview", + href: "/dashboard/export-warehouse", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + }, + { + label: "Terminal Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + ], + }, + ], + }, { title: "Warehouse Management", items: [ @@ -211,46 +292,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/warehouses", icon: , }, - { - label: "Inventory", - href: "/dashboard/warehouse-inventory", - icon: , - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - }, - { - label: "Djibouti Unloading", - href: "/dashboard/export-djibouti-unloading", - icon: , - }, - { - label: "Interchange Documents", - href: "/dashboard/interchange-documents", - icon: , - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - }, { label: "Allocation & Fees", href: "/dashboard/warehouse-rules", @@ -418,6 +459,8 @@ const App = () => { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index a8f0ca1ef..f37db855d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -120,6 +120,26 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo } }; + const openHandoverDocument = async (item: WarehouseInventoryItem) => { + setBusyId(item.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadHandoverDocument(item.id); + const filename = `handover-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`; + const opened = openPdfBlob(response.data, filename, pdfWindow); + toast({ title: opened ? 'Handover document opened' : 'Handover document downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Handover document failed', + description: extractErrorMessage(error), + }); + } finally { + setBusyId(null); + } + }; + const acceptLastMile = async (item: WarehouseInventoryItem) => { const reference = item.booking?.reference; if (!reference) { @@ -227,6 +247,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onInspect={setInspectItem} onFeePreview={setFeeItem} onReleaseDocument={downloadReleaseDocument} + onHandoverDocument={openHandoverDocument} onLastMile={onLastMile ? acceptLastMile : undefined} selectedIds={selected} onToggleSelect={toggleSelect} 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 e2ff9fb3a..eb320d78b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1,5 +1,6 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; import { + ActionIcon, Alert, Badge, Button, @@ -8,6 +9,7 @@ import { Loader, Modal, NumberInput, + ScrollArea, Select, Stack, Table, @@ -15,28 +17,58 @@ import { Text, Textarea, TextInput, + Tooltip, } from '@mantine/core'; -import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + ClipboardCheck, + Eye, + FileText, + History, + Info, + PackageCheck, + PackageOpen, + PackageSearch, + Send, + Search, + Train, + Truck, +} from 'lucide-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { useInventoryInquiry } from '@/hooks/useWarehouses'; import { firstMileService } from '@/services/first-mile.service'; +import { warehouseService } from '@/services/warehouse.service'; import type { EligibleBooking, + InventoryInquiryFilter, + InventoryInquiryResult, ImportTrain, ImportTrainItem, ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, TruckEntrancePayload, + WarehouseInventoryItem, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; +import { DeliverInventoryModal } from './DeliverInventoryModal'; +import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; +import { InventoryDetailModal } from './InventoryDetailModal'; +import { InventoryHistoryModal } from './InventoryHistoryModal'; import { InventoryWorkbench } from './InventoryWorkbench'; -import { extractErrorMessage, formatDate, formatNumber } from './options'; +import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal'; +import { ReleaseOrderModal } from './ReleaseOrderModal'; +import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { openPdfBlob } from './pdf'; +import '@/components/overview/overview.css'; interface ReceiveInventoryModalProps { opened: boolean; @@ -608,6 +640,7 @@ function EligibleTab({ const [statusTab, setStatusTab] = useState('ALL'); const [truckOpen, setTruckOpen] = useState(false); const [pendingReceiveIds, setPendingReceiveIds] = useState([]); + const [receivedAt, setReceivedAt] = useState(null); const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const [lockedTruckFields, setLockedTruckFields] = useState({}); const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); @@ -717,6 +750,7 @@ function EligibleTab({ setSelected(new Set()); setTruckOpen(false); setPendingReceiveIds([]); + setReceivedAt(null); setLockedTruckFields({}); setPackagingFreightType('MIXED'); onChanged?.(); @@ -749,6 +783,7 @@ function EligibleTab({ } const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); setPendingReceiveIds(filteredIds); + setReceivedAt(new Date().toISOString()); setTruckForm(form); setLockedTruckFields(lockedFields); setPackagingFreightType(nextPackagingFreightType); @@ -807,15 +842,18 @@ function EligibleTab({ )} )} @@ -967,7 +1015,7 @@ function EligibleTab({ setTruckOpen(false)} - title="Export Truck Arrival / First Mile Receive Form" + title="Receive to Warehouse" centered size="lg" > @@ -979,6 +1027,52 @@ function EligibleTab({ : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + + + + + Booking + Customer + TIN / Phone + Container / Cargo + Qty / Package + Weight + Received at + + + + {pendingReceiveRows.map((booking) => ( + + + {booking.reference} + {booking.id.slice(0, 8)}... + + {booking.customer ?? '-'} + + + {booking.customerTin ?? '-'} + {booking.customerPhone ?? '-'} + + + + + {booking.containerNumber ?? booking.cargoDescription ?? booking.cargo ?? '-'} + {booking.freightType ?? '-'} + + + + {[ + booking.containerQuantity != null ? `${booking.containerQuantity} unit(s)` : null, + booking.containerPackagingType, + ].filter(Boolean).join(' / ') || '-'} + + {formatNumber(Number(booking.weight))} + {formatDate(receivedAt)} + + ))} + +
+
Booking ID Customer ID Customer Name - Container # + Container / Cargo Items Cargo Type Weight Route @@ -1466,11 +1560,11 @@ function LoadedExportTab({ } /** Assigned bookings/items for an arrived import train (read-only detail view). */ -function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { +function ImportTrainDetailTable({ train }: { train: ImportTrain }) { const { data: items = [], isLoading } = useQuery( api.warehouses.importTrainItems.queryOptions({ - input: { scheduleId }, - enabled: Boolean(scheduleId), + input: { scheduleId: train.scheduleId }, + enabled: Boolean(train.scheduleId), }), ); @@ -1493,6 +1587,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { + Wagon Booking ID Booking Ref Customer ID @@ -1509,7 +1604,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { {items.map((it: ImportTrainItem) => ( - + + + + {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} + + {it.bookingId.slice(0, 8)}… @@ -1656,8 +1756,8 @@ function ImportArriveQueueTab({ {t.totalCargoes} - - {fullyUnloaded ? 'UNLOADED' : t.status} + + {t.status} {Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded @@ -1690,7 +1790,7 @@ function ImportArriveQueueTab({ {isOpen && ( - + )} @@ -1712,14 +1812,24 @@ function ImportArriveQueueTab({ */ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const { toast } = useToast(); + const qc = useQueryClient(); const { data: rows = [], isLoading } = useQuery( api.warehouses.importUnloadedQueue.queryOptions({ enabled }), ); const inspectMutation = useMutation( api.warehouses.bulkMarkInspected.mutationOptions(), ); + const storeMutation = useMutation(api.warehouses.store.mutationOptions()); + const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); + const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const [inspectId, setInspectId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [viewItem, setViewItem] = useState(null); + const [historyItem, setHistoryItem] = useState(null); + const [feeItem, setFeeItem] = useState(null); + const [releaseItem, setReleaseItem] = useState(null); + const [deliverItem, setDeliverItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -1744,11 +1854,62 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, }); setSelected(new Set()); + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); } catch (error) { toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) }); } }; + const toInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem => + ({ + id: row.id, + bookingId: row.bookingId, + quantity: 1, + weight: Number(row.weight) || 0, + status: row.currentStatus, + arrivedAt: row.arrivalTime, + unloadedAt: row.arrivalTime, + inspectionStatus: row.inspectionStatus, + releaseDate: row.releaseDate, + releaseOrderReference: row.releaseOrderReference, + deliveredAt: row.deliveredAt, + booking: row.bookingId + ? { + id: row.bookingId, + reference: row.bookingReference ?? row.bookingId, + tradeDirection: 'IMPORT', + lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null, + } + : null, + }) as unknown as WarehouseInventoryItem; + + const runRowAction = async (row: ImportUnloadedItem, label: string, fn: () => Promise) => { + setBusyId(row.id); + try { + await fn(); + toast({ title: label }); + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + } catch (error) { + toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + + const openHandoverDocument = async (row: ImportUnloadedItem) => { + setBusyId(row.id); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadHandoverDocument(row.id); + openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + return ( @@ -1852,9 +2013,92 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {r.currentStatus} - + + + setViewItem(toInventoryItem(r))}> + + + + {r.currentStatus === 'UNLOADED' && ( + + )} + {['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && ( + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && ( + <> + + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && ( + + )} + {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( + + )} + {r.inspectionStatus === 'PASSED' && ( + + )} + + + setFeeItem(toInventoryItem(r))}> + + + + + setHistoryItem(toInventoryItem(r))}> + + + + ))} @@ -1868,6 +2112,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { onClose={() => setInspectId(null)} inventoryId={inspectId} /> + setViewItem(null)} item={viewItem} /> + setHistoryItem(null)} item={historyItem} /> + setFeeItem(null)} + inventoryId={feeItem?.id ?? null} + /> + setReleaseItem(null)} item={releaseItem} /> + setDeliverItem(null)} item={deliverItem} /> ); } @@ -1905,20 +2158,340 @@ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) { ); } -/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ -function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { - const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); - const [tab, setTab] = useState<'IMPORT' | 'EXPORT'>('IMPORT'); +type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH'; +type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking'; +type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking'; - useEffect(() => { - if (opened) setLocation({ warehouseId: '', yardId: '', zoneId: '' }); - }, [opened]); +interface WarehouseQueueTab { + value: TValue; + label: string; + icon: React.ReactNode; + count?: number; +} + +interface WarehouseFlowWorkbenchProps { + direction?: WarehouseFlowDirection; + enabled?: boolean; + onChanged?: () => void; +} + +function WarehouseQueueTabs({ + value, + onChange, + tabs, +}: { + value: TValue; + onChange: (value: TValue) => void; + tabs: WarehouseQueueTab[]; +}) { + return ( + onChange((next as TValue) ?? value)} + variant="pills" + color="edr-green" + keepMounted={false} + classNames={{ list: 'ov-tablist', tab: 'ov-tab' }} + > + + + {tabs.map((tab) => { + const active = value === tab.value; + return ( + + {tab.count} + + ) : undefined + } + > + {tab.label} + + ); + })} + + + + ); +} + +function LocateBookingTab({ enabled }: { enabled: boolean }) { + const [draft, setDraft] = useState({}); + const [applied, setApplied] = useState({}); + const [viewResult, setViewResult] = useState(null); + const hasSearch = Boolean( + applied.bookingReference || + applied.containerNumber || + applied.goodsName || + applied.cargoType || + applied.status, + ); + const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch); + + const normalizeDraft = (): InventoryInquiryFilter => ({ + bookingReference: draft.bookingReference?.trim() || undefined, + containerNumber: draft.containerNumber?.trim() || undefined, + goodsName: draft.goodsName?.trim() || undefined, + cargoType: draft.cargoType?.trim() || undefined, + status: draft.status, + }); + + const runSearch = () => setApplied(normalizeDraft()); + const reset = () => { + setDraft({}); + setApplied({}); + }; return ( - - - + + + setDraft((filter) => ({ ...filter, bookingReference: e.currentTarget.value || undefined }))} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={230} + /> + setDraft((filter) => ({ ...filter, containerNumber: e.currentTarget.value || undefined }))} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={220} + /> + { + const value = e.currentTarget.value || undefined; + setDraft((filter) => ({ ...filter, goodsName: value, cargoType: value })); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') runSearch(); + }} + w={200} + /> +
+ Wagon Booking ID Booking Reference Customer ID @@ -115,6 +116,11 @@ function ExportTrainDetailRows({ {items.map((item: ExportTrainItem) => ( + + + {item.sequenceNo ? `#${item.sequenceNo}` : '-'} {item.wagonNumber ?? ''} + + {item.bookingId.slice(0, 8)} @@ -384,7 +390,7 @@ export default function ExportDjiboutiUnloadingQueuePage() { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx new file mode 100644 index 000000000..bf086f1ce --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportWarehouseFlowPage.tsx @@ -0,0 +1,28 @@ +import { Button, Card } from '@mantine/core'; +import { PackageSearch } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { PageContainer, PageHeader } from '@/components/page'; +import { WarehouseFlowWorkbench } from '@/components/warehouses'; + +export default function ExportWarehouseFlowPage() { + const navigate = useNavigate(); + + return ( + + } onClick={() => navigate('/dashboard/import-warehouse')}> + Import Operations + + } + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx new file mode 100644 index 000000000..2fd2f4f43 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportWarehouseFlowPage.tsx @@ -0,0 +1,28 @@ +import { Button, Card } from '@mantine/core'; +import { Truck } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +import { PageContainer, PageHeader } from '@/components/page'; +import { WarehouseFlowWorkbench } from '@/components/warehouses'; + +export default function ImportWarehouseFlowPage() { + const navigate = useNavigate(); + + return ( + + } onClick={() => navigate('/dashboard/export-warehouse')}> + Export Operations + + } + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx index 057dd0c21..7133c47ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInventoryPage.tsx @@ -1,13 +1,12 @@ import { useMemo, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; -import { PackagePlus, Search } from 'lucide-react'; +import { PackageOpen, Search, Truck } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; import { InventoryWorkbench, - ReceiveInventoryModal, inventoryStatusOptions, } from '@/components/warehouses'; import { @@ -19,13 +18,13 @@ import { import type { InventoryFilter, InventoryStatus } from '@/types/warehouse'; export default function WarehouseInventoryPage() { + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined; const [filter, setFilter] = useState( initialStatus ? { status: initialStatus } : {}, ); const [search, setSearch] = useState(''); - const [modalOpen, setModalOpen] = useState(false); const [debouncedSearch] = useDebouncedValue(search, 300); const queryFilter = useMemo( @@ -57,9 +56,18 @@ export default function WarehouseInventoryPage() { title="Warehouse Inventory" subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle." action={ - + + + + } /> @@ -126,8 +134,6 @@ export default function WarehouseInventoryPage() { - - setModalOpen(false)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index a9a98a80d..83909c415 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -137,6 +137,10 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), { responseType: 'blob', }), + downloadHandoverDocument: (id: string) => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), { + responseType: 'blob', + }), deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index e3285b6c1..bca62a21c 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -517,6 +517,9 @@ export interface ExportTrainItem { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; itemType: 'CONTAINER' | 'CARGO'; itemId: string | null; inventoryId: string | null; @@ -566,6 +569,9 @@ export interface ImportUnloadedItem { pickupOption: string; lastMileRequested: boolean; currentStatus: string; + releaseDate: string | null; + releaseOrderReference: string | null; + deliveredAt: string | null; } export interface ImportTrainItem { @@ -573,6 +579,9 @@ export interface ImportTrainItem { bookingReference: string | null; customerId: string | null; customerName: string | null; + wagonNumber: string | null; + sequenceNo: number | null; + allocatedWeightTons: number | null; containerNumber: string | null; cargoType: string | null; weight: number | null; diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index 341d2c7c3..6e7326189 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; import { defineConfig } from "vitest/config"; -import { loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -11,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const streamBrowserifyPath = require.resolve("stream-browserify"); -export default defineConfig(({ mode }) => { +export default defineConfig(() => { return { plugins: [react(), tailwindcss()], resolve: { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index d115369c3..3a558c5cb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,10 +1,12 @@ import { Box, Group, Text } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; -import { CreditCard, Download } from "lucide-react"; +import { CheckCircle2, CreditCard, Download } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { toast } from "sonner"; import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import type { Freight } from "@edr/types"; @@ -49,6 +51,19 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) window.location.href = redirectUrl; }, }); + const approveDeliveryMutation = useMutation({ + mutationFn: () => bookingsService.approveDelivery(booking.id), + onSuccess: (data) => { + toast.success(`Delivery approved as ${data.signerDisplayName}`); + }, + onError: (error) => { + toast.error( + error instanceof Error + ? error.message + : "Could not approve delivery. Please try again.", + ); + }, + }); const pricing = booking.pricingBreakdown; // A general contract is paid once it's FULLY_EXECUTED (signed) — it never @@ -62,6 +77,10 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) : status === "SELECTED_FOR_BATCH"); const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; + const canApproveDelivery = + booking.tradeDirection === "IMPORT" && + !isNegative(status) && + !["DRAFT", "DRAFT_DOCUMENTS_PENDING"].includes(status); const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isClearance = [ "AWAITING_DOCUMENTS", @@ -81,14 +100,30 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } - label="Pay now" - onClick={() => setPayModalOpen(true)} - /> + (canPay || canApproveDelivery) && ( + + {canApproveDelivery && ( + } + label={ + approveDeliveryMutation.isPending + ? "Approving..." + : "Approve Delivery" + } + disabled={approveDeliveryMutation.isPending} + onClick={() => approveDeliveryMutation.mutate()} + /> + )} + {canPay && !showCountdown && ( + } + label="Pay now" + onClick={() => setPayModalOpen(true)} + /> + )} + ) } menuActions={{ diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 6a8c76795..2d0e79a9a 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -72,6 +72,13 @@ export interface SignContractPayload { consentText?: string; } +export interface ApproveDeliveryResponse { + bookingId: string; + inventoryId: string; + approvedAt: string; + signerDisplayName: string; +} + export interface BookingListFilter { status?: string; /** Comma-separated statuses (overrides `status` when set). */ @@ -242,6 +249,13 @@ export const bookingsService = { return data.data ?? data; }, + approveDelivery: async (id: string): Promise => { + const { data } = await client.post( + `/api/warehouse-inventory/bookings/${id}/approve-delivery`, + ); + return data.data ?? data; + }, + getBookableSchedules: async ( query: Freight.BookableSchedulesQuery = {}, ): Promise => { From b3ec6e4235b4154b606fc248750e9f2ef0d5ff36 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 29 Jun 2026 13:51:05 +0300 Subject: [PATCH 02/20] refactor: ( iam ) user real uuid --- .../src/seed/edr-passenger.seed.ts | 2 +- .../seed/passenger-permissions.registry.ts | 40 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts index 8413cadb2..cd178139a 100644 --- a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts +++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts @@ -11,7 +11,7 @@ export type PassengerSeedRole = { }; export const EDR_PASSENGER_APPLICATION = { - id: 'd2000001-0001-4000-8000-000000000001', + id: '921cd1a4-98a7-4601-bfb1-6fe19518be52', key: 'edr_passenger_app', name: { am: 'EDR Passenger App', diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index 9b06c812b..d6a804b3a 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -15,26 +15,26 @@ const perm = (id: string, key: string, en: string): PassengerPermissionSeed => ( }); export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ - perm('c1000001-0001-4000-8000-000000000001', 'edr_passenger_app:bookings:view', 'View bookings'), - perm('c1000001-0001-4000-8000-000000000002', 'edr_passenger_app:bookings:manage', 'Manage bookings'), - perm('c1000001-0001-4000-8000-000000000003', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), - perm('c1000001-0001-4000-8000-000000000004', 'edr_passenger_app:passengers:view', 'View passengers'), - perm('c1000001-0001-4000-8000-000000000005', 'edr_passenger_app:passengers:manage', 'Manage passengers'), - perm('c1000001-0001-4000-8000-000000000006', 'edr_passenger_app:tickets:view', 'View tickets'), - perm('c1000001-0001-4000-8000-000000000007', 'edr_passenger_app:tickets:manage', 'Manage tickets'), - perm('c1000001-0001-4000-8000-000000000008', 'edr_passenger_app:payments:view_all', 'View all payments'), - perm('c1000001-0001-4000-8000-000000000009', 'edr_passenger_app:payments:refund', 'Refund payments'), - perm('c1000001-0001-4000-8000-00000000000a', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), - perm('c1000001-0001-4000-8000-00000000000b', 'edr_passenger_app:reports:view', 'View reports'), - perm('c1000001-0001-4000-8000-00000000000c', 'edr_passenger_app:fraud:view', 'View fraud alerts'), - perm('c1000001-0001-4000-8000-00000000000d', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'), - perm('c1000001-0001-4000-8000-00000000000e', 'edr_passenger_app:audit:view', 'View audit logs'), - perm('c1000001-0001-4000-8000-00000000000f', 'edr_passenger_app:agents:view', 'View agents'), - perm('c1000001-0001-4000-8000-000000000010', 'edr_passenger_app:agents:manage', 'Manage agents'), - perm('c1000001-0001-4000-8000-000000000011', 'edr_passenger_app:currencies:manage', 'Manage currencies'), - perm('c1000001-0001-4000-8000-000000000012', 'edr_passenger_app:notifications:send', 'Send notifications'), - perm('c1000001-0001-4000-8000-000000000013', 'edr_passenger_app:dashboard:view', 'View dashboard'), - perm('c1000001-0001-4000-8000-000000000014', 'edr_passenger_app:admin', 'Full admin access'), + perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'), + perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'), + perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), + perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'), + perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'), + perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'), + perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'), + perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'), + perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'), + perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'), + perm('bf6cbd48-7a4a-46ec-91f3-0a23245da0a4', 'edr_passenger_app:reports:view', 'View reports'), + perm('53b49280-a272-4688-8345-e14fbedce50e', 'edr_passenger_app:fraud:view', 'View fraud alerts'), + perm('834f576c-afe2-41f4-9e6e-5f87b155fbf4', 'edr_passenger_app:fraud:manage', 'Manage fraud rules'), + perm('988b680d-df5a-4e79-9c01-c9441753ce1a', 'edr_passenger_app:audit:view', 'View audit logs'), + perm('38736897-545f-47dc-9531-7fc381478a1f', 'edr_passenger_app:agents:view', 'View agents'), + perm('30ada94a-cd15-4588-97bd-cd2f9d33ad7d', 'edr_passenger_app:agents:manage', 'Manage agents'), + perm('75b5ff62-a8e4-4331-b6e6-d53e1456d10e', 'edr_passenger_app:currencies:manage', 'Manage currencies'), + perm('4a47da9b-cf6e-4240-aff8-aadf01641c54', 'edr_passenger_app:notifications:send', 'Send notifications'), + perm('bfe3428f-8b85-4a36-87c6-33063b084bf3', 'edr_passenger_app:dashboard:view', 'View dashboard'), + perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'), ]; export const PASSENGER_PERMISSION_KEYS = PASSENGER_PERMISSIONS.map((p) => p.key); From 835facc65a0f039c7cade84e7ce5aa37bf72be01 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 11:12:51 +0000 Subject: [PATCH 03/20] fix --- .../first-mile/entities/first-mile.entity.ts | 3 +++ .../last-mile/entities/last-mile.entity.ts | 3 +++ .../src/pages/operations/FirstMilePage.tsx | 15 ++++++++++++++- .../src/pages/operations/LastMilePage.tsx | 15 ++++++++++++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 17ed1c101..513aaf98e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 3bfe2cd19..6c8c9d1ca 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -34,6 +34,9 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ type: 'boolean', default: false }) + isPostPaymentCompleted!: boolean; + @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 9514d8c5c..e17ea46c7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -314,6 +314,7 @@ const FirstMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -529,6 +530,7 @@ const FirstMilePage = () => { }; const matchesFilter = (r: FirstMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -566,7 +568,7 @@ const FirstMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -887,6 +889,17 @@ const FirstMilePage = () => { ); })} + diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 894a5a091..9798a90bf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -298,6 +298,7 @@ const LastMilePage = () => { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); + const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [assignOpen, setAssignOpen] = useState(false); @@ -508,6 +509,7 @@ const LastMilePage = () => { ); const matchesFilter = (r: LastMileRecord) => { + if (filterPostPaymentPending && r.isPostPaymentCompleted) return false; switch (statusFilter) { case "ALL": return true; case "ASSIGNED": return isAssigned(r); @@ -545,7 +547,7 @@ const LastMilePage = () => { .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [records, search, statusFilter]); + }, [records, search, statusFilter, filterPostPaymentPending]); const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); const pagedRecords = useMemo(() => { @@ -866,6 +868,17 @@ const LastMilePage = () => { ); })} + From 5c36c09daeef81919df457cf0deca2ac1daeaec6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:01:19 +0000 Subject: [PATCH 04/20] fix --- .../modules/first-mile/first-mile.service.ts | 21 +++++++++++------- .../modules/last-mile/last-mile.service.ts | 22 +++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) 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 a06f9eb87..48aa23084 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 @@ -4,6 +4,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; @@ -37,6 +38,7 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} /** @@ -251,16 +253,19 @@ export class FirstMileService { const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.firstMilePickupAddress, - destinationYard: booking?.originYard?.label, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + + (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 2e8fb2463..998d28c68 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -4,6 +4,7 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; @@ -32,12 +33,12 @@ export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( - private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly notificationsService: NotificationsService, + private readonly smsClient: SmsClientService, ) {} async acceptBooking(bookingReference: string): Promise { @@ -185,16 +186,19 @@ export class LastMileService { }; const booking = (record as LastMile & { booking?: BookingWithYards }).booking; - await this.notificationsService.notifyDriverVehicleAssignment({ - driverPhone: driver.phoneNumber, - driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), - vehiclePlateNumber: vehicle.plateNumber ?? vehicleId, - bookingReference: booking?.reference ?? record.bookingId, - pickupAddress: booking?.destinationYard?.label, - destinationYard: booking?.lastMileDeliveryAddress, + const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const message = + `Dear ${driverName}, you have been assigned to a last-mile delivery. ` + + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + + (booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') + + (booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : ''); + + void this.smsClient.sendSms({ + to: driver.phoneNumber, + message, }); - this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); } catch (err) { this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); } From 1491db304b0cd0fd218d31e67d52fdd9b8cd3fc8 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 29 Jun 2026 15:04:01 +0300 Subject: [PATCH 05/20] payemnt --- .../modules/payment/payment-client.service.ts | 3 +- .../src/modules/payment/payment.service.ts | 54 +++++++++++-------- .../backoffice/src/constants/apiConfig.ts | 4 +- .../portal/src/constants/apiConfig.ts | 4 +- .../new-contract-form/step2-service-type.tsx | 2 +- 5 files changed, 40 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index bcfa643b6..68ed249a5 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -19,7 +19,8 @@ export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( // process.env.PAYMENT_API_URL ?? - "https://paymentcallback.triaplc.com" + // "https://paymentcallback.triaplc.com" + "http://localhost:3003" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index dbd635d86..247b6cf62 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -398,34 +398,46 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - } - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId: intent.id, + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId:event.intentId, bookingId: event.referenceId, providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, }); + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; - } + // console.log(`Received payment event: ${JSON.stringify(event)}`); + // if (event.eventType === "payment.succeeded") { + // console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`); + // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + // if (!intent) { + // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + // } + // console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`); + // const { alreadyFinalized } = await this.finalizePaymentSuccess({ + // intentId: intent.id, + // bookingId: event.referenceId, + // providerTxnId: event.providerTxnId, + // paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + // }); + // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + // return { processed: true, alreadyFinalized }; + // } - if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - if (!intent) { - return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - } - await this.markPaymentFailed({ - intentId: intent.id, - failureCode: event.failureCode, - failureMessage: event.failureMessage, - }); - return { processed: true }; - } + // if (event.eventType === "payment.failed") { + // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + // if (!intent) { + // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + // } + // await this.markPaymentFailed({ + // intentId: intent.id, + // failureCode: event.failureCode, + // failureMessage: event.failureMessage, + // }); + // return { processed: true }; + // } - return { processed: false, reason: `Unknown event type: ${event.eventType}` }; + // return { processed: false, reason: `Unknown event type: ${event.eventType}` }; } private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 1b070d87d..a24cb4a6d 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index ff8b847b9..3d9bd4ec1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -6,7 +6,7 @@ import { FileCheck2, FileText, Info, - PackageCheck, + // PackageCheck, ShieldCheck, TrainFront, Truck, From 079351f7c467affb4d3f45ef68309a67742d34ce Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 29 Jun 2026 15:05:46 +0300 Subject: [PATCH 06/20] fix: migration problem --- .../20260101000000_add_configurable_fare_system/migration.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql index aa6cd855a..351c44637 100644 --- a/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql +++ b/apps/edr-passenger-api/prisma/migrations/20260101000000_add_configurable_fare_system/migration.sql @@ -113,5 +113,5 @@ CREATE TABLE "system_features" ( ); -- Insert the configurable fares feature flag -INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config") -VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}'); \ No newline at end of file +INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at") +VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP); \ No newline at end of file From b688a765503fbb4d73189f41d1481348cc8e26cd Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 29 Jun 2026 15:05:50 +0300 Subject: [PATCH 07/20] payemnt --- .../src/modules/payment/payment.service.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 247b6cf62..1d56cb1cf 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -289,20 +289,21 @@ export class PaymentService { providerTxnId?: string; paidAt?: Date; }): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; + // const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + // if (!intent) throw new NotFoundException("PaymentIntent not found"); + // if (intent.status === "success") return { alreadyFinalized: true }; const paidAt = input.paidAt ?? new Date(); // Every booking is a real shipment now (contracts are a separate aggregate), // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { - await mg.update( - PaymentEntity, - { id: intent.id }, - { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - ); + // await mg.update( + // PaymentEntity, + // // { id: intent.id }, + // {id:input.intentId}, + // { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, + // ); await mg.update( Booking, { id: input.bookingId }, From 09e1429c38f83981de2211fc0d992ecc4f733731 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 29 Jun 2026 15:08:12 +0300 Subject: [PATCH 08/20] payemnt --- .../src/modules/payment/payment-client.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 68ed249a5..4c2ebe971 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -19,8 +19,8 @@ export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( // process.env.PAYMENT_API_URL ?? - // "https://paymentcallback.triaplc.com" - "http://localhost:3003" + "https://paymentcallback.triaplc.com" + // "http://localhost:3003" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; From b22ab739dbab12bfbb1eb10528220c065a437519 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:08:16 +0000 Subject: [PATCH 09/20] fix --- .../edr-freight-api/src/modules/first-mile/first-mile.service.ts | 1 - apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts | 1 - 2 files changed, 2 deletions(-) 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 48aa23084..caa6e2b1d 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 @@ -37,7 +37,6 @@ export class FirstMileService { private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, private readonly smsClient: SmsClientService, ) {} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 998d28c68..b1c984326 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -37,7 +37,6 @@ export class LastMileService { private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, - private readonly notificationsService: NotificationsService, private readonly smsClient: SmsClientService, ) {} From a83a686f37bbe55bb1ff5bf47027e75faca603cf Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 29 Jun 2026 15:09:19 +0300 Subject: [PATCH 10/20] payemnt --- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 4 ++-- apps/edr-freight-web/portal/src/constants/apiConfig.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 7a7604cc7..1217b8762 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index a24cb4a6d..1b070d87d 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From a64e53132446cc6b5f5cb90c4fc4ec787c3363b8 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:15:30 +0000 Subject: [PATCH 11/20] fix --- .../edr-freight-api/src/modules/first-mile/first-mile.service.ts | 1 - apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts | 1 - 2 files changed, 2 deletions(-) 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 caa6e2b1d..45c2658db 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 @@ -3,7 +3,6 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index b1c984326..77a8a2fea 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -3,7 +3,6 @@ import { FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; -import { NotificationsService } from '../notifications/notifications.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; From 6685a3ba760fc64ed1557772a4e76ab3d7c8d3a1 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 29 Jun 2026 15:17:51 +0300 Subject: [PATCH 12/20] payemnt --- apps/edr-freight-api/src/modules/payment/payment.service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1d56cb1cf..9f216bc60 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -163,6 +163,10 @@ export class PaymentService { failureUrl: 'https://edrfreight.triaplc.com/payment/failure', }); + await this.datasource.getRepository(Booking).update( + { id: dto.bookingId }, + { paymentStatus: "PAID", status: "PAID" }, + ); const intent = await this.syncIntentProjection(booking.id, booking, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { From fc95d48d28ef977b728cdc4aae328514052735a2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 12:25:36 +0000 Subject: [PATCH 13/20] fix --- apps/edr-freight-api/src/modules/payment/payment.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1d56cb1cf..e9f74f741 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -293,8 +293,6 @@ export class PaymentService { // if (!intent) throw new NotFoundException("PaymentIntent not found"); // if (intent.status === "success") return { alreadyFinalized: true }; - const paidAt = input.paidAt ?? new Date(); - // Every booking is a real shipment now (contracts are a separate aggregate), // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { From 98959c03ccf17e77669a65f0c7522c5034f5e2b7 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 29 Jun 2026 16:03:00 +0300 Subject: [PATCH 14/20] Fare engine issue resolution --- .../src/modules/fare-engine/fare-engine.dto.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts index 5652ae91d..eb8010cd6 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.dto.ts @@ -4,14 +4,14 @@ import { Type } from 'class-transformer'; import { Currency } from '@prisma/client'; // Nationality → home currency mapping (keys are uppercase for case-insensitive lookup) -export const NATIONALITY_CURRENCY_MAP: Record = { - ETHIOPIAN: Currency.ETB, - DJIBOUTIAN: Currency.DJF, +export const NATIONALITY_CURRENCY_MAP: Record = { + ETHIOPIAN: 'ETB', + DJIBOUTIAN: 'DJF', }; export function resolveCurrencyFromNationality(nationality?: string): Currency { - if (!nationality) return Currency.ETB; - return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD; + if (!nationality) return 'ETB' as Currency; + return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency; } export class FareCalculateDto { From 999753b84248c9f1ebba27c83f48194b533f08d5 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Mon, 29 Jun 2026 16:07:44 +0300 Subject: [PATCH 15/20] Update deploy.yml --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 78c15cba1..4dbb0ddd5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,7 +170,7 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" - name: Deploy ${{ matrix.service }} run: | From 2d4608aded61fe078c0076d28bff132cf2504b01 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Mon, 29 Jun 2026 16:21:46 +0300 Subject: [PATCH 16/20] Update deploy.yml --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4dbb0ddd5..78c15cba1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,7 +170,7 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail - docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ matrix.service }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" - name: Deploy ${{ matrix.service }} run: | From 0d8ed45256b62749dae9bee08037071f77d31537 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 13:24:01 +0000 Subject: [PATCH 17/20] fix --- ...1719667261000-AddPostPaymentCompletedColumn.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts new file mode 100644 index 000000000..b7846bac9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface { + name = 'AddPostPaymentCompletedColumn1719667261000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + } +} From f2826f2b9b05473888d763b364da71ee883f72ed Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 13:49:01 +0000 Subject: [PATCH 18/20] fix --- apps/edr-freight-api/Dockerfile | 2 +- apps/edr-freight-api/package.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b0850737b..a9965c74a 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -34,4 +34,4 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 -CMD ["node", "dist/main.js"] +CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"] diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 27737c84c..df84d0c35 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -29,7 +29,8 @@ "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" + "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" }, "dependencies": { "@edr/api-common": "workspace:*", From 8b6b6ec7375f90f9a8b43b949056568762440dc8 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 13:52:54 +0000 Subject: [PATCH 19/20] fix --- ...667261000-AddPostPaymentCompletedColumn.ts | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts index b7846bac9..c755d6356 100644 --- a/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts +++ b/apps/edr-freight-api/src/migrations/1719667261000-AddPostPaymentCompletedColumn.ts @@ -1,15 +1,51 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface { name = 'AddPostPaymentCompletedColumn1719667261000'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); - await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`); + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.first_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } + + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.last_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); - await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`); + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + } + + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + } } } From 7dba546254a51bf25342969dd7f0a269dcf63ca5 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 29 Jun 2026 13:59:51 +0000 Subject: [PATCH 20/20] fix --- .../src/modules/first-mile/entities/first-mile.entity.ts | 4 ++-- .../src/modules/last-mile/entities/last-mile.entity.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 513aaf98e..e810d23cc 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -34,8 +34,8 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; - @Column({ type: 'boolean', default: false }) - isPostPaymentCompleted!: boolean; + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 6c8c9d1ca..ad4b789f4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -34,8 +34,8 @@ export class LastMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; - @Column({ type: 'boolean', default: false }) - isPostPaymentCompleted!: boolean; + // @Column({ type: 'boolean', default: false }) + // isPostPaymentCompleted!: boolean; @Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) estimatedKm?: number | null;