mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Import operation gate pass
This commit is contained in:
@@ -19,6 +19,8 @@
|
||||
"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:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
|
||||
@@ -55,6 +55,7 @@ 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';
|
||||
@@ -68,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: [
|
||||
@@ -131,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -147,6 +150,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -179,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -842,8 +842,12 @@ export class WarehouseInventoryService {
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking);
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
const truckEntrance = dto.truckEntrance
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
||||
: undefined;
|
||||
if (dto.direction === 'EXPORT') {
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
}
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
@@ -856,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,
|
||||
@@ -869,16 +873,18 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${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,
|
||||
ownerName: truckEntrance.ownerName,
|
||||
bookingReference: truckEntrance.edrDigitalBookingId,
|
||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
warehouseId: dto.warehouseId,
|
||||
@@ -982,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');
|
||||
@@ -1592,19 +1603,27 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
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, await this.getBookingTruckEntranceSource(manager, dto.bookingId))
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {})
|
||||
: dto.truckEntrance;
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
|
||||
@@ -1628,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',
|
||||
@@ -2619,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;
|
||||
@@ -2872,42 +2881,42 @@ export class WarehouseInventoryService {
|
||||
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.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
|
||||
`Truck Plate: ${truck.truckPlateNumber}`,
|
||||
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||
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');
|
||||
|
||||
@@ -124,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);
|
||||
});
|
||||
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,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 = 'DEMO-FIRST-LAST-001';
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -88,9 +88,15 @@ interface TruckEntranceFormState {
|
||||
|
||||
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';
|
||||
@@ -129,23 +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,
|
||||
customerPhone: form.customerPhone.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,
|
||||
@@ -221,9 +217,15 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
},
|
||||
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,
|
||||
};
|
||||
@@ -292,6 +294,7 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Consignee details"
|
||||
value={value.consigneeDetails}
|
||||
readOnly={lockedFields?.consigneeDetails}
|
||||
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -334,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
|
||||
@@ -413,6 +417,7 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
readOnly={lockedFields?.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -422,6 +427,7 @@ function TruckEntranceFields({
|
||||
data={packagingOptions}
|
||||
clearable
|
||||
searchable
|
||||
readOnly={lockedFields?.packagingType}
|
||||
value={value.packagingType}
|
||||
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
|
||||
/>
|
||||
@@ -429,6 +435,7 @@ function TruckEntranceFields({
|
||||
label={quantityLabel}
|
||||
min={0}
|
||||
value={value.unitCount}
|
||||
readOnly={lockedFields?.unitCount}
|
||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -437,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
|
||||
@@ -676,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)));
|
||||
@@ -686,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' });
|
||||
@@ -704,6 +743,10 @@ function EligibleTab({
|
||||
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(form);
|
||||
@@ -717,26 +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([]);
|
||||
setLockedTruckFields({});
|
||||
setPackagingFreightType('MIXED');
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
};
|
||||
|
||||
const loadPassedExport = async () => {
|
||||
@@ -940,11 +964,21 @@ 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>
|
||||
<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}
|
||||
@@ -956,7 +990,7 @@ function EligibleTab({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
||||
Receive and Generate GRN
|
||||
Register Arrival & Generate GRN
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -965,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();
|
||||
@@ -1765,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>
|
||||
@@ -1773,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>
|
||||
@@ -1849,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;
|
||||
@@ -1862,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),
|
||||
@@ -1889,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"
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
import type { FleetResourceConfig } from "./resources";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Truck", value: "TRUCK" },
|
||||
@@ -93,4 +92,3 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
};
|
||||
|
||||
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
|
||||
export { API_BASE_URL };
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -403,7 +403,7 @@ export interface BulkReceivePayload {
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingIds: string[];
|
||||
truckEntrance: TruckEntrancePayload;
|
||||
truckEntrance?: TruckEntrancePayload;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
|
||||
Reference in New Issue
Block a user