mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 15:48:11 +00:00
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user