Merge pull request #318 from Tria-plc/Interchange

Interchange
This commit is contained in:
Hagernesh Tadesse
2026-06-27 13:55:58 +03:00
committed by GitHub
22 changed files with 1543 additions and 178 deletions

View File

@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
# Freight org + staff (bookings / rule-engine IAM) # Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true SEED_FREIGHT_STAFF=true
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
# MinIO (used by @tria-plc/iamapi-common for file storage) # MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost

View File

@@ -52,6 +52,7 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
@@ -145,6 +146,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
Batch7TestDataSeeder, Batch7TestDataSeeder,
Batch8TestDataSeeder, Batch8TestDataSeeder,
WarehouseDemoSeeder, WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
@@ -161,6 +163,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { } ) { }
@@ -179,6 +182,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch7TestDataSeeder.run(); await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run(); await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run(); await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // 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. // Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,

View File

@@ -265,12 +265,12 @@ export class InterchangeDocumentsService {
) )
SELECT a.booking_id AS "bookingId", SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference", a.reference AS "bookingReference",
'CONTAINER' AS "itemType", 'CONTAINER'::varchar AS "itemType",
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId", COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
NULL AS "bookingCargoId", NULL::uuid AS "bookingCargoId",
COALESCE(c.container_number, bc.container_number) AS "containerNumber", COALESCE(c.container_number, bc.container_number) AS "containerNumber",
c.seal_number AS "sealNumber", c.seal_number AS "sealNumber",
NULL AS "cargoId", NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType", a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription", a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight", COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
@@ -288,12 +288,12 @@ export class InterchangeDocumentsService {
UNION ALL UNION ALL
SELECT a.booking_id AS "bookingId", SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference", a.reference AS "bookingReference",
'CONTAINER' AS "itemType", 'CONTAINER'::varchar AS "itemType",
bc.id AS "bookingContainerId", bc.id AS "bookingContainerId",
NULL AS "bookingCargoId", NULL::uuid AS "bookingCargoId",
bc.container_number AS "containerNumber", bc.container_number AS "containerNumber",
NULL AS "sealNumber", NULL::varchar AS "sealNumber",
NULL AS "cargoId", NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType", a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription", a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight", COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
@@ -314,11 +314,11 @@ export class InterchangeDocumentsService {
UNION ALL UNION ALL
SELECT a.booking_id AS "bookingId", SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference", a.reference AS "bookingReference",
'CARGO' AS "itemType", 'CARGO'::varchar AS "itemType",
NULL AS "bookingContainerId", NULL::uuid AS "bookingContainerId",
cg.id AS "bookingCargoId", cg.id AS "bookingCargoId",
NULL AS "containerNumber", NULL::varchar AS "containerNumber",
NULL AS "sealNumber", NULL::varchar AS "sealNumber",
cg.id AS "cargoId", cg.id AS "cargoId",
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType", COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription", COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
@@ -337,12 +337,12 @@ export class InterchangeDocumentsService {
UNION ALL UNION ALL
SELECT a.booking_id AS "bookingId", SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference", a.reference AS "bookingReference",
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", (CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
NULL AS "bookingContainerId", NULL::uuid AS "bookingContainerId",
NULL AS "bookingCargoId", NULL::uuid AS "bookingCargoId",
NULL AS "containerNumber", NULL::varchar AS "containerNumber",
NULL AS "sealNumber", NULL::varchar AS "sealNumber",
NULL AS "cargoId", NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType", a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription", a.cargo_free_text AS "cargoDescription",
a.cargo_total_weight_vgm AS "weight", a.cargo_total_weight_vgm AS "weight",

View File

@@ -22,6 +22,11 @@ export class TruckEntranceDto {
@IsString() @IsString()
tin?: string; tin?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customerPhone?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()
truckPlateNumber!: string; truckPlateNumber!: string;

View File

@@ -33,4 +33,14 @@ export class PayInvoiceBodyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
reference?: string; reference?: string;
@ApiPropertyOptional({ description: 'Pickup driver name to notify after payment' })
@IsOptional()
@IsString()
driverName?: string;
@ApiPropertyOptional({ description: 'Pickup driver phone to notify after payment' })
@IsOptional()
@IsString()
driverPhone?: string;
} }

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator'; import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */ /** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto { export class ReleaseOrderDto {
@@ -17,4 +17,77 @@ export class ReleaseOrderDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
performedBy?: string; performedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
bookingId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
customerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
trailerPlateNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverLicense?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
driverPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
truckType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
gateInTime?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
tareWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
grossWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
netWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
gateOutTime?: string;
} }

View File

@@ -329,7 +329,7 @@ export class SchedulingReadFacade {
} }
if (filter.destination) { if (filter.destination) {
params.push(`%${filter.destination}%`); params.push(`%${filter.destination}%`);
where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`); where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`);
} }
if (filter.dateFrom) { if (filter.dateFrom) {
params.push(filter.dateFrom); params.push(filter.dateFrom);
@@ -351,7 +351,7 @@ export class SchedulingReadFacade {
ts.train_number AS "trainNumber", ts.train_number AS "trainNumber",
oy.code AS "origin", oy.code AS "origin",
dy.code AS "destination", dy.code AS "destination",
dy.name AS "destinationName", dy.label AS "destinationName",
oy.country AS "originCountry", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",
ts.scheduled_departure_date AS "departureTime", ts.scheduled_departure_date AS "departureTime",

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
@@ -6,6 +6,7 @@ import { Cargo } from '../cargoes/entities/cargoes.entity';
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service'; import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -191,6 +192,13 @@ export interface EligibleBookingRow {
reference: string; reference: string;
customerId: string | null; customerId: string | null;
customer: string | null; customer: string | null;
customerTin: string | null;
customerPhone: string | null;
containerNumber: string | null;
containerQuantity: number | null;
containerPackagingType: string | null;
cargoDescription: string | null;
lastMileRequested: boolean;
direction: string; direction: string;
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
@@ -205,6 +213,10 @@ export interface EligibleBookingRow {
firstMileVehicleId: string | null; firstMileVehicleId: string | null;
firstMileTruckPlateNumber: string | null; firstMileTruckPlateNumber: string | null;
firstMileTrailerPlateNumber: string | null; firstMileTrailerPlateNumber: string | null;
firstMileDriverName: string | null;
firstMileDriverPhone: string | null;
firstMileDriverLicenseNumber: string | null;
firstMileTruckType: string | null;
} }
export interface BulkReceiveResult { export interface BulkReceiveResult {
@@ -289,6 +301,8 @@ export interface ImportUnloadedRow {
@Injectable() @Injectable()
export class WarehouseInventoryService { export class WarehouseInventoryService {
private readonly logger = new Logger(WarehouseInventoryService.name);
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly inventoryRepository: WarehouseInventoryRepository, private readonly inventoryRepository: WarehouseInventoryRepository,
@@ -301,6 +315,7 @@ export class WarehouseInventoryService {
private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly releaseDocuments: WarehouseReleaseDocumentService,
private readonly interchangeDocuments: InterchangeDocumentsService, private readonly interchangeDocuments: InterchangeDocumentsService,
private readonly lastMileService: LastMileService, private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService,
) {} ) {}
/** /**
@@ -644,12 +659,20 @@ export class WarehouseInventoryService {
b.reference AS "reference", b.reference AS "reference",
b.company_id AS "customerId", b.company_id AS "customerId",
company.name AS "customer", company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
bc.container_numbers AS "containerNumber",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
oy.code AS "origin", oy.code AS "origin",
dy.code AS "destination", dy.code AS "destination",
oy.country AS "originCountry", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",
b.freight_type AS "freightType", b.freight_type AS "freightType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
b.cargo_total_weight_vgm AS "weight", b.cargo_total_weight_vgm AS "weight",
b.payment_status AS "paymentStatus", b.payment_status AS "paymentStatus",
b.status AS "status", b.status AS "status",
@@ -659,7 +682,14 @@ export class WarehouseInventoryService {
fm.status AS "firstMileStatus", fm.status AS "firstMileStatus",
fm.vehicle_id AS "firstMileVehicleId", fm.vehicle_id AS "firstMileVehicleId",
v.plate_number AS "firstMileTruckPlateNumber", v.plate_number AS "firstMileTruckPlateNumber",
v.trailer_plate_no AS "firstMileTrailerPlateNumber" v.trailer_plate_no AS "firstMileTrailerPlateNumber",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
v.assigned_driver_name
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
FROM freight.bookings b FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -667,6 +697,22 @@ export class WarehouseInventoryService {
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
SUM(booking_container.quantity)::int AS container_quantity,
CASE
WHEN COUNT(booking_container.id) = 0 THEN NULL
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
ELSE 'OTHER_CONTAINER'
END AS container_packaging_type
FROM freight.booking_container booking_container
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
) bc ON true
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
FROM freight.first_mile first_mile FROM freight.first_mile first_mile
@@ -675,6 +721,7 @@ export class WarehouseInventoryService {
LIMIT 1 LIMIT 1
) fm ON true ) fm ON true
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
WHERE b.deleted_at IS NULL WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID' AND b.payment_status = 'PAID'
AND inv.id IS NULL AND inv.id IS NULL
@@ -695,7 +742,6 @@ export class WarehouseInventoryService {
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> { async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
this.assertTruckEntrance(dto.truckEntrance);
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, { await this.validateLocation(manager, {
@@ -711,23 +757,61 @@ export class WarehouseInventoryService {
}; };
const [booking] = await manager.query( const [booking] = await manager.query(
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", `SELECT b.reference AS "reference",
b.payment_status AS "paymentStatus",
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
bc.container_numbers AS "containerNumber",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
oy.country AS "originCountry", dy.country AS "destinationCountry", oy.country AS "originCountry", dy.country AS "destinationCountry",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
fm.id AS "firstMileRequestId", fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus" fm.status AS "firstMileStatus",
v.plate_number AS "firstMileTruckPlateNumber",
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
v.assigned_driver_name
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
FROM freight.bookings b FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
SUM(booking_container.quantity)::int AS container_quantity,
CASE
WHEN COUNT(booking_container.id) = 0 THEN NULL
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
ELSE 'OTHER_CONTAINER'
END AS container_packaging_type
FROM freight.booking_container booking_container
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
) bc ON true
LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
FROM freight.first_mile first_mile FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC ORDER BY first_mile.created_at DESC
LIMIT 1 LIMIT 1
) fm ON true ) fm ON true
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[bookingId], [bookingId],
); );
@@ -758,11 +842,13 @@ export class WarehouseInventoryService {
const now = new Date(); const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking);
this.assertTruckEntrance(truckEntrance);
const receiveNote = this.buildReceiveNote({ const receiveNote = this.buildReceiveNote({
grnNumber, grnNumber,
direction: dto.direction, direction: dto.direction,
notes: `Bulk received (${dto.direction})`, notes: `Bulk received (${dto.direction})`,
truckEntrance: dto.truckEntrance, truckEntrance,
}); });
const saved = await manager.getRepository(WarehouseInventory).save( const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({ manager.getRepository(WarehouseInventory).create({
@@ -783,12 +869,21 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RECEIVED', activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id, inventoryId: saved.id,
warehouseId: dto.warehouseId, warehouseId: dto.warehouseId,
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`, description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`,
performedBy: dto.performedBy, performedBy: dto.performedBy,
}, },
manager, manager,
); );
await this.notifyOwnerInventoryReceived({
phone: truckEntrance.customerPhone,
ownerName: truckEntrance.ownerName,
bookingReference: truckEntrance.edrDigitalBookingId,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
});
result.receivedCount += 1; result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
} }
@@ -1166,7 +1261,7 @@ export class WarehouseInventoryService {
oy.country AS "originCountry", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",
dy.code AS "destinationCode", dy.code AS "destinationCode",
dy.name AS "destinationName" dy.label AS "destinationName"
FROM freight.train_schedules ts FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -1307,6 +1402,20 @@ export class WarehouseInventoryService {
} }
const currentStatus = item.inventoryStatus ?? item.bookingStatus; const currentStatus = item.inventoryStatus ?? item.bookingStatus;
if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') {
seenInventory.add(item.inventoryId);
result.unloadedCount += 1;
result.results.push({
bookingId: item.bookingId,
itemType: item.itemType,
itemId: item.itemId,
inventoryId: item.inventoryId,
containerNumber: item.containerNumber,
status: 'UNLOADED_AT_DJIBOUTI_PORT',
message: 'Already unloaded at Djibouti Port',
});
continue;
}
if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) { if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`); skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
continue; continue;
@@ -1483,7 +1592,6 @@ export class WarehouseInventoryService {
} }
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> { async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
this.assertTruckEntrance(dto.truckEntrance);
const weight = Number(dto.weight) || 0; const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0; const volume = Number(dto.volume) || 0;
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
@@ -1495,6 +1603,10 @@ export class WarehouseInventoryService {
if (dto.bookingId) { if (dto.bookingId) {
await this.assertBookingExists(manager, dto.bookingId); await this.assertBookingExists(manager, dto.bookingId);
} }
const truckEntrance = dto.bookingId
? this.mergeSystemTruckEntrance(dto.truckEntrance, await this.getBookingTruckEntranceSource(manager, dto.bookingId))
: dto.truckEntrance;
this.assertTruckEntrance(truckEntrance);
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
this.assertCapacity('Yard', yard, weight, volume, containerCount); this.assertCapacity('Yard', yard, weight, volume, containerCount);
@@ -1505,7 +1617,7 @@ export class WarehouseInventoryService {
const receiveNote = this.buildReceiveNote({ const receiveNote = this.buildReceiveNote({
grnNumber, grnNumber,
notes: dto.notes?.trim() || 'Single booking received', notes: dto.notes?.trim() || 'Single booking received',
truckEntrance: dto.truckEntrance, truckEntrance,
}); });
const saved = await manager.getRepository(WarehouseInventory).save( const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({ manager.getRepository(WarehouseInventory).create({
@@ -1529,14 +1641,23 @@ export class WarehouseInventoryService {
await this.activityLog.record( await this.activityLog.record(
{ {
activityType: 'INVENTORY_RECEIVED', activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id, inventoryId: saved.id,
warehouseId: dto.warehouseId, warehouseId: dto.warehouseId,
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`, description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
performedBy: dto.performedBy, performedBy: dto.performedBy,
}, },
manager, manager,
); );
await this.notifyOwnerInventoryReceived({
phone: truckEntrance.customerPhone,
ownerName: truckEntrance.ownerName,
bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId,
grnNumber,
direction: bookingDirection,
warehouseId: dto.warehouseId,
});
return saved.id; return saved.id;
}); });
@@ -1776,11 +1897,13 @@ export class WarehouseInventoryService {
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
const reference = dto.reference?.trim() || null; const reference = dto.reference?.trim() || null;
const exitInspectionNote = this.buildExitInspectionNote(dto);
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, { await manager.getRepository(WarehouseInventory).update(id, {
releaseDate, releaseDate,
releaseOrderReference: reference, releaseOrderReference: reference,
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
}); });
await this.activityLog.record( await this.activityLog.record(
{ {
@@ -1807,6 +1930,7 @@ export class WarehouseInventoryService {
inv.quantity, inv.quantity,
inv.weight, inv.weight,
inv.status, inv.status,
inv.notes,
b.id AS "bookingId", b.id AS "bookingId",
b.reference AS "bookingReference", b.reference AS "bookingReference",
b.status AS "bookingStatus", b.status AS "bookingStatus",
@@ -1867,6 +1991,7 @@ export class WarehouseInventoryService {
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row?.status ?? null, inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
}); });
return { return {
@@ -2372,6 +2497,7 @@ export class WarehouseInventoryService {
zone: string | null; zone: string | null;
inventoryStatus: string | null; inventoryStatus: string | null;
clearanceStatus: string; clearanceStatus: string;
exitInspectionSummary?: string | null;
}): string { }): string {
const esc = (value: unknown) => const esc = (value: unknown) =>
String(value ?? '-') String(value ?? '-')
@@ -2402,6 +2528,7 @@ export class WarehouseInventoryService {
['Zone', data.zone], ['Zone', data.zone],
['Inventory Status', data.inventoryStatus], ['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus], ['Clearance Status', data.clearanceStatus],
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
]; ];
return `<!doctype html> return `<!doctype html>
@@ -2522,12 +2649,225 @@ export class WarehouseInventoryService {
} }
} }
private mergeSystemTruckEntrance(
submitted: TruckEntranceDto,
booking: {
reference?: string | null;
customer?: string | null;
customerTin?: string | null;
customerPhone?: string | null;
containerNumber?: string | null;
containerQuantity?: number | string | null;
containerPackagingType?: string | null;
cargoDescription?: string | null;
weight?: number | string | null;
firstMileTruckPlateNumber?: string | null;
firstMileTrailerPlateNumber?: string | null;
firstMileDriverName?: string | null;
firstMileDriverPhone?: string | null;
firstMileDriverLicenseNumber?: string | null;
firstMileTruckType?: string | null;
},
): TruckEntranceDto {
return {
...submitted,
ownerName: booking.customer?.trim() || submitted.ownerName,
edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId,
tin: booking.customerTin?.trim() || submitted.tin,
customerPhone: booking.customerPhone?.trim() || submitted.customerPhone,
assignedEquipmentNumber: booking.containerNumber?.trim() || submitted.assignedEquipmentNumber,
itemDescription: booking.cargoDescription?.trim() || submitted.itemDescription,
packagingType: booking.containerPackagingType?.trim() || submitted.packagingType,
unitCount:
booking.containerQuantity !== undefined && booking.containerQuantity !== null
? Number(booking.containerQuantity)
: submitted.unitCount,
grossWeightKg:
booking.weight !== undefined && booking.weight !== null
? Number(booking.weight)
: submitted.grossWeightKg,
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
};
}
private async getBookingTruckEntranceSource(
manager: EntityManager,
bookingId: string,
): Promise<{
reference?: string | null;
customer?: string | null;
customerTin?: string | null;
customerPhone?: string | null;
containerNumber?: string | null;
containerQuantity?: number | string | null;
containerPackagingType?: string | null;
cargoDescription?: string | null;
weight?: number | string | null;
firstMileTruckPlateNumber?: string | null;
firstMileTrailerPlateNumber?: string | null;
firstMileDriverName?: string | null;
firstMileDriverPhone?: string | null;
firstMileDriverLicenseNumber?: string | null;
firstMileTruckType?: string | null;
}> {
const [booking] = await manager.query(
`SELECT b.reference AS "reference",
company.name AS "customer",
company.tin AS "customerTin",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
b.cargo_total_weight_vgm AS "weight",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
bc.container_numbers AS "containerNumber",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
v.plate_number AS "firstMileTruckPlateNumber",
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
v.assigned_driver_name
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
SUM(booking_container.quantity)::int AS container_quantity,
CASE
WHEN COUNT(booking_container.id) = 0 THEN NULL
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
ELSE 'OTHER_CONTAINER'
END AS container_packaging_type
FROM freight.booking_container booking_container
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
) bc ON true
LEFT JOIN LATERAL (
SELECT first_mile.vehicle_id
FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC
LIMIT 1
) fm ON true
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
WHERE b.id = $1 AND b.deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
return booking ?? {};
}
private async notifyOwnerInventoryReceived(params: {
phone?: string | null;
ownerName?: string | null;
bookingReference?: string | null;
grnNumber: string;
direction?: string | null;
warehouseId?: string | null;
}): Promise<void> {
const phone = params.phone?.trim();
if (!phone) return;
const ownerName = params.ownerName?.trim() || 'Customer';
const bookingReference = params.bookingReference?.trim();
const message =
`Dear ${ownerName}, your cargo has been received by EDR warehouse. ` +
(bookingReference ? `Booking: ${bookingReference}. ` : '') +
`GRN: ${params.grnNumber}. ` +
(params.direction ? `Direction: ${params.direction}. ` : '') +
`Thank you.`;
try {
await this.notifications.directSend('sms', phone, message);
} catch (error) {
// Receiving inventory must not be rolled back because an SMS provider is unavailable.
this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`);
}
}
private generateGrnNumber(direction: string, referenceId: string, date: Date): string { private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
} }
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
const hasExitInspection =
Boolean(dto.truckPlateNumber?.trim()) ||
Boolean(dto.trailerPlateNumber?.trim()) ||
Boolean(dto.driverName?.trim()) ||
Boolean(dto.driverLicense?.trim()) ||
Boolean(dto.driverPhone?.trim()) ||
Boolean(dto.truckType?.trim()) ||
Boolean(dto.containerNumber?.trim()) ||
dto.tareWeight !== undefined ||
dto.grossWeight !== undefined ||
dto.netWeight !== undefined ||
Boolean(dto.gateInTime) ||
Boolean(dto.gateOutTime);
if (!hasExitInspection) return null;
if (!dto.truckPlateNumber?.trim()) {
throw new BadRequestException('Truck plate number is required for exit inspection');
}
if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection');
}
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
}
const tareWeight = Number(dto.tareWeight);
const grossWeight = Number(dto.grossWeight);
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
const rows = [
'[Exit Inspection]',
dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null,
dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null,
`Truck Plate: ${dto.truckPlateNumber.trim()}`,
dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null,
`Driver: ${dto.driverName.trim()}`,
dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null,
dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null,
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} kg`,
`Gross Weight: ${grossWeight} kg`,
`Net Weight: ${computedNetWeight} kg`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
return rows.filter(Boolean).join('\n');
}
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
const index = notes.lastIndexOf(marker);
if (index < 0) return null;
return notes.slice(index + marker.length).trim() || null;
}
private buildReceiveNote(input: { private buildReceiveNote(input: {
grnNumber: string; grnNumber: string;
direction?: string | null; direction?: string | null;
@@ -2542,6 +2882,7 @@ export class WarehouseInventoryService {
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null, truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null, truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
truck.tin ? `TIN: ${truck.tin}` : null, truck.tin ? `TIN: ${truck.tin}` : null,
truck.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
`Truck Plate: ${truck.truckPlateNumber}`, `Truck Plate: ${truck.truckPlateNumber}`,
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null, truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,

View File

@@ -1,6 +1,7 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { NotificationsService } from '../notifications/notifications.service';
import { import {
WarehouseFeeInvoice, WarehouseFeeInvoice,
WarehouseInvoiceStatus, WarehouseInvoiceStatus,
@@ -22,6 +23,8 @@ export interface PayInvoiceDto {
amount: number; amount: number;
method?: string; method?: string;
reference?: string; reference?: string;
driverName?: string;
driverPhone?: string;
} }
/** Invoices that still owe money and therefore block terminal release. */ /** Invoices that still owe money and therefore block terminal release. */
@@ -46,12 +49,15 @@ export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<Invoi
@Injectable() @Injectable()
export class WarehouseInvoiceService { export class WarehouseInvoiceService {
private readonly logger = new Logger(WarehouseInvoiceService.name);
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
private readonly feeService: WarehouseFeeService, private readonly feeService: WarehouseFeeService,
private readonly documents: WarehouseReleaseDocumentService, private readonly documents: WarehouseReleaseDocumentService,
private readonly notifications: NotificationsService,
) {} ) {}
// ── Generation ─────────────────────────────────────────────────────────── // ── Generation ───────────────────────────────────────────────────────────
@@ -150,7 +156,9 @@ export class WarehouseInvoiceService {
await this.itemRepository.create({ invoiceId: invoice.id, ...it }); await this.itemRepository.create({ invoiceId: invoice.id, ...it });
} }
return this.findById(invoice.id); const saved = await this.findById(invoice.id);
await this.notifyWarehouseFeeIssued(saved);
return saved;
} }
/** WHF-YYYYMMDD-00001 — sequential per day. */ /** WHF-YYYYMMDD-00001 — sequential per day. */
@@ -246,7 +254,9 @@ export class WarehouseInvoiceService {
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
payments, payments,
}); });
return updated as WarehouseFeeInvoice; const paidInvoice = updated as WarehouseFeeInvoice;
await this.notifyWarehouseFeePayment(paidInvoice, dto);
return paidInvoice;
} }
// ── Release blocking ────────────────────────────────────────────────────── // ── Release blocking ──────────────────────────────────────────────────────
@@ -332,6 +342,125 @@ export class WarehouseInvoiceService {
}; };
} }
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
bookingReference: string | null;
customerName: string | null;
customerPhone: string | null;
driverName: string | null;
driverPhone: string | null;
containerNumber: string | null;
cargoDescription: string | null;
}> {
const [row] = await this.dataSource.query(
`SELECT b.reference AS "bookingReference",
company.name AS "customerName",
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
last_vehicle.assigned_driver_name,
NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''),
first_vehicle.assigned_driver_name
) AS "driverName",
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
FROM freight.warehouse_fee_invoices fee
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON (
booking_container.booking_id = b.id
AND booking_container.deleted_at IS NULL
)
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN LATERAL (
SELECT lm.vehicle_id
FROM freight.last_mile lm
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL
ORDER BY lm.created_at DESC
LIMIT 1
) latest_last_mile ON true
LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id
LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id
LEFT JOIN LATERAL (
SELECT fm.vehicle_id
FROM freight.first_mile fm
WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL
ORDER BY fm.created_at DESC
LIMIT 1
) latest_first_mile ON true
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
WHERE fee.id = $1
LIMIT 1`,
[invoice.id],
);
return {
bookingReference: row?.bookingReference ?? null,
customerName: row?.customerName ?? null,
customerPhone: row?.customerPhone ?? null,
driverName: row?.driverName ?? null,
driverPhone: row?.driverPhone ?? null,
containerNumber: row?.containerNumber ?? null,
cargoDescription: row?.cargoDescription ?? null,
};
}
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> {
const phone = recipient?.trim();
if (!phone) return;
try {
await this.notifications.directSend('sms', phone, message);
} catch (error) {
this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`);
}
}
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const cargo = contacts.containerNumber || contacts.cargoDescription;
const cargoText = cargo ? ` Cargo: ${cargo}.` : '';
const message =
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` +
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
}
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
const contacts = await this.getInvoiceNotificationContacts(invoice);
const customerName = contacts.customerName?.trim() || 'Customer';
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
const statusText =
invoice.status === 'PAID'
? 'fully paid and ready for pickup release'
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
const customerMessage =
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`);
if (invoice.status !== 'PAID') return;
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver';
const cargo = contacts.containerNumber || contacts.cargoDescription;
const driverMessage =
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
(contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') +
(cargo ? ` Cargo: ${cargo}.` : '') +
' Proceed with pickup after gate verification.';
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
}
private buildInvoiceDocumentHtml( private buildInvoiceDocumentHtml(
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
kind: 'INVOICE' | 'RECEIPT', kind: 'INVOICE' | 'RECEIPT',

View File

@@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService {
} }
private htmlToBasicPdfBuffer(html: string): Buffer { private htmlToBasicPdfBuffer(html: string): Buffer {
const text = this.htmlToPlainText(html); const doc = this.extractReleaseDocument(html);
const lines = this.wrapLines(text, 86).slice(0, 52); const body: string[] = [
const body = lines this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
.map((line, index) => { this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
const y = 770 - index * 12; this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
const isTitle = index < 2 || /clearance|release order/i.test(line); this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
const size = index === 0 ? 13 : isTitle ? 11 : 9.6; this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
const font = isTitle ? 'F2' : 'F1'; this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
return this.textOp(line, 48, y, size, font); this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
}) this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
.join('\n'); this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8),
this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2),
...this.wrapLines(doc.notice, 68)
.slice(0, 4)
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
];
let y = 586;
const rowHeight = 20;
for (const [label, value] of doc.rows.slice(0, 14)) {
body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6));
body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6));
body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16'));
body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16'));
y -= rowHeight;
}
body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18'));
body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7));
body.push(
...this.wrapLines(doc.clause, 92)
.slice(0, 4)
.map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')),
);
const stream = [ const stream = [
this.lineOp(48, 752, 548, 752), ...body,
body, this.lineOp(36, 60, 218, 60, '0 0 0', 1),
this.circularSealOps(184, 154), this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
this.lineOp(48, 92, 278, 92, '0 0 0'), this.circularSealOps(286, 62, 38),
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'), this.lineOp(341, 60, 559, 60, '0 0 0', 1),
this.lineOp(326, 92, 548, 92, '0 0 0'), this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'),
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
].join('\n'); ].join('\n');
const objects = [ const objects = [
@@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService {
return Buffer.from(pdf, 'latin1'); return Buffer.from(pdf, 'latin1');
} }
private extractReleaseDocument(html: string): {
reference: string;
issuedAt: string;
notice: string;
clause: string;
rows: Array<[string, string]>;
} {
const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim();
const reference = textFromHtml(html.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO');
const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-');
const notice = textFromHtml(
html.match(/<div class="notice">([\s\S]*?)<\/div>/i)?.[1] ??
'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.',
);
const clause = textFromHtml(
html.match(/<div class="clause">([\s\S]*?)<\/div>/i)?.[1] ??
'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.',
);
const rows: Array<[string, string]> = [];
for (const match of html.matchAll(/<tr><th>([\s\S]*?)<\/th><td>([\s\S]*?)<\/td><\/tr>/gi)) {
rows.push([textFromHtml(match[1]), textFromHtml(match[2])]);
}
return { reference, issuedAt, notice, clause, rows };
}
private htmlToPlainText(html: string): string { private htmlToPlainText(html: string): string {
return html return html
.replace(/<script[\s\S]*?<\/script>/gi, '') .replace(/<script[\s\S]*?<\/script>/gi, '')
@@ -203,24 +253,36 @@ export class WarehouseReleaseDocumentService {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`; return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
} }
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string { private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string {
return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
} }
private circularSealOps(cx: number, cy: number): string { private rectOp(
x: number,
y: number,
width: number,
height: number,
fillColor = '1 1 1',
strokeColor = '0.08 0.32 0.18',
lineWidth = 0.8,
): string {
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
}
private circularSealOps(cx: number, cy: number, radius = 51): string {
return [ return [
'q', 'q',
'0.08 0.32 0.18 RG', '0.08 0.32 0.18 RG',
'0.08 0.32 0.18 rg', '0.08 0.32 0.18 rg',
'2.2 w', '2.2 w',
this.circlePath(cx, cy, 51), this.circlePath(cx, cy, radius),
'S', 'S',
'0.8 w', '0.8 w',
this.circlePath(cx, cy, 41), this.circlePath(cx, cy, radius - 10),
'S', 'S',
this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'), this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'), this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'),
this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'), this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
'Q', 'Q',
].join('\n'); ].join('\n');
} }

View File

@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module'; import { FilesModule } from '../files/files.module';
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
import { LastMileModule } from '../last-mile/last-mile.module'; import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
@@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service';
FilesModule, FilesModule,
InterchangeDocumentsModule, InterchangeDocumentsModule,
forwardRef(() => LastMileModule), forwardRef(() => LastMileModule),
NotificationsModule,
ExchangeModule.forRootAsync({ ExchangeModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions => useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01'; const DEMO_TRAINS = [
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003']; {
trainNumber: 'ICD-DEMO-EXP-DJ-01',
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
arrivalOffsetHours: 1,
},
{
trainNumber: 'ICD-DEMO-EXP-DJ-02',
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
arrivalOffsetHours: 2,
},
];
async function main() { async function main() {
const app = await NestFactory.createApplicationContext(AppModule, { const app = await NestFactory.createApplicationContext(AppModule, {
@@ -44,13 +54,6 @@ async function main() {
const scheduleRepo = dataSource.getRepository(TrainSchedule); const scheduleRepo = dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking); const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
if (existingSchedule) {
console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`);
console.log(`Schedule ID: ${existingSchedule.id}`);
return;
}
const originYard = const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? (await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } })); (await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
@@ -83,10 +86,6 @@ async function main() {
throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`); throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
} }
const now = Date.now();
const departure = new Date(now - 6 * 60 * 60 * 1000);
const arrival = new Date(now - 60 * 60 * 1000);
const locomotive = const locomotive =
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ?? (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
(await locomotiveRepo.save( (await locomotiveRepo.save(
@@ -97,83 +96,106 @@ async function main() {
}), }),
)); ));
const trainSet = await trainSetRepo.save( const now = Date.now();
trainSetRepo.create({ const seededSchedules: TrainSchedule[] = [];
locomotiveId: locomotive.id,
totalWeightTons: 700,
totalLengthMeters: 360,
wagonCount: 12,
status: 'COMPLETED',
}),
);
const schedule = await scheduleRepo.save( for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
scheduleRepo.create({ const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
trainSetId: trainSet.id, if (existingSchedule) {
originStationId: originYard!.id, console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`);
destinationStationId: destinationYard!.id, console.log(`Schedule ID: ${existingSchedule.id}`);
scheduledDepartureDate: departure, seededSchedules.push(existingSchedule);
scheduledArrivalDate: arrival, continue;
actualArrivalAt: arrival, }
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: TRAIN_NUMBER,
}),
);
for (const [index, reference] of BOOKING_REFS.entries()) { const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
const weight = 5200 + index * 800; const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
const booking = await bookingRepo.save(
bookingRepo.create({ const trainSet = await trainSetRepo.save(
reference, trainSetRepo.create({
originYardId: originYard!.id, locomotiveId: locomotive.id,
destinationYardId: destinationYard!.id, totalWeightTons: 700 + trainIndex * 80,
serviceTypeId: serviceType!.id, totalLengthMeters: 360 + trainIndex * 20,
status: 'IN_TRANSIT', wagonCount: 12 + trainIndex,
paymentStatus: 'PAID', status: 'COMPLETED',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`,
cargoTotalWeightVgm: weight,
}), }),
); );
await inventoryRepo.save( const schedule = await scheduleRepo.save(
inventoryRepo.create({ scheduleRepo.create({
warehouseId: warehouse!.id, trainSetId: trainSet.id,
yardId: warehouseYard!.id, originStationId: originYard!.id,
zoneId: warehouseZone!.id, destinationStationId: destinationYard!.id,
bookingId: booking.id, scheduledDepartureDate: departure,
quantity: 1, scheduledArrivalDate: arrival,
weight, actualArrivalAt: arrival,
status: 'DISPATCHED', status: 'ARRIVED' as TrainSchedule['status'],
inspectionStatus: 'PASSED', trainNumber: demo.trainNumber,
arrivedAt: new Date(now - 4 * 60 * 60 * 1000), direction: 'EXPORT',
inspectedAt: new Date(now - 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000),
loadedAt: new Date(now - 90 * 60 * 1000),
dispatchedAt: new Date(now - 70 * 60 * 1000),
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
}), }),
); );
await scheduleBookingRepo.save( for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
scheduleBookingRepo.create({ const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
trainScheduleId: schedule.id, const booking = await bookingRepo.save(
bookingId: booking.id, bookingRepo.create({
}), reference,
); originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType
? null
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
cargoTotalWeightVgm: weight,
}),
);
await inventoryRepo.save(
inventoryRepo.create({
warehouseId: warehouse!.id,
yardId: warehouseYard!.id,
zoneId: warehouseZone!.id,
bookingId: booking.id,
quantity: 1,
weight,
status: 'DISPATCHED',
inspectionStatus: 'PASSED',
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
dispatchedAt: departure,
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
seededSchedules.push(schedule);
} }
console.log('Export Djibouti interchange demo seeded.'); console.log('Export Djibouti interchange demo seeded.');
console.log(`Train number: ${TRAIN_NUMBER}`); for (const schedule of seededSchedules) {
console.log(`Schedule ID: ${schedule.id}`); console.log(`Train number: ${schedule.trainNumber}`);
console.log(`Schedule ID: ${schedule.id}`);
}
console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.'); console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
} finally { } finally {
await app.close(); await app.close();

View File

@@ -0,0 +1,202 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
const SEED_FLAG = 'SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO';
const DEMO_TRAINS = [
{
trainNumber: 'ICD-DEMO-EXP-DJ-01',
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
arrivalOffsetHours: 1,
},
{
trainNumber: 'ICD-DEMO-EXP-DJ-02',
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
arrivalOffsetHours: 2,
},
];
@Injectable()
export class ExportDjiboutiInterchangeDemoSeeder {
private readonly logger = new Logger(ExportDjiboutiInterchangeDemoSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
this.logger.log(`Skipping export Djibouti interchange demo seed because ${SEED_FLAG} is not enabled`);
return;
}
try {
const yardRepo = this.dataSource.getRepository(Yard);
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
const bookingRepo = this.dataSource.getRepository(Booking);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
const locomotiveRepo = this.dataSource.getRepository(Locomotive);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const destinationYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const warehouseYard = warehouse
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
: null;
const warehouseZone = warehouseYard
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null;
const missing = [
!originYard ? 'Ethiopian origin yard' : '',
!destinationYard ? 'Djibouti destination yard' : '',
!serviceType ? 'service type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
].filter(Boolean);
if (missing.length) {
this.logger.warn(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
return;
}
const locomotive =
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
(await locomotiveRepo.save(
locomotiveRepo.create({
code: 'ICD-DEMO-LOCO',
name: 'Interchange Demo Locomotive',
maxPullWeightTons: 4000,
}),
));
const now = Date.now();
let seeded = 0;
let skipped = 0;
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existingSchedule) {
skipped += 1;
continue;
}
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: 700 + trainIndex * 80,
totalLengthMeters: 360 + trainIndex * 20,
wagonCount: 12 + trainIndex,
status: 'COMPLETED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard!.id,
destinationStationId: destinationYard!.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualArrivalAt: arrival,
status: 'ARRIVED' as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: 'EXPORT',
}),
);
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'IN_TRANSIT',
paymentStatus: 'PAID',
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType
? null
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
cargoTotalWeightVgm: weight,
}),
);
await inventoryRepo.save(
inventoryRepo.create({
warehouseId: warehouse!.id,
yardId: warehouseYard!.id,
zoneId: warehouseZone!.id,
bookingId: booking.id,
quantity: 1,
weight,
status: 'DISPATCHED',
inspectionStatus: 'PASSED',
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
dispatchedAt: departure,
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
seeded += 1;
}
this.logger.log(`Export Djibouti interchange demo seed complete: ${seeded} train(s) seeded, ${skipped} skipped`);
} catch (error) {
this.logger.error(
`ExportDjiboutiInterchangeDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}

View File

@@ -58,6 +58,7 @@ interface TruckEntranceFormState {
consigneeDetails: string; consigneeDetails: string;
edrDigitalBookingId: string; edrDigitalBookingId: string;
tin: string; tin: string;
customerPhone: string;
truckPlateNumber: string; truckPlateNumber: string;
trailerPlateNumber: string; trailerPlateNumber: string;
assignedEquipmentNumber: string; assignedEquipmentNumber: string;
@@ -85,11 +86,21 @@ interface TruckEntranceFormState {
warehouseManagerName: string; warehouseManagerName: string;
} }
interface LockedTruckEntranceFields {
ownerName?: boolean;
tin?: boolean;
edrDigitalBookingId?: boolean;
customerPhone?: boolean;
}
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
const emptyTruckEntrance = (): TruckEntranceFormState => ({ const emptyTruckEntrance = (): TruckEntranceFormState => ({
ownerName: '', ownerName: '',
consigneeDetails: '', consigneeDetails: '',
edrDigitalBookingId: '', edrDigitalBookingId: '',
tin: '', tin: '',
customerPhone: '',
truckPlateNumber: '', truckPlateNumber: '',
trailerPlateNumber: '', trailerPlateNumber: '',
assignedEquipmentNumber: '', assignedEquipmentNumber: '',
@@ -122,6 +133,7 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
consigneeDetails: form.consigneeDetails.trim() || undefined, consigneeDetails: form.consigneeDetails.trim() || undefined,
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
tin: form.tin.trim() || undefined, tin: form.tin.trim() || undefined,
customerPhone: form.customerPhone.trim() || undefined,
truckPlateNumber: form.truckPlateNumber.trim(), truckPlateNumber: form.truckPlateNumber.trim(),
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
@@ -149,13 +161,124 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
warehouseManagerName: form.warehouseManagerName.trim() || undefined, warehouseManagerName: form.warehouseManagerName.trim() || undefined,
}); });
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
return unique.length === 1 ? unique[0] : '';
};
const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
form: TruckEntranceFormState;
lockedFields: LockedTruckEntranceFields;
packagingFreightType: PackagingFreightType;
} => {
const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer));
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber));
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType));
const edrDigitalBookingId =
bookings.length === 1
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
: commonNonEmptyValue(bookings.map((booking) => booking.reference));
const firstMileBooking = bookings.length === 1 ? bookings[0] : null;
const unitCount =
bookings.length === 1 && bookings[0]?.containerQuantity != null
? Number(bookings[0].containerQuantity)
: '';
const grossWeightKg =
bookings.length === 1 && bookings[0]?.weight != null
? Number(bookings[0].weight)
: '';
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
const packagingFreightType =
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
? 'CONTAINER'
: freightTypes.length === 1 && freightTypes[0] === 'BULK'
? 'BULK'
: 'MIXED';
return {
form: {
...emptyTruckEntrance(),
ownerName,
consigneeDetails,
tin,
customerPhone,
edrDigitalBookingId,
assignedEquipmentNumber,
itemDescription,
packagingType,
unitCount,
grossWeightKg,
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '',
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '',
driverName: firstMileBooking?.firstMileDriverName ?? '',
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '',
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '',
truckType: firstMileBooking?.firstMileTruckType ?? '',
},
lockedFields: {
ownerName: Boolean(ownerName),
tin: Boolean(tin),
edrDigitalBookingId: Boolean(edrDigitalBookingId),
customerPhone: Boolean(customerPhone),
},
packagingFreightType,
};
};
const BULK_PACKAGING_TYPE_OPTIONS = [
{ value: 'BAG', label: 'Bag' },
{ value: 'SACK', label: 'Sack' },
{ value: 'BALE', label: 'Bale' },
{ value: 'CARTON', label: 'Carton' },
{ value: 'CRATE', label: 'Crate' },
{ value: 'DRUM', label: 'Drum' },
{ value: 'BARREL', label: 'Barrel' },
{ value: 'PALLET', label: 'Pallet' },
{ value: 'LOOSE_BULK', label: 'Loose bulk' },
{ value: 'OTHER', label: 'Other' },
];
const CONTAINER_PACKAGING_TYPE_OPTIONS = [
{ value: 'CONTAINER_20FT', label: '20 ft container' },
{ value: 'CONTAINER_40FT', label: '40 ft container' },
{ value: 'CONTAINER_45FT', label: '45 ft container' },
{ value: 'REEFER_CONTAINER', label: 'Reefer container' },
{ value: 'TANK_CONTAINER', label: 'Tank container' },
{ value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' },
{ value: 'OPEN_TOP_CONTAINER', label: 'Open top container' },
{ value: 'OTHER_CONTAINER', label: 'Other container' },
];
const packagingOptionsFor = (freightType: PackagingFreightType) =>
freightType === 'CONTAINER'
? CONTAINER_PACKAGING_TYPE_OPTIONS
: freightType === 'BULK'
? BULK_PACKAGING_TYPE_OPTIONS
: [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS];
function TruckEntranceFields({ function TruckEntranceFields({
value, value,
onChange, onChange,
lockedFields,
packagingFreightType = 'MIXED',
}: { }: {
value: TruckEntranceFormState; value: TruckEntranceFormState;
onChange: (next: TruckEntranceFormState) => void; onChange: (next: TruckEntranceFormState) => void;
lockedFields?: LockedTruckEntranceFields;
packagingFreightType?: PackagingFreightType;
}) { }) {
const packagingOptions = packagingOptionsFor(packagingFreightType);
const quantityLabel =
packagingFreightType === 'CONTAINER'
? 'Container quantity'
: packagingFreightType === 'BULK'
? 'Unit count'
: 'Quantity';
return ( return (
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" fw={600}>Customer and cargo ownership</Text> <Text size="sm" fw={600}>Customer and cargo ownership</Text>
@@ -163,6 +286,7 @@ function TruckEntranceFields({
<TextInput <TextInput
label="Owner's name" label="Owner's name"
value={value.ownerName} value={value.ownerName}
readOnly={lockedFields?.ownerName}
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })} onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
/> />
<TextInput <TextInput
@@ -175,14 +299,22 @@ function TruckEntranceFields({
<TextInput <TextInput
label="EDR digital booking ID" label="EDR digital booking ID"
value={value.edrDigitalBookingId} value={value.edrDigitalBookingId}
readOnly={lockedFields?.edrDigitalBookingId}
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })} onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
/> />
<TextInput <TextInput
label="TIN" label="TIN"
value={value.tin} value={value.tin}
readOnly={lockedFields?.tin}
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })} onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
/> />
</Group> </Group>
<TextInput
label="Customer phone"
value={value.customerPhone}
readOnly={lockedFields?.customerPhone}
onChange={(e) => onChange({ ...value, customerPhone: e.currentTarget.value })}
/>
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text> <Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
<Group grow> <Group grow>
@@ -285,13 +417,16 @@ function TruckEntranceFields({
/> />
</Group> </Group>
<Group grow> <Group grow>
<TextInput <Select
label="Packaging type" label="Packaging type"
data={packagingOptions}
clearable
searchable
value={value.packagingType} value={value.packagingType}
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })} onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
/> />
<NumberInput <NumberInput
label="Unit count" label={quantityLabel}
min={0} min={0}
value={value.unitCount} value={value.unitCount}
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
@@ -466,6 +601,8 @@ function EligibleTab({
const [truckOpen, setTruckOpen] = useState(false); const [truckOpen, setTruckOpen] = useState(false);
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]); const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance()); const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
const canReceiveBooking = (row: EligibleBooking) => const canReceiveBooking = (row: EligibleBooking) =>
@@ -564,13 +701,14 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
return; return;
} }
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; const selectedRows = filteredIds
.map((id) => rows.find((item) => item.id === id))
.filter(Boolean) as EligibleBooking[];
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
setPendingReceiveIds(filteredIds); setPendingReceiveIds(filteredIds);
setTruckForm({ setTruckForm(form);
...emptyTruckEntrance(), setLockedTruckFields(lockedFields);
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', setPackagingFreightType(nextPackagingFreightType);
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
});
setTruckOpen(true); setTruckOpen(true);
}; };
@@ -593,6 +731,8 @@ function EligibleTab({
setSelected(new Set()); setSelected(new Set());
setTruckOpen(false); setTruckOpen(false);
setPendingReceiveIds([]); setPendingReceiveIds([]);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
onChanged?.(); onChanged?.();
} catch (error) { } catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
@@ -805,7 +945,12 @@ function EligibleTab({
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
</Text> </Text>
<TruckEntranceFields value={truckForm} onChange={setTruckForm} /> <TruckEntranceFields
value={truckForm}
onChange={setTruckForm}
lockedFields={lockedTruckFields}
packagingFreightType={packagingFreightType}
/>
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}> <Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel Cancel

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core'; import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
import { Info } from 'lucide-react'; import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
@@ -17,23 +17,115 @@ interface ReleaseOrderModalProps {
item: WarehouseInventoryItem | null; item: WarehouseInventoryItem | null;
} }
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
].map(([powerPlate, trailerPlate], index) => ({
value: powerPlate,
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
trailerPlate,
}));
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) { export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState(''); const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
const [driverName, setDriverName] = useState('');
const [driverLicense, setDriverLicense] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const [truckType, setTruckType] = useState('');
const [containerNumber, setContainerNumber] = useState('');
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
useEffect(() => { useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? ''); if (opened) {
setReference(item?.releaseOrderReference ?? '');
setTruckPlateNumber('');
setTrailerPlateNumber('');
setDriverName('');
setDriverLicense('');
setDriverPhone('');
setTruckType('');
setContainerNumber('');
setGateInTime('');
setTareWeight('');
setGrossWeight('');
setNetWeight(item?.weight != null ? Number(item.weight) : '');
setGateOutTime('');
}
}, [opened, item]); }, [opened, item]);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
const handleSubmit = async () => { const handleSubmit = async () => {
if (!item) return; if (!item) return;
if (!truckPlateNumber.trim() || !driverName.trim()) {
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (tareWeight === '' || grossWeight === '') {
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
return;
}
if (weightMismatch) {
toast({
variant: 'destructive',
title: 'Weight mismatch',
description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.',
});
return;
}
const pdfWindow = window.open('', '_blank'); const pdfWindow = window.open('', '_blank');
try { try {
const released = await releaseMutation.mutateAsync({ const released = await releaseMutation.mutateAsync({
id: item.id, id: item.id,
payload: { reference: reference.trim() || undefined }, payload: {
reference: reference.trim() || undefined,
bookingId: item.bookingId ?? undefined,
customerId: undefined,
truckPlateNumber: truckPlateNumber.trim(),
trailerPlateNumber: trailerPlateNumber.trim() || undefined,
driverName: driverName.trim(),
driverLicense: driverLicense.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
truckType: truckType.trim() || undefined,
containerNumber: containerNumber.trim() || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: Number(grossWeight),
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
gateOutTime: toIsoDateTime(gateOutTime),
},
}); });
setDownloading(true); setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id); const response = await warehouseService.downloadReleaseDocument(item.id);
@@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
}; };
return ( return (
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md"> <Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
<Stack gap="md"> <Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light"> <Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm"> <Text size="sm">
Creates the warehouse release document with booking, customer, cargo and location details. The Save the exit inspection before generating the exit paper. Gate clearance is blocked when
printed paper authorizes the goods to leave the warehouse gate. recorded net weight does not equal gross weight minus tare weight.
</Text> </Text>
</Alert> </Alert>
<TextInput <TextInput
@@ -70,12 +162,69 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
value={reference} value={reference}
onChange={(e) => setReference(e.currentTarget.value)} onChange={(e) => setReference(e.currentTarget.value)}
/> />
<Select
label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below"
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
}}
/>
<Group grow>
<TextInput
label="Truck plate number"
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
/>
<TextInput
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
</Group>
<Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
<Text size="sm">
Weight mismatch detected. Exit paper and gate clearance are blocked; use Store or Move to
reassign the item back to warehouse handling.
</Text>
</Alert>
)}
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}> <Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel Cancel
</Button> </Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}> <Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Issue & view exit paper Exit Inspection & View Exit Paper
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -164,7 +164,7 @@ export function WarehouseInventoryTable({
loading={busy} loading={busy}
onClick={() => onAdvance(item, nextAction)} onClick={() => onAdvance(item, nextAction)}
> >
{humanizeEnum(nextAction.replace(/-/g, '_'))} {nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button> </Button>
)} )}
{item.status === 'READY_FOR_PICKUP' && ( {item.status === 'READY_FOR_PICKUP' && (

View File

@@ -1,6 +1,6 @@
export const API_BASE_URL = export const API_BASE_URL =
import.meta.env.VITE_BASE_API_URL || import.meta.env.VITE_BASE_API_URL ||
import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL ||
'https://edrfreightapi.triaplc.com'; 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001'; //export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -7,6 +7,7 @@ import {
Group, Group,
Loader, Loader,
Modal, Modal,
Alert,
Stack, Stack,
Table, Table,
Tabs, Tabs,
@@ -168,7 +169,7 @@ function ExportTrainDetailRows({
export default function ExportDjiboutiUnloadingQueuePage() { export default function ExportDjiboutiUnloadingQueuePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue(); const { data: trains = [], isLoading, isError, error } = useExportDjiboutiArrivalQueue();
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' }); const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
const autoUnload = useAutoUnloadExportAtDjibouti(); const autoUnload = useAutoUnloadExportAtDjibouti();
const generateInterchange = useGenerateInterchangeDocument(); const generateInterchange = useGenerateInterchangeDocument();
@@ -272,6 +273,10 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Group justify="center" py="xl"> <Group justify="center" py="xl">
<Loader /> <Loader />
</Group> </Group>
) : isError ? (
<Alert color="red" variant="light" title="Could not load arrived export trains">
{getErrorMessage(error) ?? 'Check your API connection and sign in again.'}
</Alert>
) : trains.length === 0 ? ( ) : trains.length === 0 ? (
<VisualEmptyState <VisualEmptyState
variant="train" variant="train"

View File

@@ -12,7 +12,7 @@ import {
Text, Text,
TextInput, TextInput,
} from '@mantine/core'; } from '@mantine/core';
import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react'; import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
@@ -25,6 +25,7 @@ import {
useInterchangeDocuments, useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments'; } from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument'; import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
const statusColor: Record<InterchangeDocumentStatus, string> = { const statusColor: Record<InterchangeDocumentStatus, string> = {
@@ -45,6 +46,116 @@ const getErrorMessage = (error: unknown) => {
return error instanceof Error ? error.message : undefined; return error instanceof Error ? error.message : undefined;
}; };
const escapeHtml = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const filenameFor = (document: InterchangeDocument) =>
`${document.documentNo || document.id}-interchange-document.html`.replace(/[\\/:*?"<>|]/g, '-');
const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
const items = document.items ?? [];
const rows = items
.map(
(item, index) => `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
<td>${escapeHtml(item.itemType)}</td>
<td>${escapeHtml(item.containerNumber)}</td>
<td>${escapeHtml(item.sealNumber)}</td>
<td>${escapeHtml(item.cargoType ?? item.cargoDescription)}</td>
<td>${escapeHtml(formatNumber(item.weight))}</td>
<td>${escapeHtml(formatNumber(item.quantity))}</td>
<td>${escapeHtml(item.wagonNumber)}</td>
<td>${escapeHtml(item.conditionStatus)}</td>
<td>${escapeHtml(item.damageDescription)}</td>
</tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(document.documentNo)} Interchange Document</title>
<style>
@page { size: A4 landscape; margin: 14mm; }
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; color: #111827; margin: 0; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid #111827; padding-bottom: 14px; }
h1 { margin: 0; font-size: 24px; }
.muted { color: #4b5563; font-size: 12px; }
.stamp { border: 2px solid #15803d; color: #15803d; border-radius: 999px; padding: 14px 18px; text-align: center; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 18px; margin: 18px 0; }
.field { border-bottom: 1px solid #d1d5db; padding-bottom: 6px; }
.label { color: #6b7280; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }
.value { font-size: 13px; font-weight: 700; margin-top: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 11px; }
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; vertical-align: top; }
th { background: #f3f4f6; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 34px; }
.signature { border-top: 1px solid #111827; padding-top: 8px; min-height: 48px; }
.footer { margin-top: 16px; font-size: 10px; color: #6b7280; }
</style>
</head>
<body>
<div class="top">
<div>
<h1>EDR / Djibouti Port Interchange Document</h1>
<div class="muted">Official export handover document</div>
<div class="muted">Document No: ${escapeHtml(document.documentNo)}</div>
</div>
<div class="stamp">${escapeHtml(document.status)}<br/>SIGNED HANDOVER</div>
</div>
<div class="grid">
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
<div class="field"><div class="label">Generated At</div><div class="value">${escapeHtml(formatDate(document.generatedAt))}</div></div>
<div class="field"><div class="label">Acknowledged At</div><div class="value">${escapeHtml(formatDate(document.acknowledgedAt))}</div></div>
<div class="field"><div class="label">Signed by EDR</div><div class="value">${escapeHtml(document.generatedBy)}</div></div>
<div class="field"><div class="label">Signed by Djibouti Port</div><div class="value">${escapeHtml(document.acknowledgedBy)}</div></div>
<div class="field"><div class="label">Port Operator</div><div class="value">${escapeHtml(document.portOperatorName)}</div></div>
<div class="field"><div class="label">Manifest Ref</div><div class="value">${escapeHtml(document.manifestReference)}</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Booking</th>
<th>Type</th>
<th>Container</th>
<th>Seal</th>
<th>Cargo</th>
<th>Weight</th>
<th>Qty</th>
<th>Wagon</th>
<th>Condition</th>
<th>Damage / Notes</th>
</tr>
</thead>
<tbody>${rows || '<tr><td colspan="11">No items</td></tr>'}</tbody>
</table>
<div class="signatures">
<div class="signature">EDR Representative: ${escapeHtml(document.generatedBy)}</div>
<div class="signature">Djibouti Port Operator: ${escapeHtml(document.acknowledgedBy)}</div>
</div>
<div class="footer">Generated from EDR Freight Management System. Printed on ${escapeHtml(new Date().toLocaleString())}.</div>
</body>
</html>`;
};
function DetailField({ label, value }: { label: string; value: ReactNode }) { function DetailField({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<Stack gap={2}> <Stack gap={2}>
@@ -157,6 +268,37 @@ export default function InterchangeDocumentsPage() {
const dispute = useDisputeInterchangeDocument(); const dispute = useDisputeInterchangeDocument();
const cancel = useCancelInterchangeDocument(); const cancel = useCancelInterchangeDocument();
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
if (interchangeDocument.items?.length) return interchangeDocument;
return interchangeDocumentsService.getById(interchangeDocument.id).then((response) => response.data);
};
const printDocument = async (interchangeDocument: InterchangeDocument) => {
const fullDocument = await getPrintableDocument(interchangeDocument);
const win = window.open('', '_blank');
if (!win) {
toast({ variant: 'destructive', title: 'Pop-up blocked', description: 'Allow pop-ups to print the document.' });
return;
}
win.document.write(buildPrintableInterchangeHtml(fullDocument));
win.document.close();
win.focus();
setTimeout(() => win.print(), 250);
};
const downloadDocument = async (interchangeDocument: InterchangeDocument) => {
const fullDocument = await getPrintableDocument(interchangeDocument);
const blob = new Blob([buildPrintableInterchangeHtml(fullDocument)], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(fullDocument);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const run = async (fn: () => Promise<unknown>, title: string) => { const run = async (fn: () => Promise<unknown>, title: string) => {
try { try {
await fn(); await fn();
@@ -169,10 +311,10 @@ export default function InterchangeDocumentsPage() {
const acknowledgeDocument = (document: InterchangeDocument) => { const acknowledgeDocument = (document: InterchangeDocument) => {
const acknowledgedBy = window.prompt('Acknowledged by'); const acknowledgedBy = window.prompt('Acknowledged by');
if (!acknowledgedBy) return; if (!acknowledgedBy) return;
run( run(async () => {
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }), const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy });
'Interchange document acknowledged', await printDocument(response.data);
); }, 'Interchange document acknowledged');
}; };
const disputeDocument = (document: InterchangeDocument) => { const disputeDocument = (document: InterchangeDocument) => {
@@ -284,6 +426,28 @@ export default function InterchangeDocumentsPage() {
Acknowledge Acknowledge
</Button> </Button>
) : null} ) : null}
{document.status === 'ACKNOWLEDGED' ? (
<>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Printer size={14} />}
onClick={() => run(() => printDocument(document), 'Print view opened')}
>
Print
</Button>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Download size={14} />}
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
>
Download
</Button>
</>
) : null}
{document.status !== 'CANCELLED' ? ( {document.status !== 'CANCELLED' ? (
<Button <Button
size="compact-xs" size="compact-xs"

View File

@@ -165,6 +165,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions()); const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions()); const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
const [payAmount, setPayAmount] = useState<number | ''>(''); const [payAmount, setPayAmount] = useState<number | ''>('');
const [driverName, setDriverName] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
@@ -254,8 +256,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
const handlePay = async () => { const handlePay = async () => {
if (!inv || !payAmount) return; if (!inv || !payAmount) return;
try { try {
const paidInvoice = await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } }); const paidInvoice = await pay.mutateAsync({
id: inv.id,
payload: {
amount: Number(payAmount),
method: 'MANUAL',
driverName: driverName.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
},
});
setPayAmount(''); setPayAmount('');
setDriverName('');
setDriverPhone('');
if (paidInvoice.status === 'PAID') { if (paidInvoice.status === 'PAID') {
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' }); toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
await downloadReceiptPdf(paidInvoice); await downloadReceiptPdf(paidInvoice);
@@ -334,6 +346,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))} onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<TextInput
label="Pickup driver"
value={driverName}
onChange={(e) => setDriverName(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<TextInput
label="Driver phone"
value={driverPhone}
onChange={(e) => setDriverPhone(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}> <Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay Pay
</Button> </Button>

View File

@@ -341,6 +341,20 @@ export interface ReserveInventoryPayload {
export interface ReleaseOrderPayload { export interface ReleaseOrderPayload {
reference?: string; reference?: string;
releaseDate?: string; releaseDate?: string;
bookingId?: string;
customerId?: string;
truckPlateNumber?: string;
trailerPlateNumber?: string;
driverName?: string;
driverLicense?: string;
driverPhone?: string;
truckType?: string;
containerNumber?: string;
gateInTime?: string;
tareWeight?: number;
grossWeight?: number;
netWeight?: number;
gateOutTime?: string;
} }
/** Import branch: proof of delivery captured on customer pickup. */ /** Import branch: proof of delivery captured on customer pickup. */
@@ -356,6 +370,13 @@ export interface EligibleBooking {
reference: string; reference: string;
customerId: string | null; customerId: string | null;
customer: string | null; customer: string | null;
customerTin: string | null;
customerPhone: string | null;
containerNumber: string | null;
containerQuantity: number | null;
containerPackagingType: string | null;
cargoDescription: string | null;
lastMileRequested: boolean;
direction: string; direction: string;
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
@@ -370,6 +391,10 @@ export interface EligibleBooking {
firstMileVehicleId: string | null; firstMileVehicleId: string | null;
firstMileTruckPlateNumber: string | null; firstMileTruckPlateNumber: string | null;
firstMileTrailerPlateNumber: string | null; firstMileTrailerPlateNumber: string | null;
firstMileDriverName: string | null;
firstMileDriverPhone: string | null;
firstMileDriverLicenseNumber: string | null;
firstMileTruckType: string | null;
} }
export interface BulkReceivePayload { export interface BulkReceivePayload {
@@ -392,6 +417,7 @@ export interface TruckEntrancePayload {
consigneeDetails?: string; consigneeDetails?: string;
edrDigitalBookingId?: string; edrDigitalBookingId?: string;
tin?: string; tin?: string;
customerPhone?: string;
truckPlateNumber: string; truckPlateNumber: string;
trailerPlateNumber?: string; trailerPlateNumber?: string;
assignedEquipmentNumber?: string; assignedEquipmentNumber?: string;
@@ -822,6 +848,8 @@ export interface PayInvoicePayload {
amount: number; amount: number;
method?: string; method?: string;
reference?: string; reference?: string;
driverName?: string;
driverPhone?: string;
} }
// ── Payloads ─────────────────────────────────────────────────────────────── // ── Payloads ───────────────────────────────────────────────────────────────

View File

@@ -1,3 +1,2 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'https://fhcdev-backend.triaplc.com'; //export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'http://localhost:3001';