mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
Merge branch 'dev' into passenger/feat/iam
This commit is contained in:
@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||
|
||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||
MINIO_ENDPOINT=localhost
|
||||
|
||||
@@ -19,8 +19,16 @@
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -39,14 +47,15 @@
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz",
|
||||
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"libphonenumber-js": "^1.13.6",
|
||||
"minio": "7.1.3",
|
||||
|
||||
@@ -52,8 +52,10 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -67,6 +69,7 @@ import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -130,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -145,6 +149,8 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -161,6 +167,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -179,6 +186,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC,
|
||||
ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS estimated_distance_km,
|
||||
DROP COLUMN IF EXISTS actual_distance_km;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
|
||||
name = 'CreateImportDjiboutiOperations1822000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_djibouti_operations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'train_schedule_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'load_list_generated_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableIndex({
|
||||
name: 'idx_import_djibouti_operations_schedule',
|
||||
columnNames: ['train_schedule_id'],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['train_schedule_id'],
|
||||
referencedTableName: 'train_schedules',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.import_djibouti_operations', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
|
||||
name = 'CreateImportOperationsTables1823000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'djibouti_import_incidents',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80', isNullable: true },
|
||||
{ name: 'cargo_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'station', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'incident_type', type: 'varchar', length: '40' },
|
||||
{ name: 'description', type: 'text' },
|
||||
{ name: 'photos', type: 'jsonb', default: "'[]'::jsonb" },
|
||||
{ name: 'reported_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'reported_at', type: 'timestamptz' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_customs_finalizations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'customs_risk', type: 'varchar', length: '12', isNullable: true },
|
||||
{ name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'completed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'empty_container_returns',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'return_date', type: 'timestamptz' },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'yard', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'zone', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'condition', type: 'text', isNullable: true },
|
||||
{ name: 'handover_note', type: 'text', isNullable: true },
|
||||
{ name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" },
|
||||
{ name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] }));
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.empty_container_returns', true);
|
||||
await queryRunner.dropTable('freight.import_customs_finalizations', true);
|
||||
await queryRunner.dropTable('freight.djibouti_import_incidents', true);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
@@ -44,30 +44,19 @@ export class FirstMileService {
|
||||
* paid before any first-mile work proceeds. Throws if the reference is
|
||||
* unknown or the booking has not reached PAID status.
|
||||
*/
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
|
||||
async acceptBooking(bookingId: string): Promise<FirstMile> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId, {
|
||||
relations: { serviceType: true },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.create({
|
||||
bookingId: booking.id,
|
||||
advancedPayment: 0,
|
||||
});
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
|
||||
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
|
||||
const [booking] = await this.bookingsRepository.findAll({
|
||||
where: { reference: bookingReference },
|
||||
relations: { serviceType: true },
|
||||
@@ -75,15 +64,39 @@ export class FirstMileService {
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
return null;
|
||||
throw new NotFoundException(`Booking ${bookingReference} not found`);
|
||||
}
|
||||
|
||||
return this.acceptEligibleBooking(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared accept path: validates payment + first-mile eligibility, rejects an
|
||||
* already-assigned booking, then creates the first-mile record. Throws a
|
||||
* meaningful HTTP error instead of returning null so the client can surface
|
||||
* why an accept was refused.
|
||||
*/
|
||||
private async acceptEligibleBooking(booking: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
paymentStatus?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): Promise<FirstMile> {
|
||||
const label = booking.reference ?? booking.id;
|
||||
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
return null;
|
||||
throw new BadRequestException(`Booking ${label} is not paid`);
|
||||
}
|
||||
|
||||
if (!this.bookingRequestsFirstMile(booking)) {
|
||||
return null;
|
||||
throw new BadRequestException(`Booking ${label} does not require a first mile`);
|
||||
}
|
||||
|
||||
const existing = await this.findByBookingId(booking.id);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
|
||||
}
|
||||
|
||||
return this.create({
|
||||
@@ -174,10 +187,17 @@ export class FirstMileService {
|
||||
}
|
||||
|
||||
private bookingRequestsFirstMile(booking: {
|
||||
tradeDirection?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): boolean {
|
||||
return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile);
|
||||
// Export bookings always need a first mile (pickup → origin yard); the
|
||||
// pickup address is captured at assignment time, not required upfront.
|
||||
return Boolean(
|
||||
booking.tradeDirection === 'EXPORT' ||
|
||||
booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile,
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||
@@ -205,6 +225,16 @@ export class FirstMileService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
|
||||
const updated = await this.firstMileRepository.update(id, { status });
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||
try {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity';
|
||||
import {
|
||||
EMPTY_CONTAINER_RETURN_STATUSES,
|
||||
type EmptyContainerReturnStatus,
|
||||
} from '../entities/empty-container-return.entity';
|
||||
import {
|
||||
IMPORT_CUSTOMS_RISK_LEVELS,
|
||||
type ImportCustomsDocumentType,
|
||||
type ImportCustomsRiskLevel,
|
||||
} from '../entities/import-customs-finalization.entity';
|
||||
|
||||
export const IMPORT_CUSTOMS_DOCUMENT_TYPES = [
|
||||
'IM4',
|
||||
'IM5',
|
||||
'T1_CLOSURE_PROOF',
|
||||
'TRANSIT_PERMIT_SCREENSHOT',
|
||||
'CUSTOMER_PAYMENT_SLIP',
|
||||
'IMPORT_RELEASE_PERMIT',
|
||||
] as const;
|
||||
|
||||
export class CreateDjiboutiIncidentDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
facility?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
station?: string;
|
||||
|
||||
@ApiProperty({ enum: DJIBOUTI_INCIDENT_TYPES })
|
||||
@IsIn(DJIBOUTI_INCIDENT_TYPES)
|
||||
incidentType!: DjiboutiIncidentType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
photos?: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reportedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
reportedAt?: string;
|
||||
}
|
||||
|
||||
export class UploadImportCustomsDocumentDto {
|
||||
@ApiProperty({ enum: IMPORT_CUSTOMS_DOCUMENT_TYPES })
|
||||
@IsIn(IMPORT_CUSTOMS_DOCUMENT_TYPES)
|
||||
documentType!: ImportCustomsDocumentType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
fileId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class RecordDeclarationDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
declarationSerialNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class AssignCustomsRiskDto {
|
||||
@ApiProperty({ enum: IMPORT_CUSTOMS_RISK_LEVELS })
|
||||
@IsIn(IMPORT_CUSTOMS_RISK_LEVELS)
|
||||
risk!: ImportCustomsRiskLevel;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class ImportOperationActionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateEmptyContainerReturnDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
returnDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
facility?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
yard?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
zone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
condition?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
handoverNote?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {
|
||||
@ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES })
|
||||
@IsIn(EMPTY_CONTAINER_RETURN_STATUSES)
|
||||
status!: EmptyContainerReturnStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
wagonAllocationReference?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
handoverNote?: string;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const DJIBOUTI_INCIDENT_TYPES = [
|
||||
'SEAL_BROKEN',
|
||||
'CONTAINER_OPENED',
|
||||
'CONTAINER_DAMAGED',
|
||||
'FLUID_LEAKING',
|
||||
'QUANTITY_MISMATCH',
|
||||
'WEIGHT_MISMATCH',
|
||||
'OTHER',
|
||||
] as const;
|
||||
|
||||
export type DjiboutiIncidentType = (typeof DJIBOUTI_INCIDENT_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'djibouti_import_incidents' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['containerNumber'])
|
||||
@Index(['incidentType'])
|
||||
export class DjiboutiIncident extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 80, nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
|
||||
cargoId?: string | null;
|
||||
|
||||
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
|
||||
facility?: string | null;
|
||||
|
||||
@Column({ name: 'station', type: 'varchar', length: 120, nullable: true })
|
||||
station?: string | null;
|
||||
|
||||
@Column({ name: 'incident_type', type: 'varchar', length: 40 })
|
||||
incidentType!: DjiboutiIncidentType;
|
||||
|
||||
@Column({ name: 'description', type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Column({ name: 'photos', type: 'jsonb', default: () => "'[]'::jsonb" })
|
||||
photos!: string[];
|
||||
|
||||
@Column({ name: 'reported_by', type: 'varchar', length: 120, nullable: true })
|
||||
reportedBy?: string | null;
|
||||
|
||||
@Column({ name: 'reported_at', type: 'timestamptz' })
|
||||
reportedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const EMPTY_CONTAINER_RETURN_STATUSES = [
|
||||
'RETURNED',
|
||||
'ASSIGNED_STORAGE',
|
||||
'DOCUMENTATION_CLEARED',
|
||||
'WAGON_ALLOCATED',
|
||||
'TRANSPORTED_TO_DJIBOUTI',
|
||||
'HANDOVER_ISSUED',
|
||||
'COMPLETED',
|
||||
] as const;
|
||||
|
||||
export type EmptyContainerReturnStatus = (typeof EMPTY_CONTAINER_RETURN_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'empty_container_returns' })
|
||||
@Index(['containerNumber'])
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
export class EmptyContainerReturn extends BaseEntity {
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 80 })
|
||||
containerNumber!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
@Column({ name: 'return_date', type: 'timestamptz' })
|
||||
returnDate!: Date;
|
||||
|
||||
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
|
||||
facility?: string | null;
|
||||
|
||||
@Column({ name: 'yard', type: 'varchar', length: 120, nullable: true })
|
||||
yard?: string | null;
|
||||
|
||||
@Column({ name: 'zone', type: 'varchar', length: 120, nullable: true })
|
||||
zone?: string | null;
|
||||
|
||||
@Column({ name: 'condition', type: 'text', nullable: true })
|
||||
condition?: string | null;
|
||||
|
||||
@Column({ name: 'handover_note', type: 'text', nullable: true })
|
||||
handoverNote?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'RETURNED' })
|
||||
status!: EmptyContainerReturnStatus;
|
||||
|
||||
@Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true })
|
||||
wagonAllocationReference?: string | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const IMPORT_CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'BLUE', 'RED'] as const;
|
||||
export type ImportCustomsRiskLevel = (typeof IMPORT_CUSTOMS_RISK_LEVELS)[number];
|
||||
|
||||
export type ImportCustomsDocumentType =
|
||||
| 'IM4'
|
||||
| 'IM5'
|
||||
| 'T1_CLOSURE_PROOF'
|
||||
| 'TRANSIT_PERMIT_SCREENSHOT'
|
||||
| 'CUSTOMER_PAYMENT_SLIP'
|
||||
| 'IMPORT_RELEASE_PERMIT';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'import_customs_finalizations' })
|
||||
@Index(['bookingId'], { unique: true })
|
||||
@Index(['customsRisk'])
|
||||
export class ImportCustomsFinalization extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
documents!: Partial<Record<ImportCustomsDocumentType, string>>;
|
||||
|
||||
@Column({ name: 'declaration_serial_number', type: 'varchar', length: 120, nullable: true })
|
||||
declarationSerialNumber?: string | null;
|
||||
|
||||
@Column({ name: 'duties_taxes_notified_at', type: 'timestamptz', nullable: true })
|
||||
dutiesTaxesNotifiedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'duties_taxes_paid_at', type: 'timestamptz', nullable: true })
|
||||
dutiesTaxesPaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customs_risk', type: 'varchar', length: 12, nullable: true })
|
||||
customsRisk?: ImportCustomsRiskLevel | null;
|
||||
|
||||
@Column({ name: 'import_release_permitted_at', type: 'timestamptz', nullable: true })
|
||||
importReleasePermittedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
RecordDeclarationDto,
|
||||
UpdateEmptyContainerReturnStatusDto,
|
||||
UploadImportCustomsDocumentDto,
|
||||
} from './dto/import-operations.dto';
|
||||
import { ImportOperationsService } from './import-operations.service';
|
||||
|
||||
@ApiTags('import-operations')
|
||||
@ApiBearerAuth()
|
||||
@Controller('import-operations')
|
||||
export class ImportOperationsController {
|
||||
constructor(private readonly service: ImportOperationsService) {}
|
||||
|
||||
@Get('djibouti-incidents')
|
||||
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
|
||||
listIncidents(@Query('bookingId') bookingId?: string) {
|
||||
return this.service.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
@Post('djibouti-incidents')
|
||||
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
|
||||
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
|
||||
return this.service.createIncident(dto);
|
||||
}
|
||||
|
||||
@Get('customs/:bookingId')
|
||||
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
|
||||
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.service.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/documents')
|
||||
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
|
||||
uploadCustomsDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: UploadImportCustomsDocumentDto,
|
||||
) {
|
||||
return this.service.uploadCustomsDocument(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/declaration')
|
||||
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
|
||||
recordDeclaration(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: RecordDeclarationDto,
|
||||
) {
|
||||
return this.service.recordDeclaration(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/notify-duties-taxes')
|
||||
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
|
||||
notifyDutiesTaxes(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ImportOperationActionDto,
|
||||
) {
|
||||
return this.service.notifyDutiesTaxes(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/duties-taxes-paid')
|
||||
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
|
||||
markDutiesTaxesPaid(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ImportOperationActionDto,
|
||||
) {
|
||||
return this.service.markDutiesTaxesPaid(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/risk')
|
||||
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
|
||||
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
|
||||
return this.service.assignRisk(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/release-permitted')
|
||||
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
|
||||
markReleasePermitted(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ImportOperationActionDto,
|
||||
) {
|
||||
return this.service.markReleasePermitted(bookingId, dto);
|
||||
}
|
||||
|
||||
@Get('empty-container-returns')
|
||||
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
|
||||
listEmptyReturns() {
|
||||
return this.service.listEmptyReturns();
|
||||
}
|
||||
|
||||
@Post('empty-container-returns')
|
||||
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
|
||||
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
|
||||
return this.service.createEmptyReturn(dto);
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/:id/status')
|
||||
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
|
||||
updateEmptyReturnStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEmptyContainerReturnStatusDto,
|
||||
) {
|
||||
return this.service.updateEmptyReturnStatus(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
|
||||
import { ImportOperationsController } from './import-operations.controller';
|
||||
import { ImportOperationsService } from './import-operations.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DjiboutiIncident,
|
||||
ImportCustomsFinalization,
|
||||
EmptyContainerReturn,
|
||||
]),
|
||||
],
|
||||
controllers: [ImportOperationsController],
|
||||
providers: [ImportOperationsService],
|
||||
exports: [ImportOperationsService],
|
||||
})
|
||||
export class ImportOperationsModule {}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
RecordDeclarationDto,
|
||||
AssignCustomsRiskDto,
|
||||
UpdateEmptyContainerReturnStatusDto,
|
||||
UploadImportCustomsDocumentDto,
|
||||
} from './dto/import-operations.dto';
|
||||
import {
|
||||
DjiboutiIncident,
|
||||
type DjiboutiIncidentType,
|
||||
} from './entities/djibouti-incident.entity';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import {
|
||||
ImportCustomsFinalization,
|
||||
type ImportCustomsDocumentType,
|
||||
} from './entities/import-customs-finalization.entity';
|
||||
|
||||
const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
|
||||
'SEAL_BROKEN',
|
||||
'CONTAINER_OPENED',
|
||||
'CONTAINER_DAMAGED',
|
||||
'FLUID_LEAKING',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ImportOperationsService {
|
||||
constructor(
|
||||
@InjectRepository(DjiboutiIncident)
|
||||
private readonly incidents: Repository<DjiboutiIncident>,
|
||||
@InjectRepository(ImportCustomsFinalization)
|
||||
private readonly customs: Repository<ImportCustomsFinalization>,
|
||||
@InjectRepository(EmptyContainerReturn)
|
||||
private readonly emptyReturns: Repository<EmptyContainerReturn>,
|
||||
) {}
|
||||
|
||||
listIncidents(bookingId?: string) {
|
||||
return this.incidents.find({
|
||||
where: bookingId ? { bookingId } : {},
|
||||
order: { reportedAt: 'DESC', createdAt: 'DESC' } as never,
|
||||
});
|
||||
}
|
||||
|
||||
async createIncident(dto: CreateDjiboutiIncidentDto) {
|
||||
const photos = dto.photos ?? [];
|
||||
if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) {
|
||||
throw new BadRequestException('Photos are required for damage-related Djibouti incidents');
|
||||
}
|
||||
|
||||
const incident = await this.incidents.save(
|
||||
this.incidents.create({
|
||||
bookingId: dto.bookingId,
|
||||
containerNumber: dto.containerNumber ?? null,
|
||||
cargoId: dto.cargoId ?? null,
|
||||
facility: dto.facility ?? null,
|
||||
station: dto.station ?? null,
|
||||
incidentType: dto.incidentType,
|
||||
description: dto.description,
|
||||
photos,
|
||||
reportedBy: dto.reportedBy ?? null,
|
||||
reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`,
|
||||
);
|
||||
console.log(
|
||||
`[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`,
|
||||
);
|
||||
return incident;
|
||||
}
|
||||
|
||||
async getCustoms(bookingId: string) {
|
||||
return this.getOrCreateCustoms(bookingId);
|
||||
}
|
||||
|
||||
async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId };
|
||||
await this.customs.update(row.id, {
|
||||
documents,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
await this.customs.update(row.id, {
|
||||
declarationSerialNumber: dto.declarationSerialNumber,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
await this.customs.update(row.id, {
|
||||
dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
notes: dto.notes ?? row.notes ?? null,
|
||||
});
|
||||
console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`);
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid');
|
||||
await this.customs.update(row.id, {
|
||||
dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
notes: dto.notes ?? row.notes ?? null,
|
||||
});
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
await this.customs.update(row.id, {
|
||||
customsRisk: dto.risk,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`);
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
this.assertReleaseReady(row);
|
||||
await this.customs.update(row.id, {
|
||||
importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(),
|
||||
completedAt: row.completedAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
notes: dto.notes ?? row.notes ?? null,
|
||||
});
|
||||
console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`);
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
listEmptyReturns() {
|
||||
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
|
||||
}
|
||||
|
||||
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
||||
return this.emptyReturns.save(
|
||||
this.emptyReturns.create({
|
||||
containerNumber: dto.containerNumber,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
customerId: dto.customerId ?? null,
|
||||
returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(),
|
||||
facility: dto.facility ?? null,
|
||||
yard: dto.yard ?? null,
|
||||
zone: dto.zone ?? null,
|
||||
condition: dto.condition ?? null,
|
||||
handoverNote: dto.handoverNote ?? null,
|
||||
performedBy: dto.performedBy ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) {
|
||||
const row = await this.emptyReturns.findOne({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Empty container return ${id} not found`);
|
||||
}
|
||||
await this.emptyReturns.update(id, {
|
||||
status: dto.status,
|
||||
wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null,
|
||||
handoverNote: dto.handoverNote ?? row.handoverNote ?? null,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
return this.emptyReturns.findOneOrFail({ where: { id } });
|
||||
}
|
||||
|
||||
private async getOrCreateCustoms(bookingId: string) {
|
||||
const existing = await this.customs.findOne({ where: { bookingId } });
|
||||
if (existing) return existing;
|
||||
return this.customs.save(this.customs.create({ bookingId, documents: {} }));
|
||||
}
|
||||
|
||||
private assertDocument(
|
||||
row: ImportCustomsFinalization,
|
||||
type: ImportCustomsDocumentType,
|
||||
message: string,
|
||||
) {
|
||||
if (!row.documents?.[type]) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private assertReleaseReady(row: ImportCustomsFinalization) {
|
||||
this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release');
|
||||
this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted');
|
||||
if (!row.declarationSerialNumber?.trim()) {
|
||||
throw new BadRequestException('Declaration serial number is required before import release');
|
||||
}
|
||||
if (!row.customsRisk) {
|
||||
throw new BadRequestException('Customs risk must be assigned before import release');
|
||||
}
|
||||
if (!row.dutiesTaxesPaidAt) {
|
||||
throw new BadRequestException('Duties and taxes must be paid before import release');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,12 +265,12 @@ export class InterchangeDocumentsService {
|
||||
)
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
'CONTAINER' AS "itemType",
|
||||
'CONTAINER'::varchar AS "itemType",
|
||||
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
|
||||
NULL AS "bookingCargoId",
|
||||
NULL::uuid AS "bookingCargoId",
|
||||
COALESCE(c.container_number, bc.container_number) AS "containerNumber",
|
||||
c.seal_number AS "sealNumber",
|
||||
NULL AS "cargoId",
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
@@ -288,12 +288,12 @@ export class InterchangeDocumentsService {
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
'CONTAINER' AS "itemType",
|
||||
'CONTAINER'::varchar AS "itemType",
|
||||
bc.id AS "bookingContainerId",
|
||||
NULL AS "bookingCargoId",
|
||||
NULL::uuid AS "bookingCargoId",
|
||||
bc.container_number AS "containerNumber",
|
||||
NULL AS "sealNumber",
|
||||
NULL AS "cargoId",
|
||||
NULL::varchar AS "sealNumber",
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
|
||||
@@ -314,11 +314,11 @@ export class InterchangeDocumentsService {
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
'CARGO' AS "itemType",
|
||||
NULL AS "bookingContainerId",
|
||||
'CARGO'::varchar AS "itemType",
|
||||
NULL::uuid AS "bookingContainerId",
|
||||
cg.id AS "bookingCargoId",
|
||||
NULL AS "containerNumber",
|
||||
NULL AS "sealNumber",
|
||||
NULL::varchar AS "containerNumber",
|
||||
NULL::varchar AS "sealNumber",
|
||||
cg.id AS "cargoId",
|
||||
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
|
||||
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
|
||||
@@ -337,12 +337,12 @@ export class InterchangeDocumentsService {
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
|
||||
NULL AS "bookingContainerId",
|
||||
NULL AS "bookingCargoId",
|
||||
NULL AS "containerNumber",
|
||||
NULL AS "sealNumber",
|
||||
NULL AS "cargoId",
|
||||
(CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
|
||||
NULL::uuid AS "bookingContainerId",
|
||||
NULL::uuid AS "bookingCargoId",
|
||||
NULL::varchar AS "containerNumber",
|
||||
NULL::varchar AS "sealNumber",
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
a.cargo_total_weight_vgm AS "weight",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [
|
||||
'DELIVERY_ORDER',
|
||||
'PORT_INVOICE',
|
||||
'DJIBOUTI_T1',
|
||||
'ETHIOPIA_T1',
|
||||
'RAILWAY_BILL',
|
||||
] as const;
|
||||
|
||||
export type ImportDjiboutiDocumentType = (typeof IMPORT_DJIBOUTI_DOCUMENT_TYPES)[number];
|
||||
|
||||
export class UploadImportDjiboutiDocumentDto {
|
||||
@ApiProperty({ enum: IMPORT_DJIBOUTI_DOCUMENT_TYPES })
|
||||
@IsIn(IMPORT_DJIBOUTI_DOCUMENT_TYPES)
|
||||
documentType!: ImportDjiboutiDocumentType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileUrl?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class ImportDjiboutiActionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
|
||||
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| 'DELIVERY_ORDER'
|
||||
| 'PORT_INVOICE'
|
||||
| 'DJIBOUTI_T1'
|
||||
| 'ETHIOPIA_T1'
|
||||
| 'RAILWAY_BILL';
|
||||
|
||||
export interface ImportDjiboutiDocumentRecord {
|
||||
fileId?: string | null;
|
||||
fileUrl?: string | null;
|
||||
reference?: string | null;
|
||||
uploadedAt: string;
|
||||
uploadedBy?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'import_djibouti_operations' })
|
||||
@Index(['trainScheduleId'], { unique: true })
|
||||
export class ImportDjiboutiOperation extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@OneToOne(() => TrainSchedule)
|
||||
@JoinColumn({ name: 'train_schedule_id' })
|
||||
trainSchedule?: TrainSchedule;
|
||||
|
||||
@Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
documents!: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||
|
||||
@Column({ name: 'gatepass_granted_at', type: 'timestamptz', nullable: true })
|
||||
gatepassGrantedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true })
|
||||
readyForLoadingAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_on_train_at', type: 'timestamptz', nullable: true })
|
||||
loadedOnTrainAt?: Date | null;
|
||||
|
||||
@Column({ name: 'departed_from_djibouti_at', type: 'timestamptz', nullable: true })
|
||||
departedFromDjiboutiAt?: Date | null;
|
||||
|
||||
@Column({ name: 'load_list_generated_at', type: 'timestamptz', nullable: true })
|
||||
loadListGeneratedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
|
||||
@@ -30,6 +32,10 @@ import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.d
|
||||
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
import {
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from "./dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
@@ -304,6 +310,101 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/import-djibouti")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
|
||||
getImportDjiboutiOperation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getImportDjiboutiOperation(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/documents")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Upload/check an import Djibouti-side document" })
|
||||
uploadImportDjiboutiDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
return this.trainSchedulingService.uploadImportDjiboutiDocument(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/gatepass-granted")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" })
|
||||
grantImportDjiboutiGatepass(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.grantImportDjiboutiGatepass(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/ready-for-loading")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Mark import train ready for loading at Djibouti" })
|
||||
markImportReadyForLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.markImportReadyForLoading(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/loaded-on-train")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" })
|
||||
confirmImportLoadedOnTrain(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/depart")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
|
||||
departImportFromDjibouti(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.departImportFromDjibouti(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/load-list")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Generate import load list / marshalling document summary" })
|
||||
generateImportLoadList(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.generateImportLoadList(id, dto);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/import-djibouti/load-list/document")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Download printable import load list / marshalling PDF" })
|
||||
async importLoadListDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.trainSchedulingService.importLoadListDocument(id);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/export/load-list/document")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })
|
||||
async exportLoadListDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.trainSchedulingService.exportLoadListDocument(id);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
// ---- batch / booking-window staff actions ----
|
||||
|
||||
@Post("schedules/:id/run-batch")
|
||||
|
||||
@@ -17,6 +17,7 @@ import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
@@ -38,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
Container,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainCheckpointEvent,
|
||||
ImportDjiboutiOperation,
|
||||
]),
|
||||
forwardRef(() => BookingsModule),
|
||||
NotificationsModule,
|
||||
|
||||
@@ -151,6 +151,9 @@ describe('TrainSchedulingService', () => {
|
||||
autoUnloadArrivedBookings: jest.fn(),
|
||||
autoUnloadExportAtDjibouti: jest.fn(),
|
||||
} as never,
|
||||
{
|
||||
htmlToPdfBuffer: jest.fn(),
|
||||
} as never,
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import {
|
||||
ImportDjiboutiOperation,
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from './entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
@@ -97,6 +106,7 @@ import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
@@ -170,6 +180,7 @@ export class TrainSchedulingService {
|
||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -767,6 +778,7 @@ export class TrainSchedulingService {
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -806,9 +818,555 @@ export class TrainSchedulingService {
|
||||
.execute();
|
||||
});
|
||||
|
||||
if (this.isImportDjiboutiSchedule(schedule)) {
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
|
||||
});
|
||||
console.log(
|
||||
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||||
}
|
||||
|
||||
async uploadImportDjiboutiDocument(
|
||||
scheduleId: string,
|
||||
dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const documents = {
|
||||
...(operation.documents ?? {}),
|
||||
[dto.documentType]: {
|
||||
fileId: dto.fileId ?? null,
|
||||
fileUrl: dto.fileUrl ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: dto.performedBy ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
documents,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const missing = this.missingImportDjiboutiDocuments(operation);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
|
||||
);
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
|
||||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||||
await this.dispatchSchedule(schedule.id);
|
||||
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||||
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
|
||||
}
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const generatedAt = operation.loadListGeneratedAt ?? new Date();
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
loadListGeneratedAt: generatedAt,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
route: schedule.route?.name ?? null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
};
|
||||
}
|
||||
|
||||
async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const loadList = await this.generateImportLoadList(scheduleId, {
|
||||
performedBy: 'DOCUMENT_GENERATION',
|
||||
});
|
||||
const html = this.buildImportLoadListHtml(loadList);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||||
return {
|
||||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isExportSchedule(schedule)) {
|
||||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
||||
}
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
const rows = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((wagon) =>
|
||||
(wagon.allocations ?? []).map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
|
||||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
</tr>`;
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Export Marshalling Document</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Export Marshalling Document / Load List</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
|
||||
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
|
||||
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
|
||||
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seq</th>
|
||||
<th>Wagon No</th>
|
||||
<th>Wagon Type</th>
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
<th>Seal No</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
Loading and dispatch staff must verify wagon identity, seal number, container number,
|
||||
cargo type, and customer booking against the physical consist before departure.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Prepared person / date</div>
|
||||
<div class="line">Check person / date</div>
|
||||
<div class="line">Operations authorization / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private isExportSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return direction === 'EXPORT';
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||
const status = loadList.operation.status;
|
||||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = loadList.wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) =>
|
||||
wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
),
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Import Load List / Marshalling Document</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.doc { min-height: 100vh; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
|
||||
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
|
||||
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
|
||||
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||||
.tile strong { font-size: 13px; }
|
||||
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
|
||||
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
|
||||
.pending { background: #f8fafc; color: #64748b; }
|
||||
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Import Load List /<br />Marshalling Document</h1>
|
||||
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
|
||||
Generated: ${esc(date(loadList.generatedAt))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
|
||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
|
||||
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
|
||||
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
|
||||
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
|
||||
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
|
||||
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
|
||||
</div>
|
||||
|
||||
<h2>Wagon Marshalling Allocation</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seq</th>
|
||||
<th>Wagon</th>
|
||||
<th>Booking</th>
|
||||
<th>Load</th>
|
||||
<th>Container numbers</th>
|
||||
<th class="num">Weight T</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
Gate and loading staff must verify this document against the granted gatepass,
|
||||
railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Prepared by Djibouti operations</div>
|
||||
<div class="line">Train loading supervisor</div>
|
||||
<div class="line">EDR operations authorization</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private safeDocumentName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||
}
|
||||
|
||||
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) return;
|
||||
const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
|
||||
where: { trainScheduleId: schedule.id },
|
||||
});
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation?.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
}
|
||||
|
||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return (
|
||||
direction === 'IMPORT' &&
|
||||
this.isDjiboutiPortDestination(
|
||||
`${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
|
||||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
if (existing) return existing;
|
||||
return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
|
||||
}
|
||||
|
||||
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
|
||||
const documents = operation?.documents ?? {};
|
||||
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
|
||||
}
|
||||
|
||||
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
|
||||
if (!operation?.gatepassGrantedAt) {
|
||||
throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
|
||||
}
|
||||
}
|
||||
|
||||
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
|
||||
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
|
||||
return {
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
status: {
|
||||
documentsComplete: missingDocuments.length === 0,
|
||||
missingDocuments,
|
||||
gatepassGranted: Boolean(operation.gatepassGrantedAt),
|
||||
readyForLoading: Boolean(operation.readyForLoadingAt),
|
||||
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
|
||||
departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
|
||||
loadListGenerated: Boolean(operation.loadListGeneratedAt),
|
||||
},
|
||||
documents: operation.documents ?? {},
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? null,
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
|
||||
loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
|
||||
performedBy: operation.performedBy ?? null,
|
||||
notes: operation.notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a fixed train number on dispatch. The number is drawn from the pool
|
||||
* for the train's dominant cargo type (container vs bulk) and trade direction
|
||||
|
||||
@@ -49,4 +49,12 @@ export class CreateVehicleDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trailerPlateNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
estimatedDistanceKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
actualDistanceKm?: number;
|
||||
}
|
||||
|
||||
@@ -62,4 +62,10 @@ export class Vehicle extends BaseEntity {
|
||||
|
||||
@Column({ name: 'assigned_driver_name', nullable: true })
|
||||
assignedDriverName?: string;
|
||||
|
||||
@Column({ name: 'estimated_distance_km', type: 'numeric', nullable: true })
|
||||
estimatedDistanceKm?: number;
|
||||
|
||||
@Column({ name: 'actual_distance_km', type: 'numeric', nullable: true })
|
||||
actualDistanceKm?: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
|
||||
export class TruckEntranceDto {
|
||||
@ApiPropertyOptional()
|
||||
@@ -22,6 +24,11 @@ export class TruckEntranceDto {
|
||||
@IsString()
|
||||
tin?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerPhone?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
truckPlateNumber!: string;
|
||||
@@ -174,8 +181,11 @@ export class BulkReceiveDto {
|
||||
@IsUUID('all', { each: true })
|
||||
bookingIds!: string[];
|
||||
|
||||
@ApiProperty({ type: TruckEntranceDto })
|
||||
truckEntrance!: TruckEntranceDto;
|
||||
@ApiPropertyOptional({ type: TruckEntranceDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => TruckEntranceDto)
|
||||
truckEntrance?: TruckEntranceDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -33,4 +33,14 @@ export class PayInvoiceBodyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Pickup driver name to notify after payment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Pickup driver phone to notify after payment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
import { TruckEntranceDto } from './bulk-receive.dto';
|
||||
|
||||
export class ReceiveWarehouseInventoryDto {
|
||||
@@ -57,6 +59,8 @@ export class ReceiveWarehouseInventoryDto {
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({ type: TruckEntranceDto })
|
||||
@ValidateNested()
|
||||
@Type(() => TruckEntranceDto)
|
||||
truckEntrance!: TruckEntranceDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||
export class ReleaseOrderDto {
|
||||
@@ -17,4 +17,77 @@ export class ReleaseOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
truckPlateNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trailerPlateNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverLicense?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
truckType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
gateInTime?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tareWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
grossWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
netWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
gateOutTime?: string;
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ export class SchedulingReadFacade {
|
||||
}
|
||||
if (filter.destination) {
|
||||
params.push(`%${filter.destination}%`);
|
||||
where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`);
|
||||
where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`);
|
||||
}
|
||||
if (filter.dateFrom) {
|
||||
params.push(filter.dateFrom);
|
||||
@@ -351,7 +351,7 @@ export class SchedulingReadFacade {
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
dy.name AS "destinationName",
|
||||
dy.label AS "destinationName",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
ts.scheduled_departure_date AS "departureTime",
|
||||
|
||||
@@ -86,6 +86,12 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.readyToLoadExport();
|
||||
}
|
||||
|
||||
@Get('received-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
|
||||
receivedExport() {
|
||||
return this.inventoryService.receivedExport();
|
||||
}
|
||||
|
||||
@Get('loaded-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||
loadedExport() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
@@ -6,6 +6,7 @@ import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -191,6 +192,13 @@ export interface EligibleBookingRow {
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
containerQuantity: number | null;
|
||||
containerPackagingType: string | null;
|
||||
cargoDescription: string | null;
|
||||
lastMileRequested: boolean;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
@@ -205,6 +213,10 @@ export interface EligibleBookingRow {
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
firstMileDriverName: string | null;
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -289,6 +301,8 @@ export interface ImportUnloadedRow {
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
private readonly logger = new Logger(WarehouseInventoryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
@@ -301,6 +315,7 @@ export class WarehouseInventoryService {
|
||||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -644,12 +659,20 @@ export class WarehouseInventoryService {
|
||||
b.reference AS "reference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
b.freight_type AS "freightType",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.status AS "status",
|
||||
@@ -659,7 +682,14 @@ export class WarehouseInventoryService {
|
||||
fm.status AS "firstMileStatus",
|
||||
fm.vehicle_id AS "firstMileVehicleId",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber"
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -667,6 +697,22 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
SUM(booking_container.quantity)::int AS container_quantity,
|
||||
CASE
|
||||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||||
ELSE 'OTHER_CONTAINER'
|
||||
END AS container_packaging_type
|
||||
FROM freight.booking_container booking_container
|
||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||
) bc ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
@@ -675,6 +721,7 @@ export class WarehouseInventoryService {
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
AND inv.id IS NULL
|
||||
@@ -695,7 +742,6 @@ export class WarehouseInventoryService {
|
||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateLocation(manager, {
|
||||
@@ -711,23 +757,61 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
|
||||
`SELECT b.reference AS "reference",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus"
|
||||
fm.status AS "firstMileStatus",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
SUM(booking_container.quantity)::int AS container_quantity,
|
||||
CASE
|
||||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||||
ELSE 'OTHER_CONTAINER'
|
||||
END AS container_packaging_type
|
||||
FROM freight.booking_container booking_container
|
||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||
) bc ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
@@ -758,11 +842,17 @@ export class WarehouseInventoryService {
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const truckEntrance = dto.truckEntrance
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
||||
: undefined;
|
||||
if (dto.direction === 'EXPORT') {
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
}
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
truckEntrance: dto.truckEntrance,
|
||||
truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
@@ -770,7 +860,7 @@ export class WarehouseInventoryService {
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId,
|
||||
quantity: 1,
|
||||
quantity: Number(booking.containerQuantity) || 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
@@ -783,12 +873,23 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
description: truckEntrance?.truckPlateNumber
|
||||
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
|
||||
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await this.notifyOwnerInventoryReceived({
|
||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
warehouseId: dto.warehouseId,
|
||||
});
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
}
|
||||
@@ -887,6 +988,11 @@ export class WarehouseInventoryService {
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||||
}
|
||||
|
||||
/** EXPORT inventory received at the facility and awaiting inspection. */
|
||||
async receivedExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('RECEIVED');
|
||||
}
|
||||
|
||||
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
|
||||
async loadedExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('LOADED');
|
||||
@@ -1166,7 +1272,7 @@ export class WarehouseInventoryService {
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
dy.code AS "destinationCode",
|
||||
dy.name AS "destinationName"
|
||||
dy.label AS "destinationName"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
@@ -1307,6 +1413,20 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const currentStatus = item.inventoryStatus ?? item.bookingStatus;
|
||||
if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') {
|
||||
seenInventory.add(item.inventoryId);
|
||||
result.unloadedCount += 1;
|
||||
result.results.push({
|
||||
bookingId: item.bookingId,
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
inventoryId: item.inventoryId,
|
||||
containerNumber: item.containerNumber,
|
||||
status: 'UNLOADED_AT_DJIBOUTI_PORT',
|
||||
message: 'Already unloaded at Djibouti Port',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
|
||||
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
|
||||
continue;
|
||||
@@ -1483,18 +1603,29 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
if (dto.bookingId) {
|
||||
await this.assertBookingExists(manager, dto.bookingId);
|
||||
const bookingSource = dto.bookingId
|
||||
? await this.getBookingTruckEntranceSource(manager, dto.bookingId)
|
||||
: null;
|
||||
if (dto.bookingId && !bookingSource?.reference) {
|
||||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||
}
|
||||
const quantity = bookingSource
|
||||
? Number(bookingSource.containerQuantity) || 1
|
||||
: Number(dto.quantity) || 0;
|
||||
const weight = bookingSource
|
||||
? Number(bookingSource.weight) || 0
|
||||
: Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(quantity) : 0;
|
||||
const truckEntrance = dto.bookingId
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {})
|
||||
: dto.truckEntrance;
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
|
||||
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||||
@@ -1505,7 +1636,7 @@ export class WarehouseInventoryService {
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes: dto.notes?.trim() || 'Single booking received',
|
||||
truckEntrance: dto.truckEntrance,
|
||||
truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
@@ -1516,7 +1647,7 @@ export class WarehouseInventoryService {
|
||||
cargoId: dto.cargoId ?? null,
|
||||
containerId: dto.containerId ?? null,
|
||||
goodsId: dto.goodsId ?? null,
|
||||
quantity: Number(dto.quantity) || 0,
|
||||
quantity,
|
||||
weight,
|
||||
volume: dto.volume ?? null,
|
||||
status: 'RECEIVED',
|
||||
@@ -1529,14 +1660,23 @@ export class WarehouseInventoryService {
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await this.notifyOwnerInventoryReceived({
|
||||
phone: truckEntrance.customerPhone,
|
||||
ownerName: truckEntrance.ownerName,
|
||||
bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId,
|
||||
grnNumber,
|
||||
direction: bookingDirection,
|
||||
warehouseId: dto.warehouseId,
|
||||
});
|
||||
|
||||
return saved.id;
|
||||
});
|
||||
@@ -1776,11 +1916,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
||||
const reference = dto.reference?.trim() || null;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
releaseOrderReference: reference,
|
||||
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
@@ -1807,6 +1949,7 @@ export class WarehouseInventoryService {
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
inv.status,
|
||||
inv.notes,
|
||||
b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.status AS "bookingStatus",
|
||||
@@ -1867,6 +2010,7 @@ export class WarehouseInventoryService {
|
||||
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
|
||||
inventoryStatus: row?.status ?? null,
|
||||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||||
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2372,6 +2516,7 @@ export class WarehouseInventoryService {
|
||||
zone: string | null;
|
||||
inventoryStatus: string | null;
|
||||
clearanceStatus: string;
|
||||
exitInspectionSummary?: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -2402,6 +2547,7 @@ export class WarehouseInventoryService {
|
||||
['Zone', data.zone],
|
||||
['Inventory Status', data.inventoryStatus],
|
||||
['Clearance Status', data.clearanceStatus],
|
||||
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
||||
];
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -2492,16 +2638,6 @@ export class WarehouseInventoryService {
|
||||
return { warehouse, yard, zone };
|
||||
}
|
||||
|
||||
private async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
|
||||
const rows = await manager.query(
|
||||
'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||
[bookingId],
|
||||
);
|
||||
if (!rows || rows.length === 0) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private appendNote(existing: string | null | undefined, note: string): string {
|
||||
const trimmed = existing?.trim();
|
||||
return trimmed ? `${trimmed}\n${note}` : note;
|
||||
@@ -2522,51 +2658,265 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private mergeSystemTruckEntrance(
|
||||
submitted: TruckEntranceDto,
|
||||
booking: {
|
||||
reference?: string | null;
|
||||
customer?: string | null;
|
||||
customerTin?: string | null;
|
||||
customerPhone?: string | null;
|
||||
containerNumber?: string | null;
|
||||
containerQuantity?: number | string | null;
|
||||
containerPackagingType?: string | null;
|
||||
cargoDescription?: string | null;
|
||||
weight?: number | string | null;
|
||||
firstMileTruckPlateNumber?: string | null;
|
||||
firstMileTrailerPlateNumber?: string | null;
|
||||
firstMileDriverName?: string | null;
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
},
|
||||
): TruckEntranceDto {
|
||||
return {
|
||||
...submitted,
|
||||
ownerName: booking.customer?.trim() || submitted.ownerName,
|
||||
edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId,
|
||||
tin: booking.customerTin?.trim() || submitted.tin,
|
||||
customerPhone: booking.customerPhone?.trim() || submitted.customerPhone,
|
||||
assignedEquipmentNumber: booking.containerNumber?.trim() || submitted.assignedEquipmentNumber,
|
||||
itemDescription: booking.cargoDescription?.trim() || submitted.itemDescription,
|
||||
packagingType: booking.containerPackagingType?.trim() || submitted.packagingType,
|
||||
unitCount:
|
||||
booking.containerQuantity !== undefined && booking.containerQuantity !== null
|
||||
? Number(booking.containerQuantity)
|
||||
: submitted.unitCount,
|
||||
grossWeightKg:
|
||||
booking.weight !== undefined && booking.weight !== null
|
||||
? Number(booking.weight)
|
||||
: submitted.grossWeightKg,
|
||||
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
|
||||
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
|
||||
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
|
||||
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
|
||||
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
|
||||
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
|
||||
};
|
||||
}
|
||||
|
||||
private async getBookingTruckEntranceSource(
|
||||
manager: EntityManager,
|
||||
bookingId: string,
|
||||
): Promise<{
|
||||
reference?: string | null;
|
||||
customer?: string | null;
|
||||
customerTin?: string | null;
|
||||
customerPhone?: string | null;
|
||||
containerNumber?: string | null;
|
||||
containerQuantity?: number | string | null;
|
||||
containerPackagingType?: string | null;
|
||||
cargoDescription?: string | null;
|
||||
weight?: number | string | null;
|
||||
firstMileTruckPlateNumber?: string | null;
|
||||
firstMileTrailerPlateNumber?: string | null;
|
||||
firstMileDriverName?: string | null;
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
}> {
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.reference AS "reference",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
SUM(booking_container.quantity)::int AS container_quantity,
|
||||
CASE
|
||||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||||
ELSE 'OTHER_CONTAINER'
|
||||
END AS container_packaging_type
|
||||
FROM freight.booking_container booking_container
|
||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||
) bc ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
return booking ?? {};
|
||||
}
|
||||
|
||||
private async notifyOwnerInventoryReceived(params: {
|
||||
phone?: string | null;
|
||||
ownerName?: string | null;
|
||||
bookingReference?: string | null;
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
warehouseId?: string | null;
|
||||
}): Promise<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 {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
}
|
||||
|
||||
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
|
||||
const hasExitInspection =
|
||||
Boolean(dto.truckPlateNumber?.trim()) ||
|
||||
Boolean(dto.trailerPlateNumber?.trim()) ||
|
||||
Boolean(dto.driverName?.trim()) ||
|
||||
Boolean(dto.driverLicense?.trim()) ||
|
||||
Boolean(dto.driverPhone?.trim()) ||
|
||||
Boolean(dto.truckType?.trim()) ||
|
||||
Boolean(dto.containerNumber?.trim()) ||
|
||||
dto.tareWeight !== undefined ||
|
||||
dto.grossWeight !== undefined ||
|
||||
dto.netWeight !== undefined ||
|
||||
Boolean(dto.gateInTime) ||
|
||||
Boolean(dto.gateOutTime);
|
||||
|
||||
if (!hasExitInspection) return null;
|
||||
|
||||
if (!dto.truckPlateNumber?.trim()) {
|
||||
throw new BadRequestException('Truck plate number is required for exit inspection');
|
||||
}
|
||||
if (!dto.driverName?.trim()) {
|
||||
throw new BadRequestException('Driver name is required for exit inspection');
|
||||
}
|
||||
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
|
||||
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
|
||||
}
|
||||
|
||||
const tareWeight = Number(dto.tareWeight);
|
||||
const grossWeight = Number(dto.grossWeight);
|
||||
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
|
||||
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
|
||||
|
||||
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
||||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
'[Exit Inspection]',
|
||||
dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null,
|
||||
dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null,
|
||||
`Truck Plate: ${dto.truckPlateNumber.trim()}`,
|
||||
dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null,
|
||||
`Driver: ${dto.driverName.trim()}`,
|
||||
dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null,
|
||||
dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null,
|
||||
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||
`Tare Weight: ${tareWeight} kg`,
|
||||
`Gross Weight: ${grossWeight} kg`,
|
||||
`Net Weight: ${computedNetWeight} kg`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
];
|
||||
|
||||
return rows.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private extractExitInspectionNote(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes.lastIndexOf(marker);
|
||||
if (index < 0) return null;
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
}
|
||||
|
||||
private buildReceiveNote(input: {
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
notes?: string | null;
|
||||
truckEntrance: TruckEntranceDto;
|
||||
truckEntrance?: TruckEntranceDto;
|
||||
}): string {
|
||||
const truck = input.truckEntrance;
|
||||
const rows = [
|
||||
`GRN Number: ${input.grnNumber}`,
|
||||
input.direction ? `Direction: ${input.direction}` : null,
|
||||
truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
||||
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||||
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||||
truck.tin ? `TIN: ${truck.tin}` : null,
|
||||
`Truck Plate: ${truck.truckPlateNumber}`,
|
||||
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||
truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
||||
truck.truckType ? `Truck Type: ${truck.truckType}` : null,
|
||||
`Driver: ${truck.driverName}`,
|
||||
`Driver Phone: ${truck.driverPhone}`,
|
||||
truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
`Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`,
|
||||
truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||
truck.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
||||
truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||
truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||
truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||
truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||
truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||
truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||
truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||
truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||
truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
||||
truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
||||
truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
||||
truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
||||
truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||||
truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||||
truck?.tin ? `TIN: ${truck.tin}` : null,
|
||||
truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
|
||||
truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null,
|
||||
truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||
truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||
truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
||||
truck?.truckType ? `Truck Type: ${truck.truckType}` : null,
|
||||
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
||||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||
truck?.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
||||
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||
truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
||||
truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
||||
truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
||||
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
|
||||
];
|
||||
return rows.filter(Boolean).join('\n');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import {
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoiceStatus,
|
||||
@@ -22,6 +23,8 @@ export interface PayInvoiceDto {
|
||||
amount: number;
|
||||
method?: string;
|
||||
reference?: string;
|
||||
driverName?: string;
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
/** Invoices that still owe money and therefore block terminal release. */
|
||||
@@ -46,12 +49,15 @@ export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<Invoi
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInvoiceService {
|
||||
private readonly logger = new Logger(WarehouseInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly documents: WarehouseReleaseDocumentService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
@@ -150,7 +156,9 @@ export class WarehouseInvoiceService {
|
||||
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. */
|
||||
@@ -246,7 +254,9 @@ export class WarehouseInvoiceService {
|
||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||
payments,
|
||||
});
|
||||
return updated as WarehouseFeeInvoice;
|
||||
const paidInvoice = updated as WarehouseFeeInvoice;
|
||||
await this.notifyWarehouseFeePayment(paidInvoice, dto);
|
||||
return paidInvoice;
|
||||
}
|
||||
|
||||
// ── 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(
|
||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
|
||||
@@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService {
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const text = this.htmlToPlainText(html);
|
||||
const lines = this.wrapLines(text, 86).slice(0, 52);
|
||||
const body = lines
|
||||
.map((line, index) => {
|
||||
const y = 770 - index * 12;
|
||||
const isTitle = index < 2 || /clearance|release order/i.test(line);
|
||||
const size = index === 0 ? 13 : isTitle ? 11 : 9.6;
|
||||
const font = isTitle ? 'F2' : 'F1';
|
||||
return this.textOp(line, 48, y, size, font);
|
||||
})
|
||||
.join('\n');
|
||||
const doc = this.extractReleaseDocument(html);
|
||||
const body: string[] = [
|
||||
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
||||
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
|
||||
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
|
||||
this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8),
|
||||
this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2),
|
||||
...this.wrapLines(doc.notice, 68)
|
||||
.slice(0, 4)
|
||||
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
|
||||
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
|
||||
];
|
||||
|
||||
let y = 586;
|
||||
const rowHeight = 20;
|
||||
for (const [label, value] of doc.rows.slice(0, 14)) {
|
||||
body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6));
|
||||
body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6));
|
||||
body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16'));
|
||||
body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16'));
|
||||
y -= rowHeight;
|
||||
}
|
||||
|
||||
body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18'));
|
||||
body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7));
|
||||
body.push(
|
||||
...this.wrapLines(doc.clause, 92)
|
||||
.slice(0, 4)
|
||||
.map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')),
|
||||
);
|
||||
|
||||
const stream = [
|
||||
this.lineOp(48, 752, 548, 752),
|
||||
body,
|
||||
this.circularSealOps(184, 154),
|
||||
this.lineOp(48, 92, 278, 92, '0 0 0'),
|
||||
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'),
|
||||
this.lineOp(326, 92, 548, 92, '0 0 0'),
|
||||
this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'),
|
||||
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
|
||||
...body,
|
||||
this.lineOp(36, 60, 218, 60, '0 0 0', 1),
|
||||
this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
|
||||
this.circularSealOps(286, 62, 38),
|
||||
this.lineOp(341, 60, 559, 60, '0 0 0', 1),
|
||||
this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
|
||||
].join('\n');
|
||||
|
||||
const objects = [
|
||||
@@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService {
|
||||
return Buffer.from(pdf, 'latin1');
|
||||
}
|
||||
|
||||
private extractReleaseDocument(html: string): {
|
||||
reference: string;
|
||||
issuedAt: string;
|
||||
notice: string;
|
||||
clause: string;
|
||||
rows: Array<[string, string]>;
|
||||
} {
|
||||
const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim();
|
||||
const reference = textFromHtml(html.match(/<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 {
|
||||
return html
|
||||
.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`;
|
||||
}
|
||||
|
||||
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string {
|
||||
return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string {
|
||||
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
}
|
||||
|
||||
private circularSealOps(cx: number, cy: number): string {
|
||||
private rectOp(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fillColor = '1 1 1',
|
||||
strokeColor = '0.08 0.32 0.18',
|
||||
lineWidth = 0.8,
|
||||
): string {
|
||||
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
|
||||
}
|
||||
|
||||
private circularSealOps(cx: number, cy: number, radius = 51): string {
|
||||
return [
|
||||
'q',
|
||||
'0.08 0.32 0.18 RG',
|
||||
'0.08 0.32 0.18 rg',
|
||||
'2.2 w',
|
||||
this.circlePath(cx, cy, 51),
|
||||
this.circlePath(cx, cy, radius),
|
||||
'S',
|
||||
'0.8 w',
|
||||
this.circlePath(cx, cy, 41),
|
||||
this.circlePath(cx, cy, radius - 10),
|
||||
'S',
|
||||
this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
|
||||
'Q',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
@@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
FilesModule,
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
@@ -122,6 +124,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseAllocationService,
|
||||
WarehouseFeeService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
WarehouseReleaseDocumentService,
|
||||
],
|
||||
})
|
||||
export class WarehousesModule {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from '../seed/approved-first-lastmile-demo-bookings.seeder';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const seeder = app.get(ApprovedFirstLastMileDemoBookingsSeeder);
|
||||
await seeder.run();
|
||||
console.log('Approved first/last-mile demo bookings seeded.');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Approved first/last-mile demo booking seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01';
|
||||
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'];
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-01',
|
||||
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
|
||||
arrivalOffsetHours: 1,
|
||||
},
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-02',
|
||||
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
|
||||
arrivalOffsetHours: 2,
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
@@ -44,13 +54,6 @@ async function main() {
|
||||
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
|
||||
if (existingSchedule) {
|
||||
console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
@@ -83,10 +86,6 @@ async function main() {
|
||||
throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const departure = new Date(now - 6 * 60 * 60 * 1000);
|
||||
const arrival = new Date(now - 60 * 60 * 1000);
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
@@ -97,83 +96,106 @@ async function main() {
|
||||
}),
|
||||
));
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700,
|
||||
totalLengthMeters: 360,
|
||||
wagonCount: 12,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
const now = Date.now();
|
||||
const seededSchedules: TrainSchedule[] = [];
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: TRAIN_NUMBER,
|
||||
}),
|
||||
);
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||
if (existingSchedule) {
|
||||
console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
seededSchedules.push(existingSchedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [index, reference] of BOOKING_REFS.entries()) {
|
||||
const weight = 5200 + index * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
|
||||
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700 + trainIndex * 80,
|
||||
totalLengthMeters: 360 + trainIndex * 20,
|
||||
wagonCount: 12 + trainIndex,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(now - 4 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(now - 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(now - 90 * 60 * 1000),
|
||||
dispatchedAt: new Date(now - 70 * 60 * 1000),
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'EXPORT',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
|
||||
dispatchedAt: departure,
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
seededSchedules.push(schedule);
|
||||
}
|
||||
|
||||
console.log('Export Djibouti interchange demo seeded.');
|
||||
console.log(`Train number: ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
for (const schedule of seededSchedules) {
|
||||
console.log(`Train number: ${schedule.trainNumber}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
}
|
||||
console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
|
||||
} finally {
|
||||
await app.close();
|
||||
|
||||
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'IMP-DJB-NAGAD-01',
|
||||
bookingRefs: ['IMP-DJB-NGD-001', 'IMP-DJB-NGD-002', 'IMP-DJB-NGD-003'],
|
||||
departureOffsetHours: 4,
|
||||
},
|
||||
{
|
||||
trainNumber: 'IMP-DJB-NAGAD-02',
|
||||
bookingRefs: ['IMP-DJB-NGD-004', 'IMP-DJB-NGD-005', 'IMP-DJB-NGD-006'],
|
||||
departureOffsetHours: 8,
|
||||
},
|
||||
];
|
||||
|
||||
function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const dataSource = app.get(DataSource);
|
||||
const yardRepo = dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = dataSource.getRepository(CargoType);
|
||||
const locomotiveRepo = dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = dataSource.getRepository(TrainSet);
|
||||
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
||||
const bookingRepo = dataSource.getRepository(Booking);
|
||||
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
|
||||
const importOperationRepo = dataSource.getRepository(ImportDjiboutiOperation);
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const destinationYard =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
}),
|
||||
));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
|
||||
const missing = [
|
||||
!originYard ? 'NAGAD/Djibouti origin yard' : '',
|
||||
!destinationYard ? 'INDODE destination yard' : '',
|
||||
!serviceType ? 'service type' : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`Cannot seed Djibouti-side import demo, missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-IMP-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'ICD-DEMO-IMP-LOCO',
|
||||
name: 'Djibouti Import Demo Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'IMPORT_READY',
|
||||
currentYardId: originYard!.id,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const seededSchedules: TrainSchedule[] = [];
|
||||
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({
|
||||
where: { trainNumber: demo.trainNumber },
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
console.log(`Djibouti-side import demo already seeded: ${demo.trainNumber}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
seededSchedules.push(existingSchedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
const departure = addHours(now, demo.departureOffsetHours);
|
||||
const arrival = addHours(departure, 12);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 900 + trainIndex * 120,
|
||||
totalLengthMeters: 430 + trainIndex * 25,
|
||||
wagonCount: 18 + trainIndex * 2,
|
||||
status: 'ASSIGNED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
status: 'SCHEDULED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'IMPORT',
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 6800 + trainIndex * 900 + bookingIndex * 750;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: departure,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Djibouti import demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
priorityScore: 80 - trainIndex * 5 - bookingIndex,
|
||||
trainScheduleId: schedule.id,
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
scheduledAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await importOperationRepo.save(
|
||||
importOperationRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
documents: {
|
||||
DELIVERY_ORDER: {
|
||||
reference: `DO-${demo.trainNumber}`,
|
||||
uploadedAt: now.toISOString(),
|
||||
uploadedBy: 'Demo Seeder',
|
||||
notes: 'Demo delivery order for Djibouti-side import flow',
|
||||
},
|
||||
RAILWAY_BILL: {
|
||||
reference: `RB-${demo.trainNumber}`,
|
||||
uploadedAt: now.toISOString(),
|
||||
uploadedBy: 'Demo Seeder',
|
||||
notes: 'Demo railway bill for Djibouti-side import flow',
|
||||
},
|
||||
},
|
||||
performedBy: 'Demo Seeder',
|
||||
notes: '[ICD-DEMO] Nagad to Indode import train for Djibouti-side flow',
|
||||
}),
|
||||
);
|
||||
|
||||
seededSchedules.push(schedule);
|
||||
}
|
||||
|
||||
console.log('Djibouti-side import demo seeded.');
|
||||
console.log(`Corridor: ${originYard!.code} -> ${destinationYard!.code}`);
|
||||
for (const schedule of seededSchedules) {
|
||||
console.log(`Train number: ${schedule.trainNumber}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
console.log(`Backoffice URL: /dashboard/operations/train-scheduling-v2/${schedule.id}`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Djibouti-side import demo seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
|
||||
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
|
||||
|
||||
function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dataSource = await AppDataSource.initialize();
|
||||
|
||||
try {
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const yardRepo = manager.getRepository(Yard);
|
||||
const locomotiveRepo = manager.getRepository(Locomotive);
|
||||
const trainSetRepo = manager.getRepository(TrainSet);
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
|
||||
const negad =
|
||||
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'NEGAD',
|
||||
label: 'Negad',
|
||||
country: 'Djibouti',
|
||||
isActive: true,
|
||||
displayOrder: 5,
|
||||
}),
|
||||
));
|
||||
|
||||
const indode =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
}),
|
||||
));
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'NEGAD-INDODE-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'NEGAD-INDODE-LOCO',
|
||||
name: 'Negad to Indode Demo Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: indode.id,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const departure = addHours(now, -12);
|
||||
const arrival = now;
|
||||
|
||||
let schedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
|
||||
if (!schedule) {
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 960,
|
||||
totalLengthMeters: 420,
|
||||
wagonCount: 18,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
schedule = scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: negad.id,
|
||||
destinationStationId: indode.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualDepartureAt: departure,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: TRAIN_NUMBER,
|
||||
direction: 'IMPORT',
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
});
|
||||
} else {
|
||||
schedule.originStationId = negad.id;
|
||||
schedule.destinationStationId = indode.id;
|
||||
schedule.scheduledDepartureDate = departure;
|
||||
schedule.scheduledArrivalDate = arrival;
|
||||
schedule.actualDepartureAt = departure;
|
||||
schedule.actualArrivalAt = arrival;
|
||||
schedule.status = 'ARRIVED' as TrainSchedule['status'];
|
||||
schedule.direction = 'IMPORT';
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
|
||||
if (schedule.trainSetId) {
|
||||
await trainSetRepo.update(schedule.trainSetId, { status: 'COMPLETED' });
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await scheduleRepo.save(schedule);
|
||||
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${saved.id}`);
|
||||
console.log(`Route: ${negad.code} -> ${indode.code}`);
|
||||
});
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Negad to Indode arrived train seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import {
|
||||
Company,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from '../modules/companies/entities/company.entity';
|
||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
|
||||
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_FIRST_LAST';
|
||||
const COMPANY_TIN = 'FLMDEMO001';
|
||||
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
|
||||
|
||||
const YARDS = [
|
||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
||||
];
|
||||
|
||||
const CONTAINER_TYPES = [
|
||||
{ code: '20FT', label: '20FT', sizeFt: 20 },
|
||||
{ code: '40FT', label: '40FT', sizeFt: 40 },
|
||||
];
|
||||
|
||||
const DEMO_BOOKINGS = [
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-001',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 8,
|
||||
totalWeightTons: 224,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-01T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Doraleh Container Terminal, Djibouti',
|
||||
firstMilePickupLat: 11.5881,
|
||||
firstMilePickupLng: 43.1372,
|
||||
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.8808,
|
||||
lastMileDeliveryLng: 38.7876,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-002',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 12,
|
||||
totalWeightTons: 240,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-02T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'PK12 Dry Port, Djibouti',
|
||||
firstMilePickupLat: 11.5536,
|
||||
firstMilePickupLng: 43.1103,
|
||||
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.9137,
|
||||
lastMileDeliveryLng: 38.7815,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-003',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 6,
|
||||
totalWeightTons: 180,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-03T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Djibouti Free Zone Yard',
|
||||
firstMilePickupLat: 11.5947,
|
||||
firstMilePickupLng: 43.1471,
|
||||
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.9806,
|
||||
lastMileDeliveryLng: 38.8736,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-004',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 10,
|
||||
totalWeightTons: 210,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-04T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Doraleh Multipurpose Port, Djibouti',
|
||||
firstMilePickupLat: 11.6062,
|
||||
firstMilePickupLng: 43.1219,
|
||||
lastMileDeliveryAddress: 'Addis Ababa Freight Terminal',
|
||||
lastMileDeliveryLat: 9.0101,
|
||||
lastMileDeliveryLng: 38.7619,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-005',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 5,
|
||||
totalWeightTons: 150,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-05T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Djibouti Port Gate 3',
|
||||
firstMilePickupLat: 11.5999,
|
||||
firstMilePickupLng: 43.1344,
|
||||
lastMileDeliveryAddress: 'Sebeta Distribution Center',
|
||||
lastMileDeliveryLat: 8.9169,
|
||||
lastMileDeliveryLng: 38.6177,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-001',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 7,
|
||||
totalWeightTons: 196,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-01T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||
firstMilePickupLat: 8.9806,
|
||||
firstMilePickupLng: 38.8736,
|
||||
lastMileDeliveryAddress: 'Doraleh Container Terminal, Djibouti',
|
||||
lastMileDeliveryLat: 11.5881,
|
||||
lastMileDeliveryLng: 43.1372,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-002',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 11,
|
||||
totalWeightTons: 220,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-02T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||
firstMilePickupLat: 8.8808,
|
||||
firstMilePickupLng: 38.7876,
|
||||
lastMileDeliveryAddress: 'Djibouti Free Zone Yard',
|
||||
lastMileDeliveryLat: 11.5947,
|
||||
lastMileDeliveryLng: 43.1471,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-003',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 4,
|
||||
totalWeightTons: 128,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-03T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||
firstMilePickupLat: 8.9137,
|
||||
firstMilePickupLng: 38.7815,
|
||||
lastMileDeliveryAddress: 'Doraleh Multipurpose Port, Djibouti',
|
||||
lastMileDeliveryLat: 11.6062,
|
||||
lastMileDeliveryLng: 43.1219,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-004',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 9,
|
||||
totalWeightTons: 180,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-04T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Addis Ababa Freight Terminal',
|
||||
firstMilePickupLat: 9.0101,
|
||||
firstMilePickupLng: 38.7619,
|
||||
lastMileDeliveryAddress: 'PK12 Dry Port, Djibouti',
|
||||
lastMileDeliveryLat: 11.5536,
|
||||
lastMileDeliveryLng: 43.1103,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-005',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 6,
|
||||
totalWeightTons: 168,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-05T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Sebeta Distribution Center',
|
||||
firstMilePickupLat: 8.9169,
|
||||
firstMilePickupLng: 38.6177,
|
||||
lastMileDeliveryAddress: 'Djibouti Port Gate 3',
|
||||
lastMileDeliveryLat: 11.5999,
|
||||
lastMileDeliveryLng: 43.1344,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
private readonly logger = new Logger(ApprovedFirstLastMileDemoBookingsSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Yard).upsert(
|
||||
YARDS.map((yard) => ({ ...yard, isActive: true })),
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(ServiceType).upsert(
|
||||
{
|
||||
code: SERVICE_TYPE_CODE,
|
||||
serviceName: 'Rail Container with First and Last Mile',
|
||||
description: 'Demo service type for approved first/last-mile bookings',
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
},
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(ContainerType).upsert(
|
||||
CONTAINER_TYPES.map((containerType, index) => ({
|
||||
...containerType,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: index + 1,
|
||||
})),
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(Company).upsert(
|
||||
{
|
||||
name: 'First and Last Mile Demo Customer',
|
||||
type: CompanyType.Customer,
|
||||
status: CompanyStatus.Active,
|
||||
tin: COMPANY_TIN,
|
||||
vatNumber: COMPANY_TIN,
|
||||
fanNumber: 'FLM0000000000001',
|
||||
country: 'Ethiopia',
|
||||
address: 'Addis Ababa',
|
||||
phone: '251900000101',
|
||||
email: COMPANY_EMAIL,
|
||||
website: null,
|
||||
contactPersonName: 'First Last Mile Demo',
|
||||
contactPersonPhone: '251900000101',
|
||||
generalManagerName: 'Demo Manager',
|
||||
generalManagerEmail: COMPANY_EMAIL,
|
||||
generalManagerPhone: '251900000101',
|
||||
},
|
||||
{ conflictPaths: { tin: true } },
|
||||
);
|
||||
|
||||
const [serviceType, company, yards, containerTypes] = await Promise.all([
|
||||
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||
manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
|
||||
manager.getRepository(Yard).find(),
|
||||
manager.getRepository(ContainerType).find(),
|
||||
]);
|
||||
|
||||
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||
const containerTypeByCode = new Map(
|
||||
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||
);
|
||||
|
||||
for (const demoBooking of DEMO_BOOKINGS) {
|
||||
const origin = yardByCode.get(demoBooking.originCode);
|
||||
const destination = yardByCode.get(demoBooking.destinationCode);
|
||||
const containerType = containerTypeByCode.get(demoBooking.containerCode);
|
||||
|
||||
if (!origin || !destination || !containerType) {
|
||||
throw new Error(`approved_first_last_mile_demo_dependency_missing:${demoBooking.reference}`);
|
||||
}
|
||||
|
||||
const wagonsRequired =
|
||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||
|
||||
await manager.getRepository(Booking).upsert(
|
||||
{
|
||||
reference: demoBooking.reference,
|
||||
companyId: company.id,
|
||||
status: 'APPROVED',
|
||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
||||
totalAmount: demoBooking.totalWeightTons * 25,
|
||||
paymentStatus: 'PENDING',
|
||||
contractType: 'NEW',
|
||||
serviceTypeId: serviceType.id,
|
||||
firstMilePickupAddress: demoBooking.firstMilePickupAddress,
|
||||
firstMilePickupLat: demoBooking.firstMilePickupLat,
|
||||
firstMilePickupLng: demoBooking.firstMilePickupLng,
|
||||
lastMileDeliveryAddress: demoBooking.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: demoBooking.lastMileDeliveryLat,
|
||||
lastMileDeliveryLng: demoBooking.lastMileDeliveryLng,
|
||||
equipmentReturn: 'WITHOUT_RETURN',
|
||||
originYardId: origin.id,
|
||||
destinationYardId: destination.id,
|
||||
tradeDirection: demoBooking.tradeDirection,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
cargoFreeText: 'Demo container cargo',
|
||||
shippingLineId: null,
|
||||
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
paymentCurrency: 'ETB',
|
||||
approvedByStaffAt: new Date(),
|
||||
priorityScore: 20,
|
||||
wagonsRequired,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
versionNumber: 1,
|
||||
},
|
||||
{ conflictPaths: { reference: true } },
|
||||
);
|
||||
|
||||
const booking = await manager.getRepository(Booking).findOneByOrFail({
|
||||
reference: demoBooking.reference,
|
||||
});
|
||||
|
||||
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
|
||||
await manager.getRepository(BookingContainer).insert({
|
||||
id: randomUUID(),
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType.id,
|
||||
quantity: demoBooking.quantity,
|
||||
vgmPerUnitTons,
|
||||
totalVgmTons: demoBooking.totalWeightTons,
|
||||
wagonsRequired,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight: vgmPerUnitTons > 35,
|
||||
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
|
||||
});
|
||||
}
|
||||
|
||||
const bookings = await manager.getRepository(Booking).find({
|
||||
where: { reference: In(DEMO_BOOKINGS.map((booking) => booking.reference)) },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map((booking) => booking.id);
|
||||
|
||||
await manager.getRepository(FirstMile).delete({ bookingId: In(bookingIds) });
|
||||
await manager.getRepository(LastMile).delete({ bookingId: In(bookingIds) });
|
||||
|
||||
await manager.getRepository(FirstMile).insert(
|
||||
bookingIds.map((bookingId) => ({
|
||||
bookingId,
|
||||
status: 'READY_TO_TRANSIT',
|
||||
advancedPayment: 0,
|
||||
remainingPayment: 0,
|
||||
estimatedKm: 18,
|
||||
exactKm: null,
|
||||
vehicleId: null,
|
||||
})),
|
||||
);
|
||||
|
||||
await manager.getRepository(LastMile).insert(
|
||||
bookingIds.map((bookingId) => ({
|
||||
bookingId,
|
||||
status: 'READY_TO_TRANSIT',
|
||||
advancedPayment: 0,
|
||||
remainingPayment: 0,
|
||||
estimatedKm: 22,
|
||||
exactKm: null,
|
||||
vehicleId: null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.log('Seeded 5 import and 5 export approved bookings with first/last-mile legs');
|
||||
}
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -77,6 +77,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
|
||||
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
|
||||
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
|
||||
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
||||
import { HealthCheck } from "./features/health/HealthCheck";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -88,7 +89,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "UM",
|
||||
label: "User Management",
|
||||
href: "/um",
|
||||
icon: <Users />,
|
||||
},
|
||||
@@ -374,6 +375,7 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="/health" element={<HealthCheck />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
|
||||
@@ -58,6 +58,7 @@ interface TruckEntranceFormState {
|
||||
consigneeDetails: string;
|
||||
edrDigitalBookingId: string;
|
||||
tin: string;
|
||||
customerPhone: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber: string;
|
||||
assignedEquipmentNumber: string;
|
||||
@@ -85,11 +86,27 @@ interface TruckEntranceFormState {
|
||||
warehouseManagerName: string;
|
||||
}
|
||||
|
||||
interface LockedTruckEntranceFields {
|
||||
ownerName?: boolean;
|
||||
consigneeDetails?: boolean;
|
||||
tin?: boolean;
|
||||
edrDigitalBookingId?: boolean;
|
||||
customerPhone?: boolean;
|
||||
assignedEquipmentNumber?: boolean;
|
||||
itemDescription?: boolean;
|
||||
packagingType?: boolean;
|
||||
unitCount?: boolean;
|
||||
grossWeightKg?: boolean;
|
||||
}
|
||||
|
||||
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
|
||||
|
||||
const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
ownerName: '',
|
||||
consigneeDetails: '',
|
||||
edrDigitalBookingId: '',
|
||||
tin: '',
|
||||
customerPhone: '',
|
||||
truckPlateNumber: '',
|
||||
trailerPlateNumber: '',
|
||||
assignedEquipmentNumber: '',
|
||||
@@ -118,22 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
});
|
||||
|
||||
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
||||
ownerName: form.ownerName.trim() || undefined,
|
||||
consigneeDetails: form.consigneeDetails.trim() || undefined,
|
||||
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
|
||||
tin: form.tin.trim() || undefined,
|
||||
truckPlateNumber: form.truckPlateNumber.trim(),
|
||||
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||
incoterms: form.incoterms.trim() || undefined,
|
||||
hsCodes: form.hsCodes.trim() || undefined,
|
||||
itemCode: form.itemCode.trim() || undefined,
|
||||
itemDescription: form.itemDescription.trim() || undefined,
|
||||
packagingType: form.packagingType.trim() || undefined,
|
||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||
grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg),
|
||||
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
|
||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||
@@ -149,13 +157,130 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
|
||||
const commonNonEmptyValue = (values: Array<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),
|
||||
consigneeDetails: Boolean(consigneeDetails),
|
||||
tin: Boolean(tin),
|
||||
edrDigitalBookingId: Boolean(edrDigitalBookingId),
|
||||
customerPhone: Boolean(customerPhone),
|
||||
assignedEquipmentNumber: Boolean(assignedEquipmentNumber),
|
||||
itemDescription: Boolean(itemDescription),
|
||||
packagingType: Boolean(packagingType),
|
||||
unitCount: unitCount !== '',
|
||||
grossWeightKg: grossWeightKg !== '',
|
||||
},
|
||||
packagingFreightType,
|
||||
};
|
||||
};
|
||||
|
||||
const BULK_PACKAGING_TYPE_OPTIONS = [
|
||||
{ value: 'BAG', label: 'Bag' },
|
||||
{ value: 'SACK', label: 'Sack' },
|
||||
{ value: 'BALE', label: 'Bale' },
|
||||
{ value: 'CARTON', label: 'Carton' },
|
||||
{ value: 'CRATE', label: 'Crate' },
|
||||
{ value: 'DRUM', label: 'Drum' },
|
||||
{ value: 'BARREL', label: 'Barrel' },
|
||||
{ value: 'PALLET', label: 'Pallet' },
|
||||
{ value: 'LOOSE_BULK', label: 'Loose bulk' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
];
|
||||
|
||||
const CONTAINER_PACKAGING_TYPE_OPTIONS = [
|
||||
{ value: 'CONTAINER_20FT', label: '20 ft container' },
|
||||
{ value: 'CONTAINER_40FT', label: '40 ft container' },
|
||||
{ value: 'CONTAINER_45FT', label: '45 ft container' },
|
||||
{ value: 'REEFER_CONTAINER', label: 'Reefer container' },
|
||||
{ value: 'TANK_CONTAINER', label: 'Tank container' },
|
||||
{ value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' },
|
||||
{ value: 'OPEN_TOP_CONTAINER', label: 'Open top container' },
|
||||
{ value: 'OTHER_CONTAINER', label: 'Other container' },
|
||||
];
|
||||
|
||||
const packagingOptionsFor = (freightType: PackagingFreightType) =>
|
||||
freightType === 'CONTAINER'
|
||||
? CONTAINER_PACKAGING_TYPE_OPTIONS
|
||||
: freightType === 'BULK'
|
||||
? BULK_PACKAGING_TYPE_OPTIONS
|
||||
: [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS];
|
||||
|
||||
function TruckEntranceFields({
|
||||
value,
|
||||
onChange,
|
||||
lockedFields,
|
||||
packagingFreightType = 'MIXED',
|
||||
}: {
|
||||
value: TruckEntranceFormState;
|
||||
onChange: (next: TruckEntranceFormState) => void;
|
||||
lockedFields?: LockedTruckEntranceFields;
|
||||
packagingFreightType?: PackagingFreightType;
|
||||
}) {
|
||||
const packagingOptions = packagingOptionsFor(packagingFreightType);
|
||||
const quantityLabel =
|
||||
packagingFreightType === 'CONTAINER'
|
||||
? 'Container quantity'
|
||||
: packagingFreightType === 'BULK'
|
||||
? 'Unit count'
|
||||
: 'Quantity';
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
|
||||
@@ -163,11 +288,13 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Owner's name"
|
||||
value={value.ownerName}
|
||||
readOnly={lockedFields?.ownerName}
|
||||
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Consignee details"
|
||||
value={value.consigneeDetails}
|
||||
readOnly={lockedFields?.consigneeDetails}
|
||||
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -175,14 +302,22 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="EDR digital booking ID"
|
||||
value={value.edrDigitalBookingId}
|
||||
readOnly={lockedFields?.edrDigitalBookingId}
|
||||
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="TIN"
|
||||
value={value.tin}
|
||||
readOnly={lockedFields?.tin}
|
||||
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
|
||||
/>
|
||||
</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>
|
||||
<Group grow>
|
||||
@@ -202,6 +337,7 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Assigned wagon / container number"
|
||||
value={value.assignedEquipmentNumber}
|
||||
readOnly={lockedFields?.assignedEquipmentNumber}
|
||||
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -281,19 +417,25 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
readOnly={lockedFields?.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
<Select
|
||||
label="Packaging type"
|
||||
data={packagingOptions}
|
||||
clearable
|
||||
searchable
|
||||
readOnly={lockedFields?.packagingType}
|
||||
value={value.packagingType}
|
||||
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })}
|
||||
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Unit count"
|
||||
label={quantityLabel}
|
||||
min={0}
|
||||
value={value.unitCount}
|
||||
readOnly={lockedFields?.unitCount}
|
||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -302,6 +444,7 @@ function TruckEntranceFields({
|
||||
label="Gross weight (kg)"
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
readOnly={lockedFields?.grossWeightKg}
|
||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
@@ -466,6 +609,8 @@ function EligibleTab({
|
||||
const [truckOpen, setTruckOpen] = useState(false);
|
||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||
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 canReceiveBooking = (row: EligibleBooking) =>
|
||||
@@ -539,6 +684,14 @@ function EligibleTab({
|
||||
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
|
||||
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const pendingReceiveRows = useMemo(
|
||||
() =>
|
||||
pendingReceiveIds
|
||||
.map((id) => rows.find((item) => item.id === id))
|
||||
.filter(Boolean) as EligibleBooking[],
|
||||
[pendingReceiveIds, rows],
|
||||
);
|
||||
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||
@@ -549,6 +702,29 @@ function EligibleTab({
|
||||
return next;
|
||||
});
|
||||
|
||||
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
|
||||
try {
|
||||
const r = await bulkReceive.mutateAsync({
|
||||
direction,
|
||||
...location,
|
||||
bookingIds,
|
||||
...(truckEntrance ? { truckEntrance } : {}),
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
setLockedTruckFields({});
|
||||
setPackagingFreightType('MIXED');
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const openTruckReceive = (bookingIds: string[]) => {
|
||||
if (!locationReady) {
|
||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
|
||||
@@ -564,13 +740,18 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
|
||||
return;
|
||||
}
|
||||
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null;
|
||||
const selectedRows = filteredIds
|
||||
.map((id) => rows.find((item) => item.id === id))
|
||||
.filter(Boolean) as EligibleBooking[];
|
||||
if (direction === 'IMPORT') {
|
||||
void receiveBookings(filteredIds);
|
||||
return;
|
||||
}
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setTruckForm({
|
||||
...emptyTruckEntrance(),
|
||||
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
|
||||
});
|
||||
setTruckForm(form);
|
||||
setLockedTruckFields(lockedFields);
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
setTruckOpen(true);
|
||||
};
|
||||
|
||||
@@ -579,24 +760,7 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await bulkReceive.mutateAsync({
|
||||
direction,
|
||||
...location,
|
||||
bookingIds: pendingReceiveIds,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
};
|
||||
|
||||
const loadPassedExport = async () => {
|
||||
@@ -800,18 +964,33 @@ function EligibleTab({
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
|
||||
<Modal
|
||||
opened={truckOpen}
|
||||
onClose={() => setTruckOpen(false)}
|
||||
title="Export Truck Arrival / First Mile Receive Form"
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
||||
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
|
||||
<Text size="sm">
|
||||
{pendingHasFirstMile
|
||||
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.'
|
||||
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
<TruckEntranceFields
|
||||
value={truckForm}
|
||||
onChange={setTruckForm}
|
||||
lockedFields={lockedTruckFields}
|
||||
packagingFreightType={packagingFreightType}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
||||
Receive and Generate GRN
|
||||
Register Arrival & Generate GRN
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -820,6 +999,161 @@ function EligibleTab({
|
||||
);
|
||||
}
|
||||
|
||||
/** Export received items awaiting inspection before loading. */
|
||||
function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.warehouses.receivedExport.queryOptions({ enabled }),
|
||||
);
|
||||
const inspectMutation = useMutation(
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
|
||||
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
|
||||
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const markInspected = async () => {
|
||||
if (selected.size === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select at least one item' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
});
|
||||
setSelected(new Set());
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Selected: <b>{selected.size}</b> / {pendingRows.length} received
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="indigo"
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No received export items awaiting inspection.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1700}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => {
|
||||
const selectable = r.inspectionStatus !== 'PASSED';
|
||||
return (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
checked={selected.has(r.id)}
|
||||
disabled={!selectable}
|
||||
onChange={() => toggleOne(r.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'orange'} variant="light" size="sm">
|
||||
{r.inspectionStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<InspectionReportModal
|
||||
inventoryId={inspectId}
|
||||
opened={Boolean(inspectId)}
|
||||
onClose={() => setInspectId(null)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Export items that passed inspection and are queued to be loaded onto a train. */
|
||||
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
@@ -1620,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
||||
<Tabs defaultValue="receive-queue" mt="xs">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
|
||||
<Tabs.Tab value="received">Received</Tabs.Tab>
|
||||
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
|
||||
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
|
||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
||||
@@ -1628,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
||||
<Tabs.Panel value="receive-queue">
|
||||
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="received">
|
||||
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="ready-to-load">
|
||||
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
@@ -1704,10 +2042,6 @@ function SingleBookingReceiveModal({
|
||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
|
||||
return;
|
||||
}
|
||||
if (form.quantity === '' || form.weight === '') {
|
||||
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
|
||||
return;
|
||||
}
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
@@ -1717,8 +2051,8 @@ function SingleBookingReceiveModal({
|
||||
warehouseId: form.warehouseId,
|
||||
yardId: form.yardId,
|
||||
zoneId: form.zoneId,
|
||||
quantity: Number(form.quantity),
|
||||
weight: Number(form.weight),
|
||||
quantity: 0,
|
||||
weight: 0,
|
||||
volume: form.volume === '' ? undefined : Number(form.volume),
|
||||
notes: form.notes.trim() || undefined,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
@@ -1744,21 +2078,13 @@ function SingleBookingReceiveModal({
|
||||
|
||||
<LocationSelects value={location} onChange={(next) => setForm((f) => ({ ...f, ...next }))} />
|
||||
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Quantity"
|
||||
required
|
||||
min={0}
|
||||
value={form.quantity}
|
||||
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={form.weight}
|
||||
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Volume (m³)"
|
||||
placeholder="Optional"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
@@ -17,23 +17,115 @@ interface ReleaseOrderModalProps {
|
||||
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) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
const [reference, setReference] = useState('');
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverLicense, setDriverLicense] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [truckType, setTruckType] = useState('');
|
||||
const [containerNumber, setContainerNumber] = useState('');
|
||||
const [gateInTime, setGateInTime] = useState('');
|
||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||
const [gateOutTime, setGateOutTime] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||
if (opened) {
|
||||
setReference(item?.releaseOrderReference ?? '');
|
||||
setTruckPlateNumber('');
|
||||
setTrailerPlateNumber('');
|
||||
setDriverName('');
|
||||
setDriverLicense('');
|
||||
setDriverPhone('');
|
||||
setTruckType('');
|
||||
setContainerNumber('');
|
||||
setGateInTime('');
|
||||
setTareWeight('');
|
||||
setGrossWeight('');
|
||||
setNetWeight(item?.weight != null ? Number(item.weight) : '');
|
||||
setGateOutTime('');
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!truckPlateNumber.trim() || !driverName.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (tareWeight === '' || grossWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
|
||||
return;
|
||||
}
|
||||
if (weightMismatch) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Weight mismatch',
|
||||
description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const released = await releaseMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { reference: reference.trim() || undefined },
|
||||
payload: {
|
||||
reference: reference.trim() || undefined,
|
||||
bookingId: item.bookingId ?? undefined,
|
||||
customerId: undefined,
|
||||
truckPlateNumber: truckPlateNumber.trim(),
|
||||
trailerPlateNumber: trailerPlateNumber.trim() || undefined,
|
||||
driverName: driverName.trim(),
|
||||
driverLicense: driverLicense.trim() || undefined,
|
||||
driverPhone: driverPhone.trim() || undefined,
|
||||
truckType: truckType.trim() || undefined,
|
||||
containerNumber: containerNumber.trim() || undefined,
|
||||
gateInTime: toIsoDateTime(gateInTime),
|
||||
tareWeight: Number(tareWeight),
|
||||
grossWeight: Number(grossWeight),
|
||||
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
|
||||
gateOutTime: toIsoDateTime(gateOutTime),
|
||||
},
|
||||
});
|
||||
setDownloading(true);
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
@@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Creates the warehouse release document with booking, customer, cargo and location details. The
|
||||
printed paper authorizes the goods to leave the warehouse gate.
|
||||
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
|
||||
recorded net weight does not equal gross weight minus tare weight.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
@@ -70,12 +162,69 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
value={reference}
|
||||
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">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Issue & view exit paper
|
||||
Exit Inspection & View Exit Paper
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -164,7 +164,7 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
|
||||
@@ -192,6 +192,24 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
IMPORT_DJIBOUTI: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti`,
|
||||
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/documents`,
|
||||
IMPORT_DJIBOUTI_GATEPASS_GRANTED: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`,
|
||||
IMPORT_DJIBOUTI_READY_FOR_LOADING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/ready-for-loading`,
|
||||
IMPORT_DJIBOUTI_LOADED_ON_TRAIN: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/loaded-on-train`,
|
||||
IMPORT_DJIBOUTI_DEPART: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/depart`,
|
||||
IMPORT_DJIBOUTI_LOAD_LIST: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list`,
|
||||
IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
@@ -322,6 +340,7 @@ export const URL_CONSTANTS = {
|
||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
||||
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
|
||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
||||
@@ -377,6 +396,23 @@ export const URL_CONSTANTS = {
|
||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||
},
|
||||
|
||||
IMPORT_OPERATIONS: {
|
||||
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
|
||||
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
||||
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
|
||||
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
|
||||
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
||||
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
||||
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
|
||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
|
||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||
`/import-operations/empty-container-returns/${id}/status`,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
BASE: '/vehicles',
|
||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 ||
|
||||
'https://edrfreightapi.triaplc.com';
|
||||
'https://edrfreightapi.triaplc.com';
|
||||
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
LayoutGrid,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -84,9 +84,6 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const approveMutation = useMutation(
|
||||
api.customers.setCompanyStatus.mutationOptions(),
|
||||
);
|
||||
const bookingsQuery = useQuery(
|
||||
api.customers.bookings.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
@@ -394,28 +391,12 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
{company.status === "pending" && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="green"
|
||||
loading={approveMutation.isPending}
|
||||
onClick={() =>
|
||||
approveMutation.mutate({
|
||||
companyId: company.id,
|
||||
status: "active",
|
||||
})
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
@@ -546,9 +527,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -567,9 +548,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -588,9 +569,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import {
|
||||
UserManagementApp,
|
||||
type UserManagementRuntimeOptions,
|
||||
type UserManagementSessionSeed,
|
||||
} from "@tria-plc/iamui";
|
||||
} from '@tria-plc/iamui';
|
||||
import { iamConfig } from './iamConfig';
|
||||
|
||||
import { getCookie } from "@/auth/cookies";
|
||||
function readCookieValue(name: string): string | null {
|
||||
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
||||
const match = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${escaped}=([^;]*)`),
|
||||
);
|
||||
|
||||
import { iamConfig } from "./iamConfig";
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function readInitialSession(): UserManagementSessionSeed | null {
|
||||
const token = getCookie("auth-token");
|
||||
const token =
|
||||
localStorage.getItem('fhc-backoffice-auth-token') ??
|
||||
readCookieValue('auth-token');
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshToken = getCookie("refresh-token") ?? undefined;
|
||||
const refreshToken =
|
||||
localStorage.getItem('fhc-backoffice-auth-refresh-token') ??
|
||||
readCookieValue('refresh-token') ??
|
||||
undefined;
|
||||
|
||||
return {
|
||||
token,
|
||||
@@ -47,15 +58,14 @@ export default function UserManagementHostPage() {
|
||||
rootRef.current = createRoot(mountNode);
|
||||
}
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
|
||||
const iamApiUrl = "/um-api";
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, '');
|
||||
const runtime: UserManagementRuntimeOptions = {
|
||||
basename: "/um",
|
||||
basename: '/um',
|
||||
apiBaseUrl,
|
||||
apiUrl: iamApiUrl,
|
||||
recordApiUrl: iamApiUrl,
|
||||
chronicleUrl: iamApiUrl,
|
||||
auditApiUrl: iamApiUrl,
|
||||
apiUrl: `${apiBaseUrl}/api`,
|
||||
recordApiUrl: `${apiBaseUrl}/api`,
|
||||
chronicleUrl: `${apiBaseUrl}/api`,
|
||||
auditApiUrl: `${apiBaseUrl}/api`,
|
||||
};
|
||||
|
||||
rootRef.current.render(
|
||||
@@ -78,5 +88,5 @@ export default function UserManagementHostPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
|
||||
return <div ref={mountRef} style={{ position: 'fixed', inset: 0 }} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FleetResourceConfig } from "./resources";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Truck", value: "TRUCK" },
|
||||
@@ -60,6 +59,8 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
{ id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 },
|
||||
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
|
||||
{ id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 },
|
||||
{ id: "estimatedDistanceKm", header: "Est. Distance (KM)", accessorKey: "estimatedDistanceKm", format: "number", size: 150 },
|
||||
{ id: "actualDistanceKm", header: "Actual Distance (KM)", accessorKey: "actualDistanceKm", format: "number", size: 150 },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||
],
|
||||
formFields: [
|
||||
@@ -73,6 +74,8 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
{ name: "year", label: "Year", type: "number", required: true },
|
||||
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
|
||||
{ name: "capacity", label: "Capacity", type: "number", required: true },
|
||||
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
|
||||
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
],
|
||||
@@ -87,10 +90,11 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
year: new Date().getFullYear(),
|
||||
fuelType: "DIESEL",
|
||||
capacity: 0,
|
||||
estimatedDistanceKm: "",
|
||||
actualDistanceKm: "",
|
||||
status: "ACTIVE",
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
|
||||
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
|
||||
export { API_BASE_URL };
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -67,7 +68,7 @@ type StatusFilter = "ALL" | FirstMileApiStatus | AssignmentStatus;
|
||||
|
||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "ALL", label: "All" },
|
||||
...FIRST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
...FIRST_MILE_STATUSES.filter((s) => s !== "PAYMENT_PENDING").map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
{ value: "ASSIGNED", label: "Assigned" },
|
||||
{ value: "UNASSIGNED", label: "Unassigned" },
|
||||
];
|
||||
@@ -93,8 +94,6 @@ const cargoDesc = (r: FirstMileRecord) => {
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const priceAmount = (r: FirstMileRecord) =>
|
||||
r.booking?.totalAmount ?? r.advancedPayment;
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
@@ -142,11 +141,14 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
||||
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -158,10 +160,13 @@ const tripSlipRows = (record: FirstMileRecord): [string, string][] => [
|
||||
["Pickup location", pickupLocation(record)],
|
||||
["Destination yard", destinationYardName(record)],
|
||||
["Cargo", cargoDesc(record)],
|
||||
["Price", formatPrice(priceAmount(record))],
|
||||
["Advanced Payment", formatPrice(record.advancedPayment)],
|
||||
["Post Payment", formatPrice(record.remainingPayment)],
|
||||
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
||||
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
||||
["Requested date", requestedDate(record)],
|
||||
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
|
||||
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
|
||||
["Status", STATUS_META[record.status].label],
|
||||
];
|
||||
|
||||
@@ -323,6 +328,9 @@ const FirstMilePage = () => {
|
||||
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
||||
const [bookingSearch, setBookingSearch] = useState("");
|
||||
|
||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||
const [distanceValue, setDistanceValue] = useState("");
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||
queryFn: async () => {
|
||||
@@ -375,10 +383,28 @@ const FirstMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateDistanceMutation = useMutation({
|
||||
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
|
||||
firstMileService.update(id, { exactKm }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
||||
if (activeRecord) {
|
||||
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||
}
|
||||
closeDistance();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Update failed", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
||||
const res = await firstMileService.accept(reference);
|
||||
const created = res.data;
|
||||
if (!created?.id) {
|
||||
throw new Error("First-mile leg was not created for this booking.");
|
||||
}
|
||||
if (vehicleId) await firstMileService.update(created.id, { vehicleId });
|
||||
return created;
|
||||
},
|
||||
@@ -387,8 +413,11 @@ const FirstMilePage = () => {
|
||||
toast({ title: "Booking accepted", description: "First-mile leg created successfully." });
|
||||
closeAccept();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Accept failed", variant: "destructive" });
|
||||
onError: (err: unknown) => {
|
||||
const description =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
(err instanceof Error ? err.message : undefined);
|
||||
toast({ title: "Accept failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -407,7 +436,6 @@ const FirstMilePage = () => {
|
||||
paidBookings.filter(
|
||||
(booking) =>
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
Boolean(booking.firstMilePickupAddress?.trim()) &&
|
||||
!existingFirstMileBookingIds.has(booking.id),
|
||||
),
|
||||
[existingFirstMileBookingIds, paidBookings],
|
||||
@@ -445,6 +473,27 @@ const FirstMilePage = () => {
|
||||
acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue });
|
||||
};
|
||||
|
||||
const openDistance = (id: string) => {
|
||||
setActiveId(id);
|
||||
setDistanceValue("");
|
||||
setDistanceOpen(true);
|
||||
};
|
||||
|
||||
const closeDistance = () => {
|
||||
setDistanceOpen(false);
|
||||
setActiveId(null);
|
||||
setDistanceValue("");
|
||||
};
|
||||
|
||||
const handleSaveDistance = () => {
|
||||
const distance = parseFloat(distanceValue);
|
||||
if (!activeId || isNaN(distance) || distance < 0) {
|
||||
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
updateDistanceMutation.mutate({ id: activeId, exactKm: distance });
|
||||
};
|
||||
|
||||
const matchesFilter = (r: FirstMileRecord) => {
|
||||
switch (statusFilter) {
|
||||
case "ALL": return true;
|
||||
@@ -623,10 +672,16 @@ const FirstMilePage = () => {
|
||||
cell: ({ row }) => cargoDesc(row.original),
|
||||
},
|
||||
{
|
||||
id: "price",
|
||||
header: "Price",
|
||||
id: "advancedPayment",
|
||||
header: "Advanced Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(priceAmount(row.original)),
|
||||
cell: ({ row }) => formatPrice(row.original.advancedPayment),
|
||||
},
|
||||
{
|
||||
id: "postPayment",
|
||||
header: "Post Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(row.original.remainingPayment),
|
||||
},
|
||||
{
|
||||
id: "vehicle",
|
||||
@@ -634,6 +689,18 @@ const FirstMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "estimatedKm",
|
||||
header: "Est. Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "exactKm",
|
||||
header: "Actual Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -699,6 +766,11 @@ const FirstMilePage = () => {
|
||||
>
|
||||
View detail
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
>
|
||||
Add Actual distance
|
||||
</Menu.Item>
|
||||
{canPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={15} />}
|
||||
@@ -1003,6 +1075,53 @@ const FirstMilePage = () => {
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Add Actual Distance modal */}
|
||||
<Modal
|
||||
opened={distanceOpen}
|
||||
onClose={closeDistance}
|
||||
title={<Text fw={600}>Add Actual Distance</Text>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{activeRecord && (
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">Customer</Text>
|
||||
<Text size="sm">{customerName(activeRecord)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
|
||||
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
<NumberInput
|
||||
label="Actual Distance (KM)"
|
||||
placeholder="Enter distance"
|
||||
value={distanceValue}
|
||||
onChange={(v) => setDistanceValue(String(v ?? ""))}
|
||||
min={0}
|
||||
step={0.1}
|
||||
decimalScale={2}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSaveDistance}
|
||||
loading={updateDistanceMutation.isPending}
|
||||
disabled={!distanceValue}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus;
|
||||
|
||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "ALL", label: "All" },
|
||||
...LAST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
...LAST_MILE_STATUSES.filter((s) => s !== "PAYMENT_PENDING").map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
{ value: "ASSIGNED", label: "Assigned" },
|
||||
{ value: "UNASSIGNED", label: "Unassigned" },
|
||||
];
|
||||
@@ -87,14 +87,12 @@ const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||
const cargoDesc = (r: LastMileRecord) => {
|
||||
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
const parts = [r.booking?.cargoType?.cargoTypeName ?? r.booking?.cargoType?.label ?? r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const priceAmount = (r: LastMileRecord) =>
|
||||
r.booking?.totalAmount ?? r.advancedPayment;
|
||||
const originYardName = (r: LastMileRecord) =>
|
||||
r.booking?.originYard?.name ?? "—";
|
||||
r.booking?.originYard?.label ?? r.booking?.originYard?.name ?? "—";
|
||||
const contactPersonName = (r: LastMileRecord) =>
|
||||
r.booking?.company?.contactPersonName ?? "—";
|
||||
const contactPhone = (r: LastMileRecord) =>
|
||||
@@ -104,7 +102,7 @@ const requestedDate = (r: LastMileRecord) => {
|
||||
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
||||
};
|
||||
const serviceTypeName = (r: LastMileRecord) =>
|
||||
r.booking?.serviceType?.name ?? "—";
|
||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={2}>
|
||||
@@ -130,14 +128,17 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
<InfoRow label="Origin yard" value={originYardName(record)} />
|
||||
<InfoRow label="Pickup (origin yard)" value={originYardName(record)} />
|
||||
<InfoRow label="Destination" value={deliveryLocation(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -146,13 +147,16 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
||||
const tripSlipRows = (record: LastMileRecord): [string, string][] => [
|
||||
["Customer", customerName(record)],
|
||||
["Service", serviceTypeName(record)],
|
||||
["Origin yard", originYardName(record)],
|
||||
["Pickup (origin yard)", originYardName(record)],
|
||||
["Destination", deliveryLocation(record)],
|
||||
["Cargo", cargoDesc(record)],
|
||||
["Price", formatPrice(priceAmount(record))],
|
||||
["Advanced Payment", formatPrice(record.advancedPayment)],
|
||||
["Post Payment", formatPrice(record.remainingPayment)],
|
||||
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
||||
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
||||
["Requested date", requestedDate(record)],
|
||||
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
|
||||
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
|
||||
["Status", STATUS_META[record.status].label],
|
||||
];
|
||||
|
||||
@@ -587,6 +591,12 @@ const LastMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => customerName(row.original),
|
||||
},
|
||||
{
|
||||
id: "pickup",
|
||||
header: "Pickup",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => originYardName(row.original),
|
||||
},
|
||||
{
|
||||
id: "destination",
|
||||
header: "Destination",
|
||||
@@ -600,10 +610,16 @@ const LastMilePage = () => {
|
||||
cell: ({ row }) => cargoDesc(row.original),
|
||||
},
|
||||
{
|
||||
id: "price",
|
||||
header: "Price",
|
||||
id: "advancedPayment",
|
||||
header: "Advanced Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(priceAmount(row.original)),
|
||||
cell: ({ row }) => formatPrice(row.original.advancedPayment),
|
||||
},
|
||||
{
|
||||
id: "postPayment",
|
||||
header: "Post Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(row.original.remainingPayment),
|
||||
},
|
||||
{
|
||||
id: "vehicle",
|
||||
@@ -611,6 +627,18 @@ const LastMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "estimatedKm",
|
||||
header: "Est. Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "exactKm",
|
||||
header: "Actual Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
@@ -56,8 +57,10 @@ import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/s
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
@@ -124,6 +127,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
||||
direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
@@ -304,6 +313,33 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const canDispatch = schedule.status === "SCHEDULED";
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
|
||||
|
||||
const openMarshallingDocument = async () => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await downloadMarshalling.mutateAsync({
|
||||
id: scheduleId,
|
||||
direction: schedule.direction,
|
||||
});
|
||||
const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling";
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Marshalling document ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
});
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: "Could not open marshalling document",
|
||||
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!allSelectedIds.length) return;
|
||||
@@ -766,6 +802,19 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{canPrintMarshalling ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={downloadMarshalling.isPending}
|
||||
onClick={() => void openMarshallingDocument()}
|
||||
>
|
||||
Marshalling PDF
|
||||
</Button>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
component={Link}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Alert,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
@@ -168,7 +169,7 @@ function ExportTrainDetailRows({
|
||||
export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue();
|
||||
const { data: trains = [], isLoading, isError, error } = useExportDjiboutiArrivalQueue();
|
||||
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
||||
const autoUnload = useAutoUnloadExportAtDjibouti();
|
||||
const generateInterchange = useGenerateInterchangeDocument();
|
||||
@@ -272,6 +273,10 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</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 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} 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 { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useInterchangeDocuments,
|
||||
} from '@/hooks/useInterchangeDocuments';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
||||
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||
|
||||
const statusColor: Record<InterchangeDocumentStatus, string> = {
|
||||
@@ -45,6 +46,116 @@ const getErrorMessage = (error: unknown) => {
|
||||
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 }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -157,6 +268,37 @@ export default function InterchangeDocumentsPage() {
|
||||
const dispute = useDisputeInterchangeDocument();
|
||||
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) => {
|
||||
try {
|
||||
await fn();
|
||||
@@ -169,10 +311,10 @@ export default function InterchangeDocumentsPage() {
|
||||
const acknowledgeDocument = (document: InterchangeDocument) => {
|
||||
const acknowledgedBy = window.prompt('Acknowledged by');
|
||||
if (!acknowledgedBy) return;
|
||||
run(
|
||||
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }),
|
||||
'Interchange document acknowledged',
|
||||
);
|
||||
run(async () => {
|
||||
const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy });
|
||||
await printDocument(response.data);
|
||||
}, 'Interchange document acknowledged');
|
||||
};
|
||||
|
||||
const disputeDocument = (document: InterchangeDocument) => {
|
||||
@@ -284,6 +426,28 @@ export default function InterchangeDocumentsPage() {
|
||||
Acknowledge
|
||||
</Button>
|
||||
) : 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' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -165,6 +165,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
@@ -254,8 +256,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const handlePay = async () => {
|
||||
if (!inv || !payAmount) return;
|
||||
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('');
|
||||
setDriverName('');
|
||||
setDriverPhone('');
|
||||
if (paidInvoice.status === 'PAID') {
|
||||
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
|
||||
await downloadReceiptPdf(paidInvoice);
|
||||
@@ -334,6 +346,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
|
||||
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}>
|
||||
Pay
|
||||
</Button>
|
||||
|
||||
@@ -644,6 +644,13 @@ export const api = {
|
||||
() => ["warehouse-inventory", "ready-to-load-export"],
|
||||
),
|
||||
|
||||
receivedExport: endpoint<void, ReadyToLoadRow[]>(
|
||||
"warehouse-inventory",
|
||||
"received-export",
|
||||
() => warehouseService.receivedExport().then((r) => r.data),
|
||||
() => ["warehouse-inventory", "received-export"],
|
||||
),
|
||||
|
||||
loadedExport: endpoint<void, ReadyToLoadRow[]>(
|
||||
"warehouse-inventory",
|
||||
"loaded-export",
|
||||
|
||||
@@ -60,7 +60,7 @@ export const firstMileService = {
|
||||
list: (pageSize = 1000) =>
|
||||
api.get<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
|
||||
getById: (id: string) => api.get<FirstMileRecord>(FM.BY_ID(id)),
|
||||
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null }) =>
|
||||
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
|
||||
api.patch<FirstMileRecord>(FM.BY_ID(id), data),
|
||||
accept: (bookingReference: string) =>
|
||||
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { api as client } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import { unwrap } from '@/utils/endpoint';
|
||||
import type {
|
||||
AssignCustomsRiskPayload,
|
||||
CreateDjiboutiIncidentPayload,
|
||||
CreateEmptyContainerReturnPayload,
|
||||
DjiboutiIncident,
|
||||
EmptyContainerReturn,
|
||||
ImportCustomsFinalization,
|
||||
ImportOperationActionPayload,
|
||||
RecordDeclarationPayload,
|
||||
UpdateEmptyContainerReturnStatusPayload,
|
||||
UploadImportCustomsDocumentPayload,
|
||||
} from '@/types/importOperations';
|
||||
|
||||
export const importOperationsService = {
|
||||
listIncidents: async (bookingId?: string): Promise<DjiboutiIncident[]> => {
|
||||
const response = await client.get<DjiboutiIncident[]>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
|
||||
{ params: { bookingId } },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
createIncident: async (
|
||||
payload: CreateDjiboutiIncidentPayload,
|
||||
): Promise<DjiboutiIncident> => {
|
||||
const response = await client.post<DjiboutiIncident>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getCustoms: async (bookingId: string): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.get<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS(bookingId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadCustomsDocument: async (
|
||||
bookingId: string,
|
||||
payload: UploadImportCustomsDocumentPayload,
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DOCUMENTS(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
recordDeclaration: async (
|
||||
bookingId: string,
|
||||
payload: RecordDeclarationPayload,
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DECLARATION(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
notifyDutiesTaxes: async (
|
||||
bookingId: string,
|
||||
payload: ImportOperationActionPayload = {},
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_NOTIFY_DUTIES_TAXES(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markDutiesTaxesPaid: async (
|
||||
bookingId: string,
|
||||
payload: ImportOperationActionPayload = {},
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DUTIES_TAXES_PAID(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
assignRisk: async (
|
||||
bookingId: string,
|
||||
payload: AssignCustomsRiskPayload,
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RISK(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markReleasePermitted: async (
|
||||
bookingId: string,
|
||||
payload: ImportOperationActionPayload = {},
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RELEASE_PERMITTED(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
listEmptyReturns: async (): Promise<EmptyContainerReturn[]> => {
|
||||
const response = await client.get<EmptyContainerReturn[]>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
createEmptyReturn: async (
|
||||
payload: CreateEmptyContainerReturnPayload,
|
||||
): Promise<EmptyContainerReturn> => {
|
||||
const response = await client.post<EmptyContainerReturn>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateEmptyReturnStatus: async (
|
||||
id: string,
|
||||
payload: UpdateEmptyContainerReturnStatusPayload,
|
||||
): Promise<EmptyContainerReturn> => {
|
||||
const response = await client.post<EmptyContainerReturn>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_STATUS(id),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -18,10 +18,10 @@ export interface LastMileBooking {
|
||||
totalAmount: number;
|
||||
scheduledDate?: string | null;
|
||||
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
||||
serviceType?: { id: string; name?: string } | null;
|
||||
originYard?: { id: string; name?: string } | null;
|
||||
destinationYard?: { id: string; name?: string } | null;
|
||||
cargoType?: { id: string; name?: string } | null;
|
||||
serviceType?: { id: string; name?: string; label?: string } | null;
|
||||
originYard?: { id: string; name?: string; label?: string } | null;
|
||||
destinationYard?: { id: string; name?: string; label?: string } | null;
|
||||
cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null;
|
||||
}
|
||||
|
||||
export interface LastMileVehicle {
|
||||
@@ -60,7 +60,7 @@ export const lastMileService = {
|
||||
list: (pageSize = 1000) =>
|
||||
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
|
||||
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
|
||||
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) =>
|
||||
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
|
||||
api.patch<LastMileRecord>(LM.BY_ID(id), data),
|
||||
accept: (bookingReference: string) =>
|
||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
FreightType,
|
||||
ImportDjiboutiActionPayload,
|
||||
ImportDjiboutiLoadList,
|
||||
ImportDjiboutiOperation,
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
@@ -21,6 +24,7 @@ import type {
|
||||
TrainSchedulePreviewResponse,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainTrackResponse,
|
||||
UploadImportDjiboutiDocumentPayload,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
} from "@/types/trainScheduling";
|
||||
@@ -261,6 +265,101 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getImportDjiboutiOperation: async (
|
||||
scheduleId: string,
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.get<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadImportDjiboutiDocument: async (
|
||||
scheduleId: string,
|
||||
payload: UploadImportDjiboutiDocumentPayload,
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DOCUMENTS(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
grantImportDjiboutiGatepass: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_GATEPASS_GRANTED(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markImportDjiboutiReadyForLoading: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_READY_FOR_LOADING(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
confirmImportDjiboutiLoadedOnTrain: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOADED_ON_TRAIN(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
departImportFromDjibouti: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DEPART(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
generateImportDjiboutiLoadList: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiLoadList> => {
|
||||
const response = await client.post<ImportDjiboutiLoadList>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
downloadImportDjiboutiLoadListDocument: async (
|
||||
scheduleId: string,
|
||||
): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT(scheduleId),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
downloadExportLoadListDocument: async (
|
||||
scheduleId: string,
|
||||
): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.EXPORT_LOAD_LIST_DOCUMENT(scheduleId),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||
const response = await client.get<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
|
||||
@@ -149,6 +149,8 @@ export const warehouseService = {
|
||||
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
|
||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
||||
receivedExport: () =>
|
||||
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVED_EXPORT),
|
||||
readyToLoadExport: () =>
|
||||
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
|
||||
loadedExport: () =>
|
||||
|
||||
124
apps/edr-freight-web/backoffice/src/types/importOperations.ts
Normal file
124
apps/edr-freight-web/backoffice/src/types/importOperations.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export type DjiboutiIncidentType =
|
||||
| 'SEAL_BROKEN'
|
||||
| 'CONTAINER_OPENED'
|
||||
| 'CONTAINER_DAMAGED'
|
||||
| 'FLUID_LEAKING'
|
||||
| 'QUANTITY_MISMATCH'
|
||||
| 'WEIGHT_MISMATCH'
|
||||
| 'OTHER';
|
||||
|
||||
export interface DjiboutiIncident {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
containerNumber: string | null;
|
||||
cargoId: string | null;
|
||||
facility: string | null;
|
||||
station: string | null;
|
||||
incidentType: DjiboutiIncidentType;
|
||||
description: string;
|
||||
photos: string[];
|
||||
reportedBy: string | null;
|
||||
reportedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDjiboutiIncidentPayload {
|
||||
bookingId: string;
|
||||
containerNumber?: string;
|
||||
cargoId?: string;
|
||||
facility?: string;
|
||||
station?: string;
|
||||
incidentType: DjiboutiIncidentType;
|
||||
description: string;
|
||||
photos?: string[];
|
||||
reportedBy?: string;
|
||||
reportedAt?: string;
|
||||
}
|
||||
|
||||
export type ImportCustomsDocumentType =
|
||||
| 'IM4'
|
||||
| 'IM5'
|
||||
| 'T1_CLOSURE_PROOF'
|
||||
| 'TRANSIT_PERMIT_SCREENSHOT'
|
||||
| 'CUSTOMER_PAYMENT_SLIP'
|
||||
| 'IMPORT_RELEASE_PERMIT';
|
||||
|
||||
export type ImportCustomsRiskLevel = 'GREEN' | 'YELLOW' | 'BLUE' | 'RED';
|
||||
|
||||
export interface ImportCustomsFinalization {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
documents: Partial<Record<ImportCustomsDocumentType, string>>;
|
||||
declarationSerialNumber: string | null;
|
||||
dutiesTaxesNotifiedAt: string | null;
|
||||
dutiesTaxesPaidAt: string | null;
|
||||
customsRisk: ImportCustomsRiskLevel | null;
|
||||
importReleasePermittedAt: string | null;
|
||||
completedAt: string | null;
|
||||
performedBy: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface ImportOperationActionPayload {
|
||||
performedBy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UploadImportCustomsDocumentPayload {
|
||||
documentType: ImportCustomsDocumentType;
|
||||
fileId: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface RecordDeclarationPayload {
|
||||
declarationSerialNumber: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface AssignCustomsRiskPayload {
|
||||
risk: ImportCustomsRiskLevel;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export type EmptyContainerReturnStatus =
|
||||
| 'RETURNED'
|
||||
| 'ASSIGNED_STORAGE'
|
||||
| 'DOCUMENTATION_CLEARED'
|
||||
| 'WAGON_ALLOCATED'
|
||||
| 'TRANSPORTED_TO_DJIBOUTI'
|
||||
| 'HANDOVER_ISSUED'
|
||||
| 'COMPLETED';
|
||||
|
||||
export interface EmptyContainerReturn {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
bookingId: string | null;
|
||||
customerId: string | null;
|
||||
returnDate: string;
|
||||
facility: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
condition: string | null;
|
||||
handoverNote: string | null;
|
||||
status: EmptyContainerReturnStatus;
|
||||
wagonAllocationReference: string | null;
|
||||
performedBy: string | null;
|
||||
}
|
||||
|
||||
export interface CreateEmptyContainerReturnPayload {
|
||||
containerNumber: string;
|
||||
bookingId?: string;
|
||||
customerId?: string;
|
||||
returnDate?: string;
|
||||
facility?: string;
|
||||
yard?: string;
|
||||
zone?: string;
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {
|
||||
status: EmptyContainerReturnStatus;
|
||||
wagonAllocationReference?: string;
|
||||
handoverNote?: string;
|
||||
}
|
||||
@@ -399,6 +399,81 @@ export interface TrainScheduleDetail {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| "DELIVERY_ORDER"
|
||||
| "PORT_INVOICE"
|
||||
| "DJIBOUTI_T1"
|
||||
| "ETHIOPIA_T1"
|
||||
| "RAILWAY_BILL";
|
||||
|
||||
export interface ImportDjiboutiDocumentRecord {
|
||||
fileId?: string | null;
|
||||
fileUrl?: string | null;
|
||||
reference?: string | null;
|
||||
uploadedAt: string;
|
||||
uploadedBy?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiOperation {
|
||||
trainScheduleId: string;
|
||||
trainNumber: string | null;
|
||||
direction: string | null;
|
||||
status: {
|
||||
documentsComplete: boolean;
|
||||
missingDocuments: ImportDjiboutiDocumentType[];
|
||||
gatepassGranted: boolean;
|
||||
readyForLoading: boolean;
|
||||
loadedOnTrain: boolean;
|
||||
departedFromDjibouti: boolean;
|
||||
loadListGenerated: boolean;
|
||||
};
|
||||
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||
gatepassGrantedAt: string | null;
|
||||
readyForLoadingAt: string | null;
|
||||
loadedOnTrainAt: string | null;
|
||||
departedFromDjiboutiAt: string | null;
|
||||
loadListGeneratedAt: string | null;
|
||||
performedBy: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface UploadImportDjiboutiDocumentPayload {
|
||||
documentType: ImportDjiboutiDocumentType;
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiActionPayload {
|
||||
notes?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiLoadList {
|
||||
generatedAt: string;
|
||||
trainScheduleId: string;
|
||||
trainNumber: string | null;
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
totalBookings: number;
|
||||
wagons: Array<{
|
||||
sequenceNo: number;
|
||||
wagonNumber: string | null;
|
||||
allocations: Array<{
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
loadType: string | null;
|
||||
allocatedWeightTons: number;
|
||||
containerNumbers: string[];
|
||||
}>;
|
||||
}>;
|
||||
operation: ImportDjiboutiOperation;
|
||||
}
|
||||
|
||||
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
|
||||
|
||||
export interface TrackStation {
|
||||
|
||||
@@ -341,6 +341,20 @@ export interface ReserveInventoryPayload {
|
||||
export interface ReleaseOrderPayload {
|
||||
reference?: 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. */
|
||||
@@ -356,6 +370,13 @@ export interface EligibleBooking {
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
containerQuantity: number | null;
|
||||
containerPackagingType: string | null;
|
||||
cargoDescription: string | null;
|
||||
lastMileRequested: boolean;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
@@ -370,6 +391,10 @@ export interface EligibleBooking {
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
firstMileDriverName: string | null;
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -378,7 +403,7 @@ export interface BulkReceivePayload {
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingIds: string[];
|
||||
truckEntrance: TruckEntrancePayload;
|
||||
truckEntrance?: TruckEntrancePayload;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -392,6 +417,7 @@ export interface TruckEntrancePayload {
|
||||
consigneeDetails?: string;
|
||||
edrDigitalBookingId?: string;
|
||||
tin?: string;
|
||||
customerPhone?: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber?: string;
|
||||
assignedEquipmentNumber?: string;
|
||||
@@ -822,6 +848,8 @@ export interface PayInvoicePayload {
|
||||
amount: number;
|
||||
method?: string;
|
||||
reference?: string;
|
||||
driverName?: string;
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,97 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||
|
||||
function createIamApiAdapter(apiBaseUrl: string): Plugin {
|
||||
const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`;
|
||||
|
||||
return {
|
||||
name: "iam-api-adapter",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/um-api", async (req, res) => {
|
||||
const requestPath = req.url ?? "/";
|
||||
const normalizedPath = requestPath.replace(/^\/+/, "");
|
||||
const targetUrl = new URL(normalizedPath, `${upstreamBaseUrl}/`);
|
||||
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value || key.toLowerCase() === "host") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
headers.append(key, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const body =
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? undefined
|
||||
: await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) =>
|
||||
chunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
),
|
||||
);
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
|
||||
const upstreamResponse = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (targetUrl.pathname.endsWith("/auth/me")) {
|
||||
const payload = await upstreamResponse.json();
|
||||
const unwrappedPayload =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"success" in payload &&
|
||||
"data" in payload
|
||||
? payload.data
|
||||
: payload;
|
||||
|
||||
res.statusCode = upstreamResponse.status;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(unwrappedPayload));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = upstreamResponse.status;
|
||||
upstreamResponse.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
res.end(Buffer.from(await upstreamResponse.arrayBuffer()));
|
||||
} catch (error) {
|
||||
server.ssrFixStacktrace(error as Error);
|
||||
res.statusCode = 502;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
message: "Failed to forward IAM request",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, "");
|
||||
const apiBaseUrl =
|
||||
env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)],
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -164,7 +164,9 @@ export default function OnboardingWizardDialog({
|
||||
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
||||
|
||||
// Resume position from the backend-persisted step.
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(
|
||||
onboardingStep as FormStep,
|
||||
)
|
||||
? (onboardingStep as FormStep)
|
||||
: "company";
|
||||
|
||||
@@ -275,7 +277,7 @@ export default function OnboardingWizardDialog({
|
||||
const idx = FORM_STEPS.indexOf(step as FormStep);
|
||||
if (idx < 0 || idx <= furthestIdxRef.current) return;
|
||||
furthestIdxRef.current = idx;
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => {});
|
||||
api.companies.setOnboardingStep.call({ step }).catch(() => { });
|
||||
}, []);
|
||||
|
||||
// Mirror the form's step locally (for the header/pill) and persist it.
|
||||
@@ -321,7 +323,7 @@ export default function OnboardingWizardDialog({
|
||||
|
||||
// Note: no "back to role selection" — once the draft is created the role(s)
|
||||
// are fixed; the form's first-step Back is a no-op so progress never resets.
|
||||
const handleBackToRoles = useCallback(() => {}, []);
|
||||
const handleBackToRoles = useCallback(() => { }, []);
|
||||
|
||||
// Save the current step's fields to the draft (PATCH /profile). Returns the
|
||||
// server error message on failure so the form can show it (e.g. duplicate TIN).
|
||||
@@ -339,6 +341,31 @@ export default function OnboardingWizardDialog({
|
||||
[],
|
||||
);
|
||||
|
||||
// Auto-upload the documents the user just selected as they leave the documents
|
||||
// step. Only the in-memory selections are sent; once uploaded they're cleared
|
||||
// (so the final submit never re-uploads them) and the requirements query is
|
||||
// refreshed so the "Already uploaded" badges light up. Partial uploads are
|
||||
// allowed — the user may continue even with required docs still outstanding.
|
||||
const handleUploadDocuments = useCallback(async (): Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
> => {
|
||||
const companyId = company?.company?.id;
|
||||
const hasNew = Object.values(documentFiles).some(
|
||||
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
|
||||
);
|
||||
if (!companyId || !hasNew) return { ok: true };
|
||||
try {
|
||||
await companiesService.uploadDocuments(companyId, documentFiles);
|
||||
setDocumentFiles({});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: api.companies.onboardingRequirements.queryKey(),
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractApiError(err).message };
|
||||
}
|
||||
}, [company?.company?.id, documentFiles, queryClient]);
|
||||
|
||||
// Final confirm step → finalize onboarding (no company create; it already
|
||||
// exists as a draft that's been filled in step-by-step).
|
||||
const handleSubmit = useCallback(
|
||||
@@ -383,6 +410,25 @@ export default function OnboardingWizardDialog({
|
||||
requirementsQuery.data?.documentSettingCode ??
|
||||
documentSettingCode(effectiveNationality);
|
||||
|
||||
// Server-confirmed document state, used both to badge already-uploaded fields
|
||||
// and to keep a refreshed resume from over-shooting the documents step.
|
||||
const requirementDocuments = requirementsQuery.data?.documents ?? [];
|
||||
const uploadedDocumentKeys = requirementDocuments
|
||||
.filter((d) => d.uploaded)
|
||||
.map((d) => d.fileKey);
|
||||
// If any REQUIRED document is still missing, the resume must not rest past the
|
||||
// documents step (don't skip to Business License) — clamp it back. This only
|
||||
// changes the target once requirements load; the form follows the correction
|
||||
// as long as the user hasn't navigated yet.
|
||||
const requiredDocsMissing = requirementDocuments.some(
|
||||
(d) => d.isRequired && !d.uploaded,
|
||||
);
|
||||
const effectiveResumeStep: FormStep =
|
||||
requiredDocsMissing &&
|
||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
||||
? "documents"
|
||||
: resumeFormStep;
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: resolvedDocumentSettingCode,
|
||||
documentFiles,
|
||||
@@ -392,7 +438,7 @@ export default function OnboardingWizardDialog({
|
||||
isPending: finishMutation.isPending,
|
||||
onBack: handleBackToRoles,
|
||||
hideFirstStepBack: true,
|
||||
initialStep: resumeFormStep,
|
||||
initialStep: effectiveResumeStep,
|
||||
resyncOpen: opened,
|
||||
onStepChange: handleStepChange,
|
||||
onSaveStep: saveStep,
|
||||
@@ -400,6 +446,8 @@ export default function OnboardingWizardDialog({
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange: setLicenseFiles,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments: handleUploadDocuments,
|
||||
// Surface a failed final submit (license/document upload or complete) inside
|
||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||
// the submit step.
|
||||
@@ -413,7 +461,7 @@ export default function OnboardingWizardDialog({
|
||||
withCloseButton={!completed}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={!completed}
|
||||
size={720}
|
||||
size={1440}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
centered
|
||||
@@ -422,11 +470,11 @@ export default function OnboardingWizardDialog({
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
styles={{
|
||||
header: {
|
||||
alignItems:"flex-start"
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
title: {
|
||||
flex: 1
|
||||
}
|
||||
flex: 1,
|
||||
},
|
||||
}}
|
||||
title={
|
||||
completed ? null : (
|
||||
@@ -448,59 +496,64 @@ export default function OnboardingWizardDialog({
|
||||
{completed ? (
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : <ArrowRight size={16} />
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="xl">
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect
|
||||
value={nationality}
|
||||
onChange={setNationality}
|
||||
embedded
|
||||
/>
|
||||
<Group justify="flex-end" pt="xs">
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleNationalityContinue}
|
||||
disabled={!nationality}
|
||||
rightSection={<ArrowRight size={16} />}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect
|
||||
value={roles}
|
||||
onChange={setRoles}
|
||||
embedded
|
||||
/>
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => setPhase("nationality")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleRolesContinue}
|
||||
disabled={!rolesValid}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : (
|
||||
<ArrowRight size={16} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -518,7 +571,10 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||
style={{ background: "var(--mantine-color-edr-green-1)" }}
|
||||
>
|
||||
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
|
||||
<PartyPopper
|
||||
size={32}
|
||||
className="text-[var(--mantine-color-edr-green-7)]"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
@@ -538,14 +594,20 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
style={{ background: "var(--mantine-color-edr-green-0)" }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
||||
<Clock
|
||||
size={18}
|
||||
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
|
||||
/>
|
||||
<Text size="sm" ta="left">
|
||||
Each operational profile (importer, exporter, freight forwarder) is
|
||||
reviewed and approved individually.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
||||
<ShieldCheck
|
||||
size={18}
|
||||
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
|
||||
/>
|
||||
<Text size="sm" ta="left">
|
||||
You can start creating bookings under a profile as soon as it's
|
||||
approved — we'll let you know the moment that happens.
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Card,
|
||||
FileInput,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { FileText, Paperclip, Upload } from "lucide-react";
|
||||
import { Anchor, Group, Stack, Text } from "@mantine/core";
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||
|
||||
import type { LicenseFile } from "@/services/companies.service";
|
||||
|
||||
@@ -20,6 +14,48 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
/** Field key the synthesized per-profile upload setting is keyed on. */
|
||||
const LICENSE_FILE_KEY = "business_license";
|
||||
|
||||
/**
|
||||
* Build a single-field upload setting so each profile's license input can reuse
|
||||
* the shared SmartFileInput (same dropzone + "uploaded" state as the documents
|
||||
* step), instead of a bespoke file picker.
|
||||
*/
|
||||
function buildLicenseSetting(
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
): IFileUploadSetting {
|
||||
return {
|
||||
id: `license-setting-${profileId}`,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
code: "business_license",
|
||||
label: "Business license",
|
||||
description: null,
|
||||
entity: "customer",
|
||||
fields: [
|
||||
{
|
||||
id: `${LICENSE_FILE_KEY}-${profileId}`,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
deletedAt: null,
|
||||
settingId: `license-setting-${profileId}`,
|
||||
fileKey: LICENSE_FILE_KEY,
|
||||
fileLabel: `Upload ${profileName} Business license file(s)`,
|
||||
helpText: null,
|
||||
isRequired: true,
|
||||
isMultiple: true,
|
||||
maxFiles: 10,
|
||||
allowedExtensions: ["pdf", "png", "jpg", "jpeg"],
|
||||
maxSizeMb: 10,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoleLicenseProfile {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -38,8 +74,9 @@ interface RoleLicenseStepProps {
|
||||
|
||||
/**
|
||||
* Final onboarding step: collect a business license (one or more files) for
|
||||
* each operational role the company holds. Each role gets its own multi-file
|
||||
* input; already-uploaded files are listed for context.
|
||||
* each operational role the company holds. Each role gets its own SmartFileInput
|
||||
* dropzone; already-uploaded files are listed (with download links) for context
|
||||
* and surface the input's "uploaded" state.
|
||||
*/
|
||||
export default function RoleLicenseStep({
|
||||
profiles,
|
||||
@@ -60,37 +97,11 @@ export default function RoleLicenseStep({
|
||||
{profiles.map((profile) => {
|
||||
const label = ROLE_LABELS[profile.type] ?? profile.type;
|
||||
const selected = value[profile.id] ?? [];
|
||||
const hasAny = selected.length > 0 || profile.existingFiles.length > 0;
|
||||
const hasExisting = profile.existingFiles.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={profile.id} padding="lg" withBorder>
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={40}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
<FileText size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{label} — Business License
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" ff="monospace">
|
||||
{profile.reference}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{hasAny && (
|
||||
<Badge color="edr-green" variant="light">
|
||||
Provided
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{profile.existingFiles.length > 0 && (
|
||||
<>
|
||||
{hasExisting && (
|
||||
<Stack gap={4} mb="sm">
|
||||
{profile.existingFiles.map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
@@ -108,20 +119,17 @@ export default function RoleLicenseStep({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FileInput
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder={
|
||||
profile.existingFiles.length > 0
|
||||
? "Upload more / replace files"
|
||||
: "Select license file(s)"
|
||||
}
|
||||
value={selected}
|
||||
onChange={(files) => setFiles(profile.id, files ?? [])}
|
||||
<SmartFileInput
|
||||
file={buildLicenseSetting(profile.id, label)}
|
||||
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||
onChange={(v) => {
|
||||
const next = v[LICENSE_FILE_KEY];
|
||||
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||
setFiles(profile.id, files);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
@@ -18,23 +18,17 @@ import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import {
|
||||
ControlledPhoneField,
|
||||
isValidPhone,
|
||||
toEthiopianE164,
|
||||
} from "@/components/PhoneField";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
@@ -42,261 +36,21 @@ import RoleLicenseStep, {
|
||||
} from "@/components/onboarding/RoleLicenseStep";
|
||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
type CompanyStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
|
||||
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||
const phoneDigits = (p?: string | null) => (p ?? "").replace(/\D/g, "").slice(-9);
|
||||
const samePhone = (a?: string | null, b?: string | null) => {
|
||||
const da = phoneDigits(a);
|
||||
return da.length === 9 && da === phoneDigits(b);
|
||||
};
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
licenceNumber: z.string().optional(),
|
||||
statusDescription: z.string().optional(),
|
||||
dateRegistered: z.string().optional(),
|
||||
renewedFrom: z.string().optional(),
|
||||
renewalDate: z.string().optional(),
|
||||
renewedTo: z.string().optional(),
|
||||
// Address fields are user-entered and required (the registration/license
|
||||
// fields above are read-only confirmations pulled from eTrade).
|
||||
region: z.string().min(1, "Region is required"),
|
||||
zone: z.string().min(1, "Zone is required"),
|
||||
woreda: z.string().min(1, "Woreda is required"),
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
houseNo: z.string().min(1, "House number is required"),
|
||||
etradePhone: z.string().optional(),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.min(1, "Manager phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaAddress: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof onboardingSchema>;
|
||||
|
||||
const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
"etradePhone",
|
||||
],
|
||||
personnel: [
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
],
|
||||
contact: [
|
||||
"contactPersonName",
|
||||
"contactPersonPosition",
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
verify: [],
|
||||
poa: [],
|
||||
documents: [],
|
||||
additional: [],
|
||||
};
|
||||
|
||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
return {
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
vatNumber: data.vatNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
attributes: {
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: data.generalManagerPhone,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Map one wizard step's form values to the profile-update payload it saves. */
|
||||
function stepPayload(
|
||||
step: CompanyStep,
|
||||
d: FormData,
|
||||
): Partial<UpdateProfilePayload> {
|
||||
switch (step) {
|
||||
case "company":
|
||||
return {
|
||||
companyName: d.companyName,
|
||||
companyEmail: d.companyEmail,
|
||||
companyPhone: d.companyPhone,
|
||||
companyLocation: d.companyLocation,
|
||||
companyAddress: d.companyAddress,
|
||||
tin: d.tinNumber,
|
||||
vatNumber: d.vatNumber,
|
||||
fanNumber: d.fanNumber,
|
||||
licenceNumber: d.licenceNumber,
|
||||
statusDescription: d.statusDescription,
|
||||
dateRegistered: d.dateRegistered,
|
||||
renewedFrom: d.renewedFrom,
|
||||
renewalDate: d.renewalDate,
|
||||
renewedTo: d.renewedTo,
|
||||
region: d.region,
|
||||
zone: d.zone,
|
||||
woreda: d.woreda,
|
||||
kebele: d.kebele,
|
||||
houseNo: d.houseNo,
|
||||
etradePhone: d.etradePhone,
|
||||
};
|
||||
case "personnel":
|
||||
return {
|
||||
generalManagerName: d.generalManagerName,
|
||||
generalManagerEmail: d.generalManagerEmail,
|
||||
generalManagerPhone: d.generalManagerPhone,
|
||||
};
|
||||
case "contact":
|
||||
return {
|
||||
contactPersonName: d.contactPersonName,
|
||||
contactPersonPosition: d.contactPersonPosition || undefined,
|
||||
contactPersonEmail: d.contactPersonEmail || undefined,
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
};
|
||||
case "poa":
|
||||
return {
|
||||
poaName: d.poaName || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
poaAddress: d.poaAddress || undefined,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the form from previously-saved profile data. */
|
||||
function toFormValues(p: ProfileResponse): FormData {
|
||||
// The draft placeholder TIN ("D…") shouldn't show as a real value.
|
||||
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
||||
return {
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: p.companyPhone ?? "",
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
companyAddress: p.companyAddress ?? "",
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
fanNumber: p.fanNumber ?? "",
|
||||
licenceNumber: p.licenceNumber ?? "",
|
||||
statusDescription: p.statusDescription ?? "",
|
||||
dateRegistered: p.dateRegistered ?? "",
|
||||
renewedFrom: p.renewedFrom ?? "",
|
||||
renewalDate: p.renewalDate ?? "",
|
||||
renewedTo: p.renewedTo ?? "",
|
||||
region: p.region ?? "",
|
||||
zone: p.zone ?? "",
|
||||
woreda: p.woreda ?? "",
|
||||
kebele: p.kebele ?? "",
|
||||
houseNo: p.houseNo ?? "",
|
||||
etradePhone: p.etradePhone ?? "",
|
||||
contactPersonName: p.contactPersonName ?? "",
|
||||
contactPersonPosition: p.contactPersonPosition ?? "",
|
||||
contactPersonEmail: p.contactPersonEmail ?? "",
|
||||
contactPersonPhone: p.contactPersonPhone ?? "",
|
||||
generalManagerName: p.generalManagerName ?? "",
|
||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||
generalManagerPhone: p.generalManagerPhone ?? "",
|
||||
poaName: p.poaName ?? "",
|
||||
poaPhone: p.poaPhone ?? "",
|
||||
poaAddress: p.poaAddress ?? "",
|
||||
poaEmail: p.poaEmail ?? "",
|
||||
poaLocation: p.poaLocation ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** A single read-only registration value rendered as a label/value pair. */
|
||||
function ReadOnlyField({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text" fw={500}>
|
||||
{value && value.trim() ? value : "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
import {
|
||||
type CompanyStep,
|
||||
type FormData,
|
||||
onboardingSchema,
|
||||
stepFields,
|
||||
} from "./companyProfileForm/schema";
|
||||
import {
|
||||
buildPayload,
|
||||
maskPhone,
|
||||
samePhone,
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
documentSettingCode,
|
||||
@@ -316,6 +70,8 @@ export default function CompanyProfileForm({
|
||||
licenseFiles,
|
||||
onLicenseChange,
|
||||
submitError,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
@@ -345,6 +101,16 @@ export default function CompanyProfileForm({
|
||||
onLicenseChange?: (value: Record<string, File[]>) => void;
|
||||
/** Server error from the final submit (uploads/complete), shown verbatim. */
|
||||
submitError?: string | null;
|
||||
/** fileKeys whose company document is already uploaded server-side (resume). */
|
||||
uploadedDocumentKeys?: string[];
|
||||
/**
|
||||
* Auto-upload the currently-selected company documents (the Documents step's
|
||||
* "Continue" action). Resolves to an error message string on failure so the
|
||||
* step can surface it and hold the user in place.
|
||||
*/
|
||||
onUploadDocuments?: () => Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
>;
|
||||
}) {
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -356,17 +122,40 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step]);
|
||||
|
||||
// Tracks whether the user has manually navigated the form this session. While
|
||||
// false, the form still follows the parent's resume target (initialStep) —
|
||||
// which can shift to an earlier step once server data lands (e.g. a required
|
||||
// document turns out to be un-uploaded, so we must not rest on a later step).
|
||||
const userNavigatedRef = useRef(false);
|
||||
|
||||
// On reopen, jump to the furthest step reached (initialStep) so progress
|
||||
// never appears to reset.
|
||||
// never appears to reset. Re-arm the follow-the-parent behaviour too.
|
||||
const wasOpen = useRef(resyncOpen);
|
||||
useEffect(() => {
|
||||
if (resyncOpen && !wasOpen.current && initialStep) {
|
||||
userNavigatedRef.current = false;
|
||||
setStep(initialStep);
|
||||
setSaveError(null);
|
||||
}
|
||||
wasOpen.current = resyncOpen;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resyncOpen]);
|
||||
|
||||
// Follow a parent-driven resume correction: if initialStep changes (the wizard
|
||||
// re-clamps it back once onboarding requirements load — e.g. a required
|
||||
// document is still missing, so it must not skip ahead to Business License),
|
||||
// adopt it, but only while the user hasn't started navigating themselves.
|
||||
const lastInitialStep = useRef(initialStep);
|
||||
useEffect(() => {
|
||||
if (initialStep && initialStep !== lastInitialStep.current) {
|
||||
lastInitialStep.current = initialStep;
|
||||
if (!userNavigatedRef.current) {
|
||||
setStep(initialStep);
|
||||
setSaveError(null);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialStep]);
|
||||
const [internalFiles, setInternalFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
@@ -410,7 +199,6 @@ export default function CompanyProfileForm({
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
houseNo: "",
|
||||
etradePhone: "",
|
||||
contactPersonName: "",
|
||||
contactPersonPosition: "",
|
||||
contactPersonEmail: "",
|
||||
@@ -482,7 +270,7 @@ export default function CompanyProfileForm({
|
||||
setValue("kebele", data.kebele);
|
||||
setValue("houseNo", data.houseNo);
|
||||
setValue(
|
||||
"etradePhone",
|
||||
"companyPhone",
|
||||
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
||||
);
|
||||
// companyAddress is composed reactively from the address fields below, so
|
||||
@@ -512,33 +300,54 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
/** Copy the General Manager into the Contact Person fields (still editable). */
|
||||
const useGmAsContact = () => {
|
||||
setValue("contactPersonName", watch("generalManagerName"), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
setValue("contactPersonEmail", watch("generalManagerEmail"));
|
||||
setValue("contactPersonPhone", watch("generalManagerPhone"), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
const contactName = watch("contactPersonName");
|
||||
const contactEmail = watch("contactPersonEmail");
|
||||
const contactPhone = watch("contactPersonPhone");
|
||||
|
||||
// While linked, mirror the source values into the (disabled) target fields so
|
||||
// the copy stays current even if the user goes back and edits the source.
|
||||
useEffect(() => {
|
||||
if (!contactSameAsGm) return;
|
||||
setValue("contactPersonName", gmName ?? "", { shouldValidate: true });
|
||||
setValue("contactPersonEmail", gmEmail ?? "");
|
||||
setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!poaSameAsContact) return;
|
||||
setValue("poaName", contactName ?? "");
|
||||
setValue("poaEmail", contactEmail ?? "");
|
||||
setValue("poaPhone", contactPhone ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poaSameAsContact, contactName, contactEmail, contactPhone]);
|
||||
|
||||
const toggleContactSameAsGm = (checked: boolean) => {
|
||||
setContactSameAsGm(checked);
|
||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||
if (!checked) {
|
||||
setValue("contactPersonName", "");
|
||||
setValue("contactPersonEmail", "");
|
||||
setValue("contactPersonPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
/** Copy the Contact Person into the PoA fields (still editable). */
|
||||
const useContactAsPoa = () => {
|
||||
setValue("poaName", watch("contactPersonName"));
|
||||
setValue("poaEmail", watch("contactPersonEmail"));
|
||||
setValue("poaPhone", watch("contactPersonPhone"));
|
||||
};
|
||||
|
||||
/** Populate the Contact Person from the currently logged-in user. */
|
||||
const useLoggedInUserAsContact = () => {
|
||||
setValue("contactPersonName", user?.name?.en ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
if (user?.email) setValue("contactPersonEmail", user.email);
|
||||
setValue("contactPersonPhone", user?.phoneNumber ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
const togglePoaSameAsContact = (checked: boolean) => {
|
||||
setPoaSameAsContact(checked);
|
||||
if (!checked) {
|
||||
setValue("poaName", "");
|
||||
setValue("poaEmail", "");
|
||||
setValue("poaPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
// --- Contact-phone SMS OTP verification -----------------------------------
|
||||
@@ -612,7 +421,7 @@ export default function CompanyProfileForm({
|
||||
setOtpSent(false);
|
||||
// Persist the verified phone so the step resumes as "done" after a refresh
|
||||
// (best-effort — the OTP itself already succeeded server-side).
|
||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => {});
|
||||
onSaveStep?.({ contactVerifiedPhone: contactPhoneE164 }).catch(() => { });
|
||||
} catch (err) {
|
||||
setOtpError(extractApiError(err).message);
|
||||
} finally {
|
||||
@@ -675,6 +484,7 @@ export default function CompanyProfileForm({
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
userNavigatedRef.current = true;
|
||||
if (step === "additional") {
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
@@ -699,16 +509,34 @@ export default function CompanyProfileForm({
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
return;
|
||||
}
|
||||
// The documents step has nothing to persist; field steps validate + save
|
||||
// before advancing.
|
||||
if (step !== "documents") {
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
// The documents step auto-uploads whatever the user selected as they
|
||||
// continue (partial uploads are allowed — required-doc completeness is
|
||||
// re-checked on resume). A failed upload holds them on the step.
|
||||
if (step === "documents") {
|
||||
if (onUploadDocuments) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await onUploadDocuments();
|
||||
if (!res.ok) {
|
||||
setSaveError(res.error);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
setSaveError(null);
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
return;
|
||||
}
|
||||
// Field steps validate + save before advancing.
|
||||
const ok = await saveCurrentStep();
|
||||
if (!ok) return;
|
||||
setStep(stepOrder[currentIdx + 1]);
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
userNavigatedRef.current = true;
|
||||
setSaveError(null);
|
||||
if (currentIdx === 0) onBack();
|
||||
else setStep(stepOrder[currentIdx - 1]);
|
||||
@@ -864,11 +692,6 @@ export default function CompanyProfileForm({
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="etradePhone"
|
||||
label="Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
@@ -917,33 +740,17 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "contact" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Contact Person
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useLoggedInUserAsContact}
|
||||
>
|
||||
Use me
|
||||
</Button>
|
||||
{watch("generalManagerName") && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useGmAsContact}
|
||||
>
|
||||
Use General Manager
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Contact Person
|
||||
</Text>
|
||||
{watch("generalManagerName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={contactSameAsGm}
|
||||
onToggle={toggleContactSameAsGm}
|
||||
title="Same as General Manager"
|
||||
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
@@ -978,12 +785,6 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "verify" && (
|
||||
<Stack gap="md">
|
||||
<Group gap="xs" align="center">
|
||||
<ShieldCheck size={18} className="text-[var(--mantine-color-edr-green-7)]" />
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Verify the contact person
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted">
|
||||
We'll text a one-time code to the contact person's phone to
|
||||
confirm it's reachable. This is required before you continue.
|
||||
@@ -1009,7 +810,10 @@ export default function CompanyProfileForm({
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" align="center">
|
||||
<Smartphone size={16} className="text-[var(--mantine-color-edr-muted)]" />
|
||||
<Smartphone
|
||||
size={16}
|
||||
className="text-[var(--mantine-color-edr-muted)]"
|
||||
/>
|
||||
<Text size="sm" c="edr-text">
|
||||
{maskPhone(contactPhoneE164)}
|
||||
</Text>
|
||||
@@ -1028,15 +832,17 @@ export default function CompanyProfileForm({
|
||||
</Button>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Enter the 6-digit code we sent to{" "}
|
||||
{maskPhone(contactPhoneE164)}.
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
<Group gap="sm">
|
||||
@@ -1056,7 +862,9 @@ export default function CompanyProfileForm({
|
||||
disabled={resendIn > 0 || sendingOtp}
|
||||
leftSection={<RotateCw size={14} />}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
{resendIn > 0
|
||||
? `Resend in ${resendIn}s`
|
||||
: "Resend code"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -1078,24 +886,18 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "poa" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you
|
||||
have them, or skip to continue.
|
||||
</Text>
|
||||
{watch("contactPersonName") && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useContactAsPoa}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Use contact person
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Power of Attorney details are optional. Fill them in if you have
|
||||
them, or skip to continue.
|
||||
</Text>
|
||||
{watch("contactPersonName") && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsContact}
|
||||
onToggle={togglePoaSameAsContact}
|
||||
title="Same as contact person"
|
||||
description="Reuse the contact person's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
@@ -1147,6 +949,8 @@ export default function CompanyProfileForm({
|
||||
<SmartFileInput
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||
onChange={setDocumentFiles}
|
||||
/>
|
||||
)}
|
||||
@@ -1210,19 +1014,12 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
loading={isPending || saving}
|
||||
rightSection={
|
||||
!isPending &&
|
||||
!saving &&
|
||||
step !== "additional" &&
|
||||
step !== "documents" ? (
|
||||
!isPending && !saving && step !== "additional" ? (
|
||||
<ArrowRight size={16} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{step === "documents" || step === "verify"
|
||||
? "Continue"
|
||||
: step === "additional"
|
||||
? "Submit for review"
|
||||
: "Save & Continue"}
|
||||
{step === "additional" ? "Submit for review" : "Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
/**
|
||||
* A card styled as a large checkbox: clicking it toggles `checked`, which the
|
||||
* caller uses to prefill + lock a set of fields (and clear them on uncheck).
|
||||
*/
|
||||
export function LinkCheckboxCard({
|
||||
checked,
|
||||
onToggle,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => onToggle(!checked)}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked
|
||||
? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!"
|
||||
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!"
|
||||
}`}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<div
|
||||
className={`mt-px flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border transition-colors ${checked
|
||||
? "border-[var(--mantine-color-edr-green-6)] bg-[var(--mantine-color-edr-green-6)] text-white"
|
||||
: "border-[var(--mantine-color-gray-4)] bg-white"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check size={14} strokeWidth={3} />}
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} size="sm" c="edr-text" lh={1.25}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default LinkCheckboxCard;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
/** A single read-only registration value rendered as a label/value pair. */
|
||||
export function ReadOnlyField({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text" fw={500}>
|
||||
{value && value.trim() ? value : "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReadOnlyField;
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
|
||||
import type { CompanyStep, FormData } from "./schema";
|
||||
|
||||
/** Last 9 digits (Ethiopian national significant number) for tolerant compare. */
|
||||
export const phoneDigits = (p?: string | null) =>
|
||||
(p ?? "").replace(/\D/g, "").slice(-9);
|
||||
|
||||
export const samePhone = (a?: string | null, b?: string | null) => {
|
||||
const da = phoneDigits(a);
|
||||
return da.length === 9 && da === phoneDigits(b);
|
||||
};
|
||||
|
||||
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||
export const maskPhone = (p: string) =>
|
||||
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||
|
||||
export function buildPayload(
|
||||
data: FormData,
|
||||
_user: AuthUser,
|
||||
): CreateCompanyPayload {
|
||||
return {
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
vatNumber: data.vatNumber,
|
||||
fanNumber: data.fanNumber,
|
||||
attributes: {
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: data.generalManagerPhone,
|
||||
poaName: data.poaName || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaAddress: data.poaAddress || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Map one wizard step's form values to the profile-update payload it saves. */
|
||||
export function stepPayload(
|
||||
step: CompanyStep,
|
||||
d: FormData,
|
||||
): Partial<UpdateProfilePayload> {
|
||||
switch (step) {
|
||||
case "company":
|
||||
return {
|
||||
companyName: d.companyName,
|
||||
companyEmail: d.companyEmail,
|
||||
companyPhone: d.companyPhone,
|
||||
companyLocation: d.companyLocation,
|
||||
companyAddress: d.companyAddress,
|
||||
tin: d.tinNumber,
|
||||
vatNumber: d.vatNumber,
|
||||
fanNumber: d.fanNumber,
|
||||
licenceNumber: d.licenceNumber,
|
||||
statusDescription: d.statusDescription,
|
||||
dateRegistered: d.dateRegistered,
|
||||
renewedFrom: d.renewedFrom,
|
||||
renewalDate: d.renewalDate,
|
||||
renewedTo: d.renewedTo,
|
||||
region: d.region,
|
||||
zone: d.zone,
|
||||
woreda: d.woreda,
|
||||
kebele: d.kebele,
|
||||
houseNo: d.houseNo,
|
||||
etradePhone: d.companyPhone,
|
||||
};
|
||||
case "personnel":
|
||||
return {
|
||||
generalManagerName: d.generalManagerName,
|
||||
generalManagerEmail: d.generalManagerEmail,
|
||||
generalManagerPhone: d.generalManagerPhone,
|
||||
};
|
||||
case "contact":
|
||||
return {
|
||||
contactPersonName: d.contactPersonName,
|
||||
contactPersonPosition: d.contactPersonPosition || undefined,
|
||||
contactPersonEmail: d.contactPersonEmail || undefined,
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
};
|
||||
case "poa":
|
||||
return {
|
||||
poaName: d.poaName || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
poaAddress: d.poaAddress || undefined,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the form from previously-saved profile data. */
|
||||
export function toFormValues(p: ProfileResponse): FormData {
|
||||
// The draft placeholder TIN ("D…") shouldn't show as a real value.
|
||||
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
||||
return {
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: p.companyPhone ?? "",
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
companyAddress: p.companyAddress ?? "",
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
fanNumber: p.fanNumber ?? "",
|
||||
licenceNumber: p.licenceNumber ?? "",
|
||||
statusDescription: p.statusDescription ?? "",
|
||||
dateRegistered: p.dateRegistered ?? "",
|
||||
renewedFrom: p.renewedFrom ?? "",
|
||||
renewalDate: p.renewalDate ?? "",
|
||||
renewedTo: p.renewedTo ?? "",
|
||||
region: p.region ?? "",
|
||||
zone: p.zone ?? "",
|
||||
woreda: p.woreda ?? "",
|
||||
kebele: p.kebele ?? "",
|
||||
houseNo: p.houseNo ?? "",
|
||||
contactPersonName: p.contactPersonName ?? "",
|
||||
contactPersonPosition: p.contactPersonPosition ?? "",
|
||||
contactPersonEmail: p.contactPersonEmail ?? "",
|
||||
contactPersonPhone: p.contactPersonPhone ?? "",
|
||||
generalManagerName: p.generalManagerName ?? "",
|
||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||
generalManagerPhone: p.generalManagerPhone ?? "",
|
||||
poaName: p.poaName ?? "",
|
||||
poaPhone: p.poaPhone ?? "",
|
||||
poaAddress: p.poaAddress ?? "",
|
||||
poaEmail: p.poaEmail ?? "",
|
||||
poaLocation: p.poaLocation ?? "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { isValidPhone } from "@/components/PhoneField";
|
||||
|
||||
export type CompanyStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
|
||||
export const onboardingSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
companyEmail: z.string().email("Invalid email address"),
|
||||
companyPhone: z
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.length(10, "VAT number must be exactly 10 digits"),
|
||||
fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
|
||||
licenceNumber: z.string().optional(),
|
||||
statusDescription: z.string().optional(),
|
||||
dateRegistered: z.string().optional(),
|
||||
renewedFrom: z.string().optional(),
|
||||
renewalDate: z.string().optional(),
|
||||
renewedTo: z.string().optional(),
|
||||
// Address fields are user-entered and required (the registration/license
|
||||
// fields above are read-only confirmations pulled from eTrade).
|
||||
region: z.string().min(1, "Region is required"),
|
||||
zone: z.string().min(1, "Zone is required"),
|
||||
woreda: z.string().min(1, "Woreda is required"),
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
houseNo: z.string().min(1, "House number is required"),
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
generalManagerName: z.string().min(1, "Manager name is required"),
|
||||
generalManagerEmail: z.string().email("Invalid Manager email"),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.min(1, "Manager phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
poaName: z.string().optional(),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaAddress: z.string().optional(),
|
||||
poaEmail: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof onboardingSchema>;
|
||||
|
||||
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
company: [
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
"fanNumber",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
],
|
||||
personnel: [
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
],
|
||||
contact: [
|
||||
"contactPersonName",
|
||||
"contactPersonPosition",
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
verify: [],
|
||||
poa: [],
|
||||
documents: [],
|
||||
additional: [],
|
||||
};
|
||||
@@ -1,29 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build from monorepo root: docker build -f apps/edr-passenger-api/Dockerfile .
|
||||
# On start: runs prisma migrate deploy + seed, then the API.
|
||||
|
||||
# On start: runs the API only — migrations/seed now run separately via the
|
||||
# `migration` stage, invoked as a one-shot container in CI before deploy.
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/passenger-api" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm --filter "@edr/passenger-api" exec prisma generate
|
||||
RUN pnpm turbo build --filter="@edr/passenger-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/passenger-api" --legacy /deploy
|
||||
@@ -32,20 +28,24 @@ RUN if [ -d node_modules/.prisma ]; then \
|
||||
cp -r node_modules/.prisma /deploy/node_modules/.prisma; \
|
||||
fi
|
||||
|
||||
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
|
||||
# against the real DB, as its own gated step *before* the app image is built/deployed.
|
||||
# Runs the same generate + migrate + seed sequence the old entrypoint used to run at
|
||||
# container start, but now as a throwaway container, before the app container exists.
|
||||
FROM deployer AS migration
|
||||
WORKDIR /deploy
|
||||
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
||||
ENV CI=true
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
CMD ["sh", "-c", "npm run prisma:generate && npm run prisma:migrate && npm run prisma:seed"]
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable && corepack prepare pnpm@11.1.1 --activate
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer /deploy .
|
||||
COPY apps/edr-passenger-api/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh \
|
||||
&& chown -R nestjs:nodejs /app
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
ENV CI=true
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
EXPOSE 4000
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
|
||||
|
||||
# Build from monorepo root: docker build -f apps/edr-payment-api/Dockerfile .
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/payment-api" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="@edr/payment-api..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN pnpm deploy --filter="@edr/payment-api" --prod --legacy --ignore-scripts /deploy
|
||||
|
||||
# --- Migration image: built in CI, run as a one-shot `docker run --rm --env-file ...`
|
||||
# against the real DB, as its own gated step *before* the app image is built/deployed.
|
||||
# The runner below only ever runs `node dist/main.js` — migrations happen here, not there.
|
||||
FROM deployer AS migration
|
||||
WORKDIR /deploy
|
||||
CMD ["node", "dist/scripts/migrate.js"]
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
ENV NODE_ENV=production
|
||||
@@ -32,10 +34,6 @@ WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
COPY apps/edr-payment-api/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh \
|
||||
&& chown -R nestjs:nodejs /app
|
||||
USER nestjs
|
||||
EXPOSE 3008
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
Reference in New Issue
Block a user