From f4a90755c223cfc414ced3183adb57a3f8212ebd Mon Sep 17 00:00:00 2001 From: hagiye Date: Thu, 25 Jun 2026 11:00:40 +0300 Subject: [PATCH] api --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/src/app.module.ts | 2 + ...821000000000-CreateInterchangeDocuments.ts | 109 +++++ .../dto/create-interchange-document.dto.ts | 3 + .../dto/generate-from-schedule.dto.ts | 57 +++ .../dto/interchange-document-query.dto.ts | 38 ++ .../update-interchange-document-status.dto.ts | 16 + .../interchange-document-item.entity.ts | 85 ++++ .../entities/interchange-document.entity.ts | 89 ++++ .../interchange-documents.controller.ts | 56 +++ .../interchange-documents.module.ts | 16 + .../interchange-documents.repository.ts | 13 + .../interchange-documents.service.ts | 392 ++++++++++++++++++ .../modules/last-mile/last-mile.service.ts | 38 +- .../vehicles/entities/vehicle.entity.ts | 9 - .../src/modules/warehouses/dto/invoice.dto.ts | 7 +- .../warehouses/warehouse-fee.service.ts | 52 ++- .../warehouse-inspection.service.ts | 33 ++ .../warehouses/warehouse-inventory.service.ts | 39 ++ .../warehouses/warehouse-invoice.service.ts | 6 +- .../warehouses/warehouse-rules.controller.ts | 9 +- .../modules/warehouses/warehouses.module.ts | 11 + .../seed-export-djibouti-interchange-demo.ts | 186 +++++++++ apps/edr-freight-web/backoffice/src/App.tsx | 7 + .../components/warehouses/FeePreviewModal.tsx | 45 +- .../InterchangeDocumentDetailPanel.tsx | 128 ++++++ .../warehouses/InventoryWorkbench.tsx | 34 +- .../warehouses/WarehouseInventoryTable.tsx | 6 +- .../backoffice/src/constants/URLS.ts | 9 + .../backoffice/src/constants/apiConfig.ts | 4 +- .../src/hooks/useInterchangeDocuments.ts | 76 ++++ .../backoffice/src/hooks/useWarehouses.ts | 31 +- .../src/pages/warehouses/ArrivalQueuePage.tsx | 42 +- .../ExportDjiboutiUnloadingQueuePage.tsx | 69 ++- .../warehouses/InterchangeDocumentsPage.tsx | 315 ++++++++++++++ .../backoffice/src/services/api.ts | 16 +- .../services/interchange-documents.service.ts | 33 ++ .../src/services/last-mile.service.ts | 2 +- .../src/services/warehouse.service.ts | 13 +- .../src/types/interchangeDocument.ts | 90 ++++ .../backoffice/src/types/warehouse.ts | 7 + .../portal/src/constants/apiConfig.ts | 4 +- 42 files changed, 2100 insertions(+), 98 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1821000000000-CreateInterchangeDocuments.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/dto/create-interchange-document.dto.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/dto/generate-from-schedule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/dto/interchange-document-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/dto/update-interchange-document-status.dto.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document-item.entity.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document.entity.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.module.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.repository.ts create mode 100644 apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts create mode 100644 apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/interchangeDocument.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 897a7b764..bf180bdff 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -17,6 +17,7 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.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" }, diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 737eb33ff..3b69d2812 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -66,6 +66,7 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; 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'; @Module({ imports: [ @@ -128,6 +129,7 @@ import { LastMileModule } from './modules/last-mile/last-mile.module'; DriversModule, FirstMileModule, LastMileModule, + InterchangeDocumentsModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/migrations/1821000000000-CreateInterchangeDocuments.ts b/apps/edr-freight-api/src/migrations/1821000000000-CreateInterchangeDocuments.ts new file mode 100644 index 000000000..386eabd4e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000000-CreateInterchangeDocuments.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +export class CreateInterchangeDocuments1821000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'interchange_documents', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'document_no', type: 'varchar', length: '40', isUnique: true }, + { name: 'direction', type: 'varchar', length: '10' }, + { name: 'schedule_id', type: 'uuid', isNullable: true }, + { name: 'train_no', type: 'varchar', length: '40', isNullable: true }, + { name: 'route_id', type: 'uuid', isNullable: true }, + { name: 'origin_facility_id', type: 'uuid', isNullable: true }, + { name: 'destination_facility_id', type: 'uuid', isNullable: true }, + { name: 'handover_location', type: 'varchar', length: '255' }, + { name: 'handover_from', type: 'varchar', length: '255' }, + { name: 'handover_to', type: 'varchar', length: '255' }, + { name: 'operator_name', type: 'varchar', length: '255', isNullable: true }, + { name: 'port_operator_name', type: 'varchar', length: '255', isNullable: true }, + { name: 'shipping_line_name', type: 'varchar', length: '255', isNullable: true }, + { name: 'customs_reference', type: 'varchar', length: '120', isNullable: true }, + { name: 'manifest_reference', type: 'varchar', length: '120', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" }, + { name: 'generated_at', type: 'timestamptz', isNullable: true }, + { name: 'acknowledged_at', type: 'timestamptz', isNullable: true }, + { name: 'generated_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'acknowledged_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'remarks', 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 }, + ], + indices: [ + { name: 'idx_interchange_documents_direction', columnNames: ['direction'] }, + { name: 'idx_interchange_documents_status', columnNames: ['status'] }, + { name: 'idx_interchange_documents_schedule', columnNames: ['schedule_id'] }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'interchange_document_items', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'interchange_document_id', type: 'uuid' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'booking_reference', type: 'varchar', length: '64', isNullable: true }, + { name: 'item_type', type: 'varchar', length: '20' }, + { name: 'booking_container_id', type: 'uuid', isNullable: true }, + { name: 'booking_cargo_id', type: 'uuid', isNullable: true }, + { name: 'container_number', type: 'varchar', length: '64', isNullable: true }, + { name: 'seal_number', type: 'varchar', length: '100', isNullable: true }, + { name: 'cargo_id', type: 'uuid', isNullable: true }, + { name: 'cargo_type', type: 'varchar', length: '255', isNullable: true }, + { name: 'cargo_description', type: 'text', isNullable: true }, + { name: 'weight', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 3, isNullable: true }, + { name: 'package_count', type: 'int', isNullable: true }, + { name: 'wagon_number', type: 'varchar', length: '80', isNullable: true }, + { name: 'condition_status', type: 'varchar', length: '20', default: "'GOOD'" }, + { name: 'damage_description', type: 'text', isNullable: true }, + { name: 'remarks', 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 }, + ], + foreignKeys: [ + { + columnNames: ['interchange_document_id'], + referencedSchema: 'freight', + referencedTableName: 'interchange_documents', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + { + columnNames: ['booking_id'], + referencedSchema: 'freight', + referencedTableName: 'bookings', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }, + ], + indices: [ + { name: 'idx_interchange_items_document', columnNames: ['interchange_document_id'] }, + { name: 'idx_interchange_items_booking', columnNames: ['booking_id'] }, + ], + }), + true, + ); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_interchange_active_schedule_direction + ON freight.interchange_documents(schedule_id, direction) + WHERE schedule_id IS NOT NULL AND status <> 'CANCELLED' AND deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX IF EXISTS freight.uq_interchange_active_schedule_direction;'); + await queryRunner.dropTable('freight.interchange_document_items', true); + await queryRunner.dropTable('freight.interchange_documents', true); + } +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/create-interchange-document.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/create-interchange-document.dto.ts new file mode 100644 index 000000000..92328f38f --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/create-interchange-document.dto.ts @@ -0,0 +1,3 @@ +import { GenerateFromScheduleDto } from './generate-from-schedule.dto'; + +export class CreateInterchangeDocumentDto extends GenerateFromScheduleDto {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/generate-from-schedule.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/generate-from-schedule.dto.ts new file mode 100644 index 000000000..9e4c57e0f --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/generate-from-schedule.dto.ts @@ -0,0 +1,57 @@ +import { IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +import { INTERCHANGE_DIRECTIONS, InterchangeDirection } from '../entities/interchange-document.entity'; + +export class GenerateFromScheduleDto { + @IsUUID() + scheduleId!: string; + + @IsIn(INTERCHANGE_DIRECTIONS) + direction!: InterchangeDirection; + + @IsString() + @MaxLength(255) + handoverLocation!: string; + + @IsString() + @MaxLength(255) + handoverFrom!: string; + + @IsString() + @MaxLength(255) + handoverTo!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + operatorName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + portOperatorName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + shippingLineName?: string; + + @IsOptional() + @IsString() + @MaxLength(120) + customsReference?: string; + + @IsOptional() + @IsString() + @MaxLength(120) + manifestReference?: string; + + @IsOptional() + @IsString() + @MaxLength(120) + generatedBy?: string; + + @IsOptional() + @IsString() + remarks?: string; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/interchange-document-query.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/interchange-document-query.dto.ts new file mode 100644 index 000000000..5a8599f7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/interchange-document-query.dto.ts @@ -0,0 +1,38 @@ +import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + INTERCHANGE_DIRECTIONS, + INTERCHANGE_DOCUMENT_STATUSES, + InterchangeDirection, + InterchangeDocumentStatus, +} from '../entities/interchange-document.entity'; + +export class InterchangeDocumentQueryDto { + @IsOptional() + @IsIn(INTERCHANGE_DIRECTIONS) + direction?: InterchangeDirection; + + @IsOptional() + @IsIn(INTERCHANGE_DOCUMENT_STATUSES) + status?: InterchangeDocumentStatus; + + @IsOptional() + @IsUUID() + scheduleId?: string; + + @IsOptional() + @IsString() + documentNo?: string; + + @IsOptional() + @IsString() + dateFrom?: string; + + @IsOptional() + @IsString() + dateTo?: string; + + @IsOptional() + @IsString() + search?: string; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/dto/update-interchange-document-status.dto.ts b/apps/edr-freight-api/src/modules/interchange-documents/dto/update-interchange-document-status.dto.ts new file mode 100644 index 000000000..9491d0528 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/dto/update-interchange-document-status.dto.ts @@ -0,0 +1,16 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class AcknowledgeInterchangeDocumentDto { + @IsString() + @MaxLength(120) + acknowledgedBy!: string; + + @IsOptional() + @IsString() + remarks?: string; +} + +export class DisputeInterchangeDocumentDto { + @IsString() + remarks!: string; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document-item.entity.ts b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document-item.entity.ts new file mode 100644 index 000000000..d51d2f311 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document-item.entity.ts @@ -0,0 +1,85 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { InterchangeDocument } from './interchange-document.entity'; + +export const INTERCHANGE_ITEM_TYPES = ['CONTAINER', 'CARGO'] as const; +export type InterchangeItemType = (typeof INTERCHANGE_ITEM_TYPES)[number]; + +export const INTERCHANGE_CONDITION_STATUSES = [ + 'GOOD', + 'DAMAGED', + 'SHORTAGE', + 'EXCESS', + 'HOLD', + 'UNKNOWN', +] as const; +export type InterchangeConditionStatus = (typeof INTERCHANGE_CONDITION_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'interchange_document_items' }) +@Index(['interchangeDocumentId']) +@Index(['bookingId']) +export class InterchangeDocumentItem extends BaseEntity { + @Column({ name: 'interchange_document_id', type: 'uuid' }) + interchangeDocumentId!: string; + + @ManyToOne(() => InterchangeDocument, (document) => document.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'interchange_document_id' }) + document?: InterchangeDocument; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'booking_reference', type: 'varchar', length: 64, nullable: true }) + bookingReference?: string | null; + + @Column({ name: 'item_type', type: 'varchar', length: 20 }) + itemType!: InterchangeItemType; + + @Column({ name: 'booking_container_id', type: 'uuid', nullable: true }) + bookingContainerId?: string | null; + + @Column({ name: 'booking_cargo_id', type: 'uuid', nullable: true }) + bookingCargoId?: string | null; + + @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) + containerNumber?: string | null; + + @Column({ name: 'seal_number', type: 'varchar', length: 100, nullable: true }) + sealNumber?: string | null; + + @Column({ name: 'cargo_id', type: 'uuid', nullable: true }) + cargoId?: string | null; + + @Column({ name: 'cargo_type', type: 'varchar', length: 255, nullable: true }) + cargoType?: string | null; + + @Column({ name: 'cargo_description', type: 'text', nullable: true }) + cargoDescription?: string | null; + + @Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + weight?: number | null; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, nullable: true }) + quantity?: number | null; + + @Column({ name: 'package_count', type: 'int', nullable: true }) + packageCount?: number | null; + + @Column({ name: 'wagon_number', type: 'varchar', length: 80, nullable: true }) + wagonNumber?: string | null; + + @Column({ name: 'condition_status', type: 'varchar', length: 20, default: 'GOOD' }) + conditionStatus!: InterchangeConditionStatus; + + @Column({ name: 'damage_description', type: 'text', nullable: true }) + damageDescription?: string | null; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document.entity.ts b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document.entity.ts new file mode 100644 index 000000000..2d8f28aa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/entities/interchange-document.entity.ts @@ -0,0 +1,89 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { InterchangeDocumentItem } from './interchange-document-item.entity'; + +export const INTERCHANGE_DIRECTIONS = ['IMPORT', 'EXPORT'] as const; +export type InterchangeDirection = (typeof INTERCHANGE_DIRECTIONS)[number]; + +export const INTERCHANGE_DOCUMENT_STATUSES = [ + 'DRAFT', + 'GENERATED', + 'ACKNOWLEDGED', + 'DISPUTED', + 'CANCELLED', +] as const; +export type InterchangeDocumentStatus = (typeof INTERCHANGE_DOCUMENT_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'interchange_documents' }) +@Index(['documentNo'], { unique: true }) +@Index(['direction']) +@Index(['status']) +@Index(['scheduleId']) +export class InterchangeDocument extends BaseEntity { + @Column({ name: 'document_no', type: 'varchar', length: 40, unique: true }) + documentNo!: string; + + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction!: InterchangeDirection; + + @Column({ name: 'schedule_id', type: 'uuid', nullable: true }) + scheduleId?: string | null; + + @Column({ name: 'train_no', type: 'varchar', length: 40, nullable: true }) + trainNo?: string | null; + + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string | null; + + @Column({ name: 'origin_facility_id', type: 'uuid', nullable: true }) + originFacilityId?: string | null; + + @Column({ name: 'destination_facility_id', type: 'uuid', nullable: true }) + destinationFacilityId?: string | null; + + @Column({ name: 'handover_location', type: 'varchar', length: 255 }) + handoverLocation!: string; + + @Column({ name: 'handover_from', type: 'varchar', length: 255 }) + handoverFrom!: string; + + @Column({ name: 'handover_to', type: 'varchar', length: 255 }) + handoverTo!: string; + + @Column({ name: 'operator_name', type: 'varchar', length: 255, nullable: true }) + operatorName?: string | null; + + @Column({ name: 'port_operator_name', type: 'varchar', length: 255, nullable: true }) + portOperatorName?: string | null; + + @Column({ name: 'shipping_line_name', type: 'varchar', length: 255, nullable: true }) + shippingLineName?: string | null; + + @Column({ name: 'customs_reference', type: 'varchar', length: 120, nullable: true }) + customsReference?: string | null; + + @Column({ name: 'manifest_reference', type: 'varchar', length: 120, nullable: true }) + manifestReference?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: InterchangeDocumentStatus; + + @Column({ name: 'generated_at', type: 'timestamptz', nullable: true }) + generatedAt?: Date | null; + + @Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true }) + acknowledgedAt?: Date | null; + + @Column({ name: 'generated_by', type: 'varchar', length: 120, nullable: true }) + generatedBy?: string | null; + + @Column({ name: 'acknowledged_by', type: 'varchar', length: 120, nullable: true }) + acknowledgedBy?: string | null; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; + + @OneToMany(() => InterchangeDocumentItem, (item) => item.document) + items?: InterchangeDocumentItem[]; +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts new file mode 100644 index 000000000..fb6478400 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto'; +import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto'; +import { + AcknowledgeInterchangeDocumentDto, + DisputeInterchangeDocumentDto, +} from './dto/update-interchange-document-status.dto'; +import { InterchangeDocumentsService } from './interchange-documents.service'; + +@ApiTags('interchange-documents') +@ApiBearerAuth() +@Controller('interchange-documents') +export class InterchangeDocumentsController { + constructor(private readonly service: InterchangeDocumentsService) {} + + @Get() + @ApiOperation({ summary: 'List interchange documents' }) + findAll(@Query() query: InterchangeDocumentQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get interchange document detail' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findOne(id); + } + + @Post('generate-from-schedule') + @ApiOperation({ summary: 'Generate interchange document from a train schedule handover' }) + generateFromSchedule(@Body() dto: GenerateFromScheduleDto) { + return this.service.generateFromSchedule(dto); + } + + @Patch(':id/acknowledge') + @ApiOperation({ summary: 'Acknowledge an interchange document' }) + acknowledge( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AcknowledgeInterchangeDocumentDto, + ) { + return this.service.acknowledge(id, dto); + } + + @Patch(':id/dispute') + @ApiOperation({ summary: 'Dispute an interchange document' }) + dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) { + return this.service.dispute(id, dto); + } + + @Patch(':id/cancel') + @ApiOperation({ summary: 'Cancel a draft/generated interchange document' }) + cancel(@Param('id', ParseUUIDPipe) id: string) { + return this.service.cancel(id); + } +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.module.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.module.ts new file mode 100644 index 000000000..cd8927c76 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { InterchangeDocumentItem } from './entities/interchange-document-item.entity'; +import { InterchangeDocument } from './entities/interchange-document.entity'; +import { InterchangeDocumentsController } from './interchange-documents.controller'; +import { InterchangeDocumentsRepository } from './interchange-documents.repository'; +import { InterchangeDocumentsService } from './interchange-documents.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([InterchangeDocument, InterchangeDocumentItem])], + controllers: [InterchangeDocumentsController], + providers: [InterchangeDocumentsRepository, InterchangeDocumentsService], + exports: [InterchangeDocumentsService], +}) +export class InterchangeDocumentsModule {} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.repository.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.repository.ts new file mode 100644 index 000000000..0a4bb3a19 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { InterchangeDocument } from './entities/interchange-document.entity'; + +@Injectable() +export class InterchangeDocumentsRepository extends BaseRepository { + constructor(@InjectRepository(InterchangeDocument) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts new file mode 100644 index 000000000..667f22659 --- /dev/null +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -0,0 +1,392 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, ILike, Not } from 'typeorm'; + +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto'; +import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto'; +import { + AcknowledgeInterchangeDocumentDto, + DisputeInterchangeDocumentDto, +} from './dto/update-interchange-document-status.dto'; +import { InterchangeDocumentItem } from './entities/interchange-document-item.entity'; +import { + InterchangeDirection, + InterchangeDocument, + InterchangeDocumentStatus, +} from './entities/interchange-document.entity'; + +interface ScheduleSnapshot { + id: string; + status: string; + trainNo: string | null; + routeId: string | null; + originFacilityId: string | null; + destinationFacilityId: string | null; + originCountry: string | null; + destinationCountry: string | null; +} + +interface InterchangeItemSnapshot { + bookingId: string; + bookingReference: string | null; + itemType: 'CONTAINER' | 'CARGO'; + bookingContainerId: string | null; + bookingCargoId: string | null; + containerNumber: string | null; + sealNumber: string | null; + cargoId: string | null; + cargoType: string | null; + cargoDescription: string | null; + weight: string | number | null; + quantity: string | number | null; + packageCount: string | number | null; + wagonNumber: string | null; + hasDamage: boolean | null; + damageDescription: string | null; + hasWeightLoss: boolean | null; + hasMissingItems: boolean | null; + missingItemsDescription: string | null; +} + +@Injectable() +export class InterchangeDocumentsService { + constructor(private readonly dataSource: DataSource) {} + + async findAll(query: InterchangeDocumentQueryDto): Promise { + const where: FindOptionsWhere[] = []; + const base: FindOptionsWhere = { + ...(query.direction ? { direction: query.direction } : {}), + ...(query.status ? { status: query.status } : {}), + ...(query.scheduleId ? { scheduleId: query.scheduleId } : {}), + ...(query.documentNo ? { documentNo: ILike(`%${query.documentNo}%`) } : {}), + }; + + const search = query.search?.trim(); + if (search) { + where.push( + { ...base, documentNo: ILike(`%${search}%`) }, + { ...base, trainNo: ILike(`%${search}%`) }, + { ...base, handoverLocation: ILike(`%${search}%`) }, + { ...base, handoverFrom: ILike(`%${search}%`) }, + { ...base, handoverTo: ILike(`%${search}%`) }, + ); + } + + const qb = this.dataSource + .getRepository(InterchangeDocument) + .createQueryBuilder('doc') + .leftJoinAndSelect('doc.items', 'items') + .where(where.length ? where : base) + .orderBy('doc.createdAt', 'DESC') + .addOrderBy('items.createdAt', 'ASC'); + + if (query.dateFrom) qb.andWhere('doc.created_at >= :dateFrom', { dateFrom: query.dateFrom }); + if (query.dateTo) qb.andWhere('doc.created_at <= :dateTo', { dateTo: query.dateTo }); + + return qb.getMany(); + } + + async findOne(id: string): Promise { + const document = await this.dataSource.getRepository(InterchangeDocument).findOne({ + where: { id }, + relations: { items: true }, + order: { items: { createdAt: 'ASC' } }, + }); + if (!document) throw new NotFoundException(`Interchange document ${id} not found`); + return document; + } + + async generateFromSchedule(dto: GenerateFromScheduleDto): Promise { + const existing = await this.dataSource.getRepository(InterchangeDocument).findOne({ + where: { + scheduleId: dto.scheduleId, + direction: dto.direction, + status: Not('CANCELLED') as unknown as InterchangeDocumentStatus, + }, + relations: { items: true }, + }); + if (existing) return existing; + + const schedule = await this.getScheduleSnapshot(dto.scheduleId); + const routeDirection = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + if (routeDirection !== dto.direction) { + throw new BadRequestException(`Train schedule route is ${routeDirection}, not ${dto.direction}`); + } + + const itemSnapshots = await this.getScheduleItems(dto.scheduleId); + if (itemSnapshots.length === 0) { + throw new BadRequestException('No booking/container/cargo items found for this schedule'); + } + + return this.dataSource.transaction(async (manager) => { + const now = new Date(); + const document = manager.getRepository(InterchangeDocument).create({ + documentNo: await this.nextDocumentNo(dto.direction), + direction: dto.direction, + scheduleId: schedule.id, + trainNo: schedule.trainNo, + routeId: schedule.routeId, + originFacilityId: schedule.originFacilityId, + destinationFacilityId: schedule.destinationFacilityId, + handoverLocation: dto.handoverLocation.trim(), + handoverFrom: dto.handoverFrom.trim(), + handoverTo: dto.handoverTo.trim(), + operatorName: dto.operatorName?.trim() || null, + portOperatorName: dto.portOperatorName?.trim() || null, + shippingLineName: dto.shippingLineName?.trim() || null, + customsReference: dto.customsReference?.trim() || null, + manifestReference: dto.manifestReference?.trim() || null, + status: 'GENERATED', + generatedAt: now, + generatedBy: dto.generatedBy?.trim() || null, + remarks: dto.remarks?.trim() || null, + }); + const saved = await manager.getRepository(InterchangeDocument).save(document); + + const items = itemSnapshots.map((item) => + manager.getRepository(InterchangeDocumentItem).create({ + interchangeDocumentId: saved.id, + bookingId: item.bookingId, + bookingReference: item.bookingReference, + itemType: item.itemType, + bookingContainerId: item.bookingContainerId, + bookingCargoId: item.bookingCargoId, + containerNumber: item.containerNumber, + sealNumber: item.sealNumber, + cargoId: item.cargoId, + cargoType: item.cargoType, + cargoDescription: item.cargoDescription, + weight: item.weight === null ? null : Number(item.weight) || null, + quantity: item.quantity === null ? null : Number(item.quantity) || null, + packageCount: item.packageCount === null ? null : Number(item.packageCount) || null, + wagonNumber: item.wagonNumber, + conditionStatus: this.conditionFromInspection(item), + damageDescription: + item.damageDescription ?? item.missingItemsDescription ?? null, + remarks: null, + }), + ); + await manager.getRepository(InterchangeDocumentItem).save(items); + + return manager.getRepository(InterchangeDocument).findOneOrFail({ + where: { id: saved.id }, + relations: { items: true }, + order: { items: { createdAt: 'ASC' } }, + }); + }); + } + + async acknowledge( + id: string, + dto: AcknowledgeInterchangeDocumentDto, + ): Promise { + const document = await this.findOne(id); + if (document.status === 'CANCELLED') { + throw new BadRequestException('Cancelled interchange document cannot be acknowledged'); + } + await this.dataSource.getRepository(InterchangeDocument).update(id, { + status: 'ACKNOWLEDGED', + acknowledgedAt: new Date(), + acknowledgedBy: dto.acknowledgedBy, + remarks: dto.remarks ?? document.remarks ?? null, + }); + return this.findOne(id); + } + + async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise { + await this.findOne(id); + await this.dataSource.getRepository(InterchangeDocument).update(id, { + status: 'DISPUTED', + remarks: dto.remarks, + }); + return this.findOne(id); + } + + async cancel(id: string): Promise { + const document = await this.findOne(id); + if (!['DRAFT', 'GENERATED'].includes(document.status)) { + throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`); + } + await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' }); + return this.findOne(id); + } + + private async getScheduleSnapshot(scheduleId: string): Promise { + const [schedule] = await this.dataSource.query( + `SELECT ts.id, + ts.status, + ts.train_number AS "trainNo", + ts.route_id AS "routeId", + ts.origin_station_id AS "originFacilityId", + ts.destination_station_id AS "destinationFacilityId", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + return schedule; + } + + private async getScheduleItems(scheduleId: string): Promise { + return this.dataSource.query( + `WITH assigned AS ( + SELECT b.id AS booking_id, + b.reference, + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS booking_cargo_type, + b.cargo_free_text, + b.cargo_total_weight_vgm, + ( + SELECT string_agg(DISTINCT w.wagon_number, ', ' ORDER BY w.wagon_number) + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE wba.booking_id = b.id + ) AS wagon_number, + bool_or(COALESCE(wir.has_damage, false)) AS has_damage, + bool_or(COALESCE(wir.has_weight_loss, false)) AS has_weight_loss, + bool_or(COALESCE(wir.has_missing_items, false)) AS has_missing_items, + string_agg(DISTINCT NULLIF(wir.damage_description, ''), '; ') AS damage_description, + string_agg(DISTINCT NULLIF(wir.missing_items_description, ''), '; ') AS missing_items_description + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouse_inspection_reports wir ON wir.inventory_id = inv.id AND wir.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + GROUP BY b.id, b.reference, cgt.cargo_type_name, b.cargo_free_text, b.cargo_total_weight_vgm + ) + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + 'CONTAINER' AS "itemType", + COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId", + NULL AS "bookingCargoId", + COALESCE(c.container_number, bc.container_number) AS "containerNumber", + c.seal_number AS "sealNumber", + NULL AS "cargoId", + a.booking_cargo_type AS "cargoType", + a.cargo_free_text AS "cargoDescription", + COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight", + COALESCE(bc.quantity, 1) AS "quantity", + COALESCE(bc.quantity, 1) AS "packageCount", + a.wagon_number AS "wagonNumber", + a.has_damage AS "hasDamage", + a.damage_description AS "damageDescription", + a.has_weight_loss AS "hasWeightLoss", + a.has_missing_items AS "hasMissingItems", + a.missing_items_description AS "missingItemsDescription" + FROM assigned a + JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL + LEFT JOIN freight.booking_container bc ON bc.id = c.booking_container_id AND bc.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + 'CONTAINER' AS "itemType", + bc.id AS "bookingContainerId", + NULL AS "bookingCargoId", + bc.container_number AS "containerNumber", + NULL AS "sealNumber", + NULL AS "cargoId", + a.booking_cargo_type AS "cargoType", + a.cargo_free_text AS "cargoDescription", + COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight", + bc.quantity AS "quantity", + bc.quantity AS "packageCount", + a.wagon_number AS "wagonNumber", + a.has_damage AS "hasDamage", + a.damage_description AS "damageDescription", + a.has_weight_loss AS "hasWeightLoss", + a.has_missing_items AS "hasMissingItems", + a.missing_items_description AS "missingItemsDescription" + FROM assigned a + JOIN freight.booking_container bc ON bc.booking_id = a.booking_id AND bc.deleted_at IS NULL + WHERE NOT EXISTS ( + SELECT 1 FROM freight.containers c + WHERE c.booking_container_id = bc.id AND c.deleted_at IS NULL + ) + UNION ALL + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + 'CARGO' AS "itemType", + NULL AS "bookingContainerId", + cg.id AS "bookingCargoId", + NULL AS "containerNumber", + NULL AS "sealNumber", + cg.id AS "cargoId", + COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType", + COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription", + COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight", + cg.quantity AS "quantity", + cg.quantity AS "packageCount", + a.wagon_number AS "wagonNumber", + a.has_damage AS "hasDamage", + a.damage_description AS "damageDescription", + a.has_weight_loss AS "hasWeightLoss", + a.has_missing_items AS "hasMissingItems", + a.missing_items_description AS "missingItemsDescription" + FROM assigned a + JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + UNION ALL + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", + NULL AS "bookingContainerId", + NULL AS "bookingCargoId", + NULL AS "containerNumber", + NULL AS "sealNumber", + NULL AS "cargoId", + a.booking_cargo_type AS "cargoType", + a.cargo_free_text AS "cargoDescription", + a.cargo_total_weight_vgm AS "weight", + 1 AS "quantity", + 1 AS "packageCount", + a.wagon_number AS "wagonNumber", + a.has_damage AS "hasDamage", + a.damage_description AS "damageDescription", + a.has_weight_loss AS "hasWeightLoss", + a.has_missing_items AS "hasMissingItems", + a.missing_items_description AS "missingItemsDescription" + FROM assigned a + WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = a.booking_id AND bc.deleted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL) + ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC, "containerNumber" ASC NULLS LAST`, + [scheduleId], + ); + } + + private conditionFromInspection(item: InterchangeItemSnapshot) { + if (item.hasDamage) return 'DAMAGED'; + if (item.hasWeightLoss || item.hasMissingItems) return 'SHORTAGE'; + return 'GOOD'; + } + + private async nextDocumentNo(direction: InterchangeDirection): Promise { + const prefix = `ICD-${direction === 'EXPORT' ? 'EXP' : 'IMP'}-${this.yyyymmdd(new Date())}`; + const [row] = await this.dataSource.query( + `SELECT document_no AS "documentNo" + FROM freight.interchange_documents + WHERE document_no LIKE $1 + ORDER BY document_no DESC + LIMIT 1`, + [`${prefix}-%`], + ); + const last = row?.documentNo ? Number(String(row.documentNo).split('-').pop()) || 0 : 0; + return `${prefix}-${String(last + 1).padStart(4, '0')}`; + } + + private yyyymmdd(date: Date): string { + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}${mm}${dd}`; + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index d25729324..5712f809c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; @@ -29,6 +29,7 @@ export class LastMileService { constructor( private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, + private readonly dataSource: DataSource, ) {} async acceptBooking(bookingReference: string): Promise { @@ -44,6 +45,41 @@ export class LastMileService { ); } + if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { + throw new BadRequestException(`Booking ${bookingReference} is not an import booking`); + } + + if (!booking.lastMileDeliveryAddress?.trim()) { + throw new BadRequestException(`Booking ${bookingReference} has no last-mile delivery address`); + } + + const [eligibleInventory] = await this.dataSource.query( + `SELECT inv.id + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status = 'READY_FOR_PICKUP' + AND inv.inspection_status = 'PASSED' + LIMIT 1`, + [booking.id], + ); + if (!eligibleInventory) { + throw new BadRequestException( + `Booking ${bookingReference} is not eligible for last mile. Import inventory must pass inspection and be READY_FOR_PICKUP.`, + ); + } + + const [existing] = await this.dataSource.query( + `SELECT id + FROM freight.last_mile + WHERE booking_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [booking.id], + ); + if (existing) { + return this.findById(existing.id); + } + return this.create({ bookingId: booking.id, advancedPayment: booking.totalAmount, diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 713edfc64..005118b0a 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -62,13 +62,4 @@ export class Vehicle extends BaseEntity { @Column({ name: 'assigned_driver_name', nullable: true }) assignedDriverName?: string; - - @Column({ name: 'code', nullable: true }) - code?: string; - - @Column({ name: 'power_plate_no', nullable: true }) - powerPlateNo?: string; - - @Column({ name: 'trailer_plate_no', nullable: true }) - trailerPlateNo?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 9d25c974a..9c62aeae5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, Min } from 'class-validator'; export class GenerateInvoiceDto { @ApiPropertyOptional({ description: 'Create even when the calculated amount is zero.' }) @@ -11,6 +11,11 @@ export class GenerateInvoiceDto { @IsOptional() @IsString() performedBy?: string; + + @ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' }) + @IsOptional() + @IsIn(['ETB', 'USD']) + billingCurrency?: 'ETB' | 'USD'; } export class PayInvoiceBodyDto { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 61f991d23..e0a0f2b6c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { ExchangeService } from '@edr/api-common'; import { DataSource } from 'typeorm'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; @@ -28,6 +29,8 @@ export interface FeePreview { freeDays: number; ratePerDay: number; currency: string; + ruleCurrency: string | null; + billingCurrency: string; startDate: string | null; endDate: string; endIsOpen: boolean; // true when still accruing (no release/gate-clear yet) @@ -45,6 +48,7 @@ export class WarehouseFeeService { constructor( private readonly dataSource: DataSource, private readonly feeRuleRepository: WarehouseFeeRuleRepository, + private readonly exchangeService: ExchangeService, ) {} // ── Rule CRUD ────────────────────────────────────────────────────────────── @@ -137,12 +141,32 @@ export class WarehouseFeeService { return best; } - private compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): FeePreview { + private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { + return currency === 'ETB' ? 'ETB' : 'USD'; + } + + private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise { + const from = this.normalizeCurrency(fromCurrency); + const to = this.normalizeCurrency(toCurrency); + if (from === to) return Math.round(amount * 100) / 100; + const rate = await this.exchangeService.getRate(from, to); + return Math.round(amount * rate * 100) / 100; + } + + private async compute( + ruleType: FeeRuleType, + rule: WarehouseFeeRule | null, + item: ItemAttributes, + now: Date, + billingCurrency: string, + ): Promise { const start = item.arrivedAt ? new Date(item.arrivedAt) : null; const endDate = item.gateClearedAt ?? item.releaseDate ?? now; const endIsOpen = !item.gateClearedAt && !item.releaseDate; const freeDays = rule?.freeDays ?? 0; const ratePerDay = Number(rule?.ratePerDay ?? 0); + const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; + const targetCurrency = this.normalizeCurrency(billingCurrency); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); const containerCount = isContainer @@ -154,15 +178,21 @@ export class WarehouseFeeService { : 0; const chargeableDays = Math.max(0, elapsedDays - freeDays); const billableUnits = chargeableDays * containerCount; - const amount = Math.round(billableUnits * ratePerDay * 100) / 100; + const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100; + const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const convertedRatePerDay = ruleCurrency + ? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency) + : 0; return { ruleType, ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays, - ratePerDay, - currency: rule?.currency ?? 'USD', + ratePerDay: convertedRatePerDay, + currency: targetCurrency, + ruleCurrency, + billingCurrency: targetCurrency, startDate: start ? start.toISOString() : null, endDate: new Date(endDate).toISOString(), endIsOpen, @@ -175,14 +205,22 @@ export class WarehouseFeeService { } /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ - async previewForInventory(inventoryId: string): Promise { + async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; - return byType.map((type) => - this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now), + return Promise.all( + byType.map((type) => + this.compute( + type, + this.bestRule(rules.filter((r) => r.ruleType === type), item), + item, + now, + billingCurrency, + ), + ), ); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 3ee45e2e1..1de6daf81 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { FilesService } from '../files/files.service'; +import { LastMileService } from '../last-mile/last-mile.service'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; @@ -16,6 +17,7 @@ export class WarehouseInspectionService { private readonly dataSource: DataSource, private readonly inspectionRepository: WarehouseInspectionRepository, private readonly filesService: FilesService, + private readonly lastMileService: LastMileService, ) {} /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ @@ -70,9 +72,37 @@ export class WarehouseInspectionService { inspectedAt, }); + if (dto.inspectionStatus === 'PASSED') { + await this.markImportPickupReadyAndAcceptLastMile(inventoryId); + } + return report; } + private async markImportPickupReadyAndAcceptLastMile(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.trade_direction AS "tradeDirection", + b.last_mile_delivery_address AS "lastMileDeliveryAddress" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [inventoryId], + ); + if ((row?.tradeDirection ?? '').toUpperCase() !== 'IMPORT') return; + + await this.dataSource.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_PICKUP', + readyForPickupAt: new Date(), + }); + + if (row.bookingReference && row.lastMileDeliveryAddress) { + await this.lastMileService.acceptBooking(row.bookingReference); + } + } + async findByInventory(inventoryId: string): Promise { return this.inspectionRepository.findAll({ where: { inventoryId }, @@ -113,6 +143,9 @@ export class WarehouseInspectionService { await this.dataSource .getRepository(WarehouseInventory) .update(report.inventoryId, { inspectionStatus: dto.inspectionStatus }); + if (dto.inspectionStatus === 'PASSED') { + await this.markImportPickupReadyAndAcceptLastMile(report.inventoryId); + } } return this.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 26b459525..8171983df 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3,6 +3,9 @@ import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrE import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; +import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; +import { LastMileService } from '../last-mile/last-mile.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; @@ -239,6 +242,7 @@ export interface AutoUnloadExportDjiboutiResult { unloadedCount: number; skippedCount: number; failedCount: number; + interchangeDocument?: Pick; results: Array<{ bookingId: string; itemType: 'CONTAINER' | 'CARGO'; @@ -280,6 +284,8 @@ export class WarehouseInventoryService { private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, private readonly pdfService: ContractPdfService, + private readonly interchangeDocuments: InterchangeDocumentsService, + private readonly lastMileService: LastMileService, ) {} /** @@ -1256,6 +1262,24 @@ export class WarehouseInventoryService { } }); + if (result.unloadedCount > 0) { + const document = await this.interchangeDocuments.generateFromSchedule({ + scheduleId, + direction: 'EXPORT', + handoverLocation: schedule.destinationName ?? 'Djibouti Port', + handoverFrom: 'EDR', + handoverTo: 'Djibouti Port Operator', + portOperatorName: 'Doraleh Multipurpose Port', + generatedBy: performedBy, + remarks: 'Generated after export unloading at Djibouti Port', + }); + result.interchangeDocument = { + id: document.id, + documentNo: document.documentNo, + status: document.status, + }; + } + return result; } @@ -1327,6 +1351,7 @@ export class WarehouseInventoryService { manager, ); }); + await this.acceptLastMileIfRequested(item.bookingId); result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' }); } else { result.results.push({ inventoryId, status: 'INSPECTED' }); @@ -1339,6 +1364,20 @@ export class WarehouseInventoryService { // ── Receive ────────────────────────────────────────────────────────────── + private async acceptLastMileIfRequested(bookingId?: string | null): Promise { + if (!bookingId) return; + const [booking] = await this.dataSource.query( + `SELECT reference, + last_mile_delivery_address AS "lastMileDeliveryAddress" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + if (!booking?.reference || !booking.lastMileDeliveryAddress) return; + await this.lastMileService.acceptBooking(booking.reference); + } + async receive(dto: ReceiveWarehouseInventoryDto): Promise { const weight = Number(dto.weight) || 0; const volume = Number(dto.volume) || 0; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index abe514d80..29493bc9a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -14,6 +14,7 @@ import { WarehouseFeeService } from './warehouse-fee.service'; interface GenerateOptions { confirmZero?: boolean; performedBy?: string; + billingCurrency?: 'ETB' | 'USD'; } export interface PayInvoiceDto { @@ -58,7 +59,8 @@ export class WarehouseInvoiceService { ); } - const previews = await this.feeService.previewForInventory(inventoryId); + const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD'; + const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const items = previews @@ -98,7 +100,7 @@ export class WarehouseInvoiceService { const invoiceType: WarehouseInvoiceType = hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; - const currency = items[0]?.currency ?? 'USD'; + const currency = billingCurrency; const now = new Date(); const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 333597618..715080612 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { @@ -79,7 +79,10 @@ export class WarehouseRulesController { @Get('warehouse-inventory/:id/fee-preview') @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) - feePreview(@Param('id', ParseUUIDPipe) id: string) { - return this.feeService.previewForInventory(id); + feePreview( + @Param('id', ParseUUIDPipe) id: string, + @Query('billingCurrency') billingCurrency?: string, + ) { + return this.feeService.previewForInventory(id, billingCurrency); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 02e3bbb4c..98c6f0222 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { FilesModule } from '../files/files.module'; +import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; @@ -65,6 +69,13 @@ import { WarehousesService } from './warehouses.service'; WarehouseFeeInvoiceItem, ]), FilesModule, + InterchangeDocumentsModule, + LastMileModule, + ExchangeModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): ExchangeOptions => + config.get('app.cbeExchange') ?? {}, + }), ], controllers: [ WarehousesController, diff --git a/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts new file mode 100644 index 000000000..a91c0293b --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-export-djibouti-interchange-demo.ts @@ -0,0 +1,186 @@ +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 { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01'; +const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003']; + +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 warehouseRepo = dataSource.getRepository(Warehouse); + const warehouseYardRepo = dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = dataSource.getRepository(WarehouseZone); + const bookingRepo = dataSource.getRepository(Booking); + const inventoryRepo = dataSource.getRepository(WarehouseInventory); + const locomotiveRepo = dataSource.getRepository(Locomotive); + const trainSetRepo = dataSource.getRepository(TrainSet); + const scheduleRepo = dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking); + + const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } }); + if (existingSchedule) { + console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`); + console.log(`Schedule ID: ${existingSchedule.id}`); + return; + } + + const originYard = + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const destinationYard = + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const warehouseYard = warehouse + ? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }) + : null; + const warehouseZone = warehouseYard + ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) + : null; + + const missing = [ + !originYard ? 'Ethiopian origin yard' : '', + !destinationYard ? 'Djibouti destination yard' : '', + !serviceType ? 'service type' : '', + !warehouse ? 'INDODE_OPEN warehouse' : '', + !warehouseYard ? 'warehouse yard' : '', + !warehouseZone ? 'warehouse zone' : '', + ].filter(Boolean); + + if (missing.length) { + throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`); + } + + const now = Date.now(); + const departure = new Date(now - 6 * 60 * 60 * 1000); + const arrival = new Date(now - 60 * 60 * 1000); + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'ICD-DEMO-LOCO', + name: 'Interchange Demo Locomotive', + maxPullWeightTons: 4000, + }), + )); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons: 700, + totalLengthMeters: 360, + wagonCount: 12, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: originYard!.id, + destinationStationId: destinationYard!.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: TRAIN_NUMBER, + }), + ); + + for (const [index, reference] of BOOKING_REFS.entries()) { + const weight = 5200 + index * 800; + const booking = await bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: originYard!.id, + destinationYardId: destinationYard!.id, + serviceTypeId: serviceType!.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + scheduledDate: new Date(), + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: 'EXPORT', + freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`, + cargoTotalWeightVgm: weight, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + warehouseId: warehouse!.id, + yardId: warehouseYard!.id, + zoneId: warehouseZone!.id, + bookingId: booking.id, + quantity: 1, + weight, + status: 'DISPATCHED', + inspectionStatus: 'PASSED', + arrivedAt: new Date(now - 4 * 60 * 60 * 1000), + inspectedAt: new Date(now - 3 * 60 * 60 * 1000), + readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000), + loadedAt: new Date(now - 90 * 60 * 1000), + dispatchedAt: new Date(now - 70 * 60 * 1000), + notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation', + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ + trainScheduleId: schedule.id, + bookingId: booking.id, + }), + ); + } + + console.log('Export Djibouti interchange demo seeded.'); + console.log(`Train number: ${TRAIN_NUMBER}`); + console.log(`Schedule ID: ${schedule.id}`); + console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.'); + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error('Export Djibouti interchange demo seed failed:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f1c3cc3c0..ac8249f4b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -64,6 +64,7 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; +import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; @@ -230,6 +231,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/export-djibouti-unloading", icon: , }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", @@ -390,6 +396,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 56a0d950c..6167feb07 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -1,4 +1,5 @@ -import { Badge, Button, Card, Divider, Group, Loader, Modal, Stack, Text } from '@mantine/core'; +import { useState } from 'react'; +import { Badge, Button, Card, Divider, Group, Loader, Modal, SegmentedControl, Stack, Text } from '@mantine/core'; import { CalendarClock, Coins, DoorOpen, FileText } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -91,10 +92,11 @@ function Row({ label, value }: { label: string; value: string }) { /** Batch 5 fee preview + Batch 6 invoice generation / gate clearance for an inventory item. */ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModalProps) { const { toast } = useToast(); + const [billingCurrency, setBillingCurrency] = useState<'ETB' | 'USD'>('USD'); const enabledId = opened ? inventoryId ?? undefined : undefined; const { data, isLoading } = useQuery( api.warehouses.feePreview.queryOptions({ - input: { inventoryId: enabledId ?? '' }, + input: { inventoryId: enabledId ?? '', billingCurrency }, enabled: Boolean(enabledId), }), ); @@ -108,21 +110,22 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions()); const activeInvoice = (invoices ?? []).find((i) => i.status !== 'CANCELLED'); + const totalPreviewAmount = (data ?? []).reduce((sum, fee) => sum + Number(fee.amount || 0), 0); - const handleGenerate = async (confirmZero = false) => { + const handleGenerate = async () => { if (!inventoryId) return; + if (totalPreviewAmount <= 0) { + toast({ + title: 'No fee to invoice', + description: 'The item is still within the configured free days, or no active fee rule matched it.', + }); + return; + } try { - const inv = await generate.mutateAsync({ inventoryId, confirmZero }); + const inv = await generate.mutateAsync({ inventoryId, billingCurrency }); toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` }); } catch (error) { - const msg = extractErrorMessage(error); - if (/no payable warehouse fee/i.test(msg)) { - if (window.confirm('No payable warehouse fee found. Create a zero-amount invoice anyway?')) { - handleGenerate(true); - } - return; - } - toast({ variant: 'destructive', title: 'Generate failed', description: msg }); + toast({ variant: 'destructive', title: 'Generate failed', description: extractErrorMessage(error) }); } }; @@ -172,6 +175,22 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa + + + Billing currency + + setBillingCurrency(value as 'ETB' | 'USD')} + data={[ + { value: 'USD', label: 'USD' }, + { value: 'ETB', label: 'Birr' }, + ]} + disabled={Boolean(activeInvoice)} + /> + + {activeInvoice ? ( @@ -191,7 +210,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa color="orange" leftSection={} loading={generate.isPending} - onClick={() => handleGenerate(false)} + onClick={handleGenerate} > Generate Fee Invoice diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx new file mode 100644 index 000000000..c18f97acc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InterchangeDocumentDetailPanel.tsx @@ -0,0 +1,128 @@ +import { + Badge, + Group, + Loader, + SimpleGrid, + Stack, + Table, + Text, +} from '@mantine/core'; +import type { ReactNode } from 'react'; + +import { useInterchangeDocument } from '@/hooks/useInterchangeDocuments'; +import type { InterchangeDocumentStatus } from '@/types/interchangeDocument'; +import { formatDate, formatNumber } from './options'; + +const statusColor: Record = { + DRAFT: 'gray', + GENERATED: 'blue', + ACKNOWLEDGED: 'green', + DISPUTED: 'red', + CANCELLED: 'gray', +}; + +function DetailField({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value || '-'} + + + ); +} + +export function InterchangeDocumentDetailPanel({ id }: { id: string }) { + const { data: document, isLoading } = useInterchangeDocument(id); + + if (isLoading) { + return ( + + + + ); + } + + if (!document) return null; + + const items = document.items ?? []; + + return ( + + + + + + + + + + + {document.status} + + } + /> + + + + + + + + + + + Booking Reference + Item Type + Container Number + Seal Number + Cargo Type + Weight + Quantity + Wagon Number + Condition + Damage Description + Remarks + + + + {items.map((item) => ( + + {item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'} + {item.itemType} + {item.containerNumber ?? '-'} + {item.sealNumber ?? '-'} + {item.cargoType ?? item.cargoDescription ?? '-'} + {formatNumber(item.weight)} + {formatNumber(item.quantity)} + {item.wagonNumber ?? '-'} + + + {item.conditionStatus} + + + {item.damageDescription ?? '-'} + {item.remarks ?? '-'} + + ))} + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index c69696bec..a8f0ca1ef 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -6,6 +6,7 @@ import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { lastMileService } from '@/services/last-mile.service'; import { warehouseService } from '@/services/warehouse.service'; import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; import { DeliverInventoryModal } from './DeliverInventoryModal'; @@ -50,6 +51,9 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo api.warehouses.markReadyForPickup.mutationOptions(), ); const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); + const lastMileMutation = useMutation({ + mutationFn: (bookingReference: string) => lastMileService.accept(bookingReference).then((r) => r.data), + }); const inspectMutation = useMutation( api.warehouses.bulkMarkInspected.mutationOptions(), ); @@ -116,6 +120,34 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo } }; + const acceptLastMile = async (item: WarehouseInventoryItem) => { + const reference = item.booking?.reference; + if (!reference) { + toast({ + variant: 'destructive', + title: 'Last mile failed', + description: 'Booking reference is missing for this inventory item.', + }); + return; + } + setBusyId(item.id); + try { + const record = await lastMileMutation.mutateAsync(reference); + toast({ + title: 'Last mile accepted', + description: `${reference} moved to last-mile queue (${record.status.replace(/_/g, ' ')}).`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Last mile failed', + description: extractErrorMessage(error), + }); + } finally { + setBusyId(null); + } + }; + const storeInventory = async (item: WarehouseInventoryItem) => { setBusyId(item.id); try { @@ -195,7 +227,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo onInspect={setInspectItem} onFeePreview={setFeeItem} onReleaseDocument={downloadReleaseDocument} - onLastMile={onLastMile} + onLastMile={onLastMile ? acceptLastMile : undefined} selectedIds={selected} onToggleSelect={toggleSelect} onToggleSelectAll={toggleSelectAll} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 272d9bc62..9f7b568ed 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -212,11 +212,7 @@ export function WarehouseInventoryTable({ )} {onReleaseDocument && item.releaseDate && ( - onReleaseDocument(item)} - > + onReleaseDocument(item)}> diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 2ae92efe2..22236d3d9 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -366,6 +366,15 @@ export const URL_CONSTANTS = { GATE_CLEARANCE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/gate-clearance`, }, + INTERCHANGE_DOCUMENTS: { + BASE: '/interchange-documents', + BY_ID: (id: string) => `/interchange-documents/${id}`, + GENERATE_FROM_SCHEDULE: '/interchange-documents/generate-from-schedule', + ACKNOWLEDGE: (id: string) => `/interchange-documents/${id}/acknowledge`, + DISPUTE: (id: string) => `/interchange-documents/${id}/dispute`, + CANCEL: (id: string) => `/interchange-documents/${id}/cancel`, + }, + VEHICLES: { BASE: '/vehicles', BY_ID: (id: string) => `/vehicles/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index a7c8670cf..030b051a1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts b/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts new file mode 100644 index 000000000..67cf70f24 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useInterchangeDocuments.ts @@ -0,0 +1,76 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { interchangeDocumentsService } from '@/services/interchange-documents.service'; +import type { + GenerateInterchangeDocumentPayload, + InterchangeDocumentFilter, +} from '@/types/interchangeDocument'; + +export const interchangeDocumentKeys = { + all: ['interchange-documents'] as const, + list: (filter?: InterchangeDocumentFilter) => + ['interchange-documents', 'list', filter ?? {}] as const, + detail: (id?: string) => ['interchange-documents', 'detail', id ?? ''] as const, +}; + +export function useInterchangeDocuments(filter?: InterchangeDocumentFilter, enabled = true) { + return useQuery({ + queryKey: interchangeDocumentKeys.list(filter), + queryFn: () => interchangeDocumentsService.list(filter).then((r) => r.data), + enabled, + }); +} + +export function useInterchangeDocument(id?: string) { + return useQuery({ + queryKey: interchangeDocumentKeys.detail(id), + queryFn: () => interchangeDocumentsService.getById(id as string).then((r) => r.data), + enabled: Boolean(id), + }); +} + +function useInterchangeInvalidation() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: interchangeDocumentKeys.all }); + qc.invalidateQueries({ queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'] }); + }; +} + +export function useGenerateInterchangeDocument() { + const onSuccess = useInterchangeInvalidation(); + return useMutation({ + mutationFn: (payload: GenerateInterchangeDocumentPayload) => + interchangeDocumentsService.generateFromSchedule(payload), + onSuccess, + }); +} + +export function useAcknowledgeInterchangeDocument() { + const onSuccess = useInterchangeInvalidation(); + return useMutation({ + mutationFn: (args: { id: string; acknowledgedBy: string; remarks?: string }) => + interchangeDocumentsService.acknowledge(args.id, { + acknowledgedBy: args.acknowledgedBy, + remarks: args.remarks, + }), + onSuccess, + }); +} + +export function useDisputeInterchangeDocument() { + const onSuccess = useInterchangeInvalidation(); + return useMutation({ + mutationFn: (args: { id: string; remarks: string }) => + interchangeDocumentsService.dispute(args.id, { remarks: args.remarks }), + onSuccess, + }); +} + +export function useCancelInterchangeDocument() { + const onSuccess = useInterchangeInvalidation(); + return useMutation({ + mutationFn: (id: string) => interchangeDocumentsService.cancel(id), + onSuccess, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 89c183b62..0f2a23eae 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -301,8 +301,18 @@ export function useExportDjiboutiTrainItems(scheduleId?: string) { } /** Unload eligible export items assigned to an arrived Djibouti-side train. */ -export const useAutoUnloadExportAtDjibouti = () => - useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadExportAtDjibouti(scheduleId)); +export const useAutoUnloadExportAtDjibouti = () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (scheduleId: string) => warehouseService.autoUnloadExportAtDjibouti(scheduleId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); + qc.invalidateQueries({ queryKey: warehouseKeys.all }); + qc.invalidateQueries({ queryKey: ['interchange-documents'] }); + }, + }); +}; /** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ export function useImportUnloadedQueue(enabled = true) { @@ -490,10 +500,10 @@ export const useUpdateFeeRule = () => export const useDeleteFeeRule = () => useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']); -export function useFeePreview(inventoryId?: string) { +export function useFeePreview(inventoryId?: string, billingCurrency: 'ETB' | 'USD' = 'USD') { return useQuery({ - queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'], - queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data), + queryKey: ['warehouse-inventory', inventoryId, 'fee-preview', billingCurrency], + queryFn: () => warehouseService.feePreview(inventoryId as string, billingCurrency).then((r) => r.data), enabled: Boolean(inventoryId), }); } @@ -534,8 +544,15 @@ function useInvoiceInvalidation() { export function useGenerateInvoice() { const onSuccess = useInvoiceInvalidation(); return useMutation({ - mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) => - warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data), + mutationFn: ({ + inventoryId, + confirmZero, + billingCurrency, + }: { + inventoryId: string; + confirmZero?: boolean; + billingCurrency?: 'ETB' | 'USD'; + }) => warehouseService.generateInvoice(inventoryId, confirmZero, billingCurrency).then((r) => r.data), onSuccess, }); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index cf0a75fa8..d0ea713a8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -12,7 +12,6 @@ import { } from '@mantine/core'; import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react'; -import { PageHeader } from '@/components/page'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { VisualEmptyState, @@ -79,17 +78,13 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { {item.bookingReference ?? item.bookingId.slice(0, 8)} - {item.customerName ?? '-'} - {item.containerNumber ?? '-'} - {item.cargoType ?? '-'} + {item.customerName ?? '—'} + {item.containerNumber ?? '—'} + {item.cargoType ?? '—'} {formatNumber(item.weight)} {formatDate(item.arrivalTime)} - + {item.currentStatus ?? 'PENDING'} @@ -112,9 +107,7 @@ export default function ArrivalQueuePage() { const unloadTrain = async (train: ImportTrain) => { setBusyScheduleId(train.scheduleId); try { - const res = (await autoUnload.mutateAsync(train.scheduleId)) as { - data: AutoUnloadArrivedResult; - }; + const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult }; const result = res.data; const details = [ result.skippedCount ? `${result.skippedCount} skipped` : '', @@ -143,11 +136,6 @@ export default function ArrivalQueuePage() { - - - {train.trainNumber ?? '-'} + {train.trainNumber ?? '—'} {train.scheduleId.slice(0, 8)} - {train.route ?? '-'} - {train.origin ?? '-'} - {train.destination ?? '-'} + {train.route ?? '—'} + {train.origin ?? '—'} + {train.destination ?? '—'} {formatDate(train.arrivalTime)} @@ -225,9 +213,7 @@ export default function ArrivalQueuePage() { + {interchangeDocument ? ( + + ) : ( + + )}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx new file mode 100644 index 000000000..32feaf95e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx @@ -0,0 +1,315 @@ +import { useMemo, useState } from 'react'; +import { + Badge, + Button, + Card, + Group, + Loader, + Modal, + SimpleGrid, + Stack, + Table, + Text, + TextInput, +} from '@mantine/core'; +import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { PageContainer, PageHeader } from '@/components/page'; +import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses'; +import { + useAcknowledgeInterchangeDocument, + useCancelInterchangeDocument, + useDisputeInterchangeDocument, + useInterchangeDocument, + useInterchangeDocuments, +} from '@/hooks/useInterchangeDocuments'; +import { useToast } from '@/hooks/use-toast'; +import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument'; + +const statusColor: Record = { + DRAFT: 'gray', + GENERATED: 'blue', + ACKNOWLEDGED: 'green', + DISPUTED: 'red', + CANCELLED: 'gray', +}; + +const getErrorMessage = (error: unknown) => { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { data?: { message?: unknown } } }).response; + const message = response?.data?.message; + if (Array.isArray(message)) return message.join(', '); + if (typeof message === 'string') return message; + } + return error instanceof Error ? error.message : undefined; +}; + +function DetailField({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value || '-'} + + + ); +} + +function InterchangeDocumentDetail({ id }: { id: string }) { + const { data: document, isLoading } = useInterchangeDocument(id); + + if (isLoading) { + return ( + + + + ); + } + + if (!document) return null; + + const items = document.items ?? []; + + return ( + + + + + + + + + + + {document.status} + + } + /> + + + + + + + + + + + Booking Reference + Item Type + Container Number + Seal Number + Cargo Type + Weight + Quantity + Wagon Number + Condition + Damage Description + Remarks + + + + {items.map((item) => ( + + {item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'} + {item.itemType} + {item.containerNumber ?? '-'} + {item.sealNumber ?? '-'} + {item.cargoType ?? item.cargoDescription ?? '-'} + {formatNumber(item.weight)} + {formatNumber(item.quantity)} + {item.wagonNumber ?? '-'} + + + {item.conditionStatus} + + + {item.damageDescription ?? '-'} + {item.remarks ?? '-'} + + ))} + +
+
+
+ ); +} + +export default function InterchangeDocumentsPage() { + const { toast } = useToast(); + const [search, setSearch] = useState(''); + const [viewId, setViewId] = useState(null); + const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]); + const { data: documents = [], isLoading } = useInterchangeDocuments(filter); + const acknowledge = useAcknowledgeInterchangeDocument(); + const dispute = useDisputeInterchangeDocument(); + const cancel = useCancelInterchangeDocument(); + + const run = async (fn: () => Promise, title: string) => { + try { + await fn(); + toast({ title }); + } catch (error) { + toast({ variant: 'destructive', title: 'Action failed', description: getErrorMessage(error) }); + } + }; + + const acknowledgeDocument = (document: InterchangeDocument) => { + const acknowledgedBy = window.prompt('Acknowledged by'); + if (!acknowledgedBy) return; + run( + () => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }), + 'Interchange document acknowledged', + ); + }; + + const disputeDocument = (document: InterchangeDocument) => { + const remarks = window.prompt('Dispute reason'); + if (!remarks) return; + run(() => dispute.mutateAsync({ id: document.id, remarks }), 'Interchange document disputed'); + }; + + return ( + + + + + + {documents.length} document(s) + } + placeholder="Search documents" + value={search} + onChange={(event) => setSearch(event.currentTarget.value)} + /> + + + {isLoading ? ( + + + + ) : documents.length === 0 ? ( + + ) : ( + + + + + Document No + Direction + Train No / Schedule + Route + Handover Location + Handover From + Handover To + Status + Generated At + Actions + + + + {documents.map((document) => ( + + + + {document.documentNo} + + + {document.direction} + + + {document.trainNo ?? '-'} + + {document.scheduleId?.slice(0, 8) ?? '-'} + + + + {document.routeId?.slice(0, 8) ?? '-'} + {document.handoverLocation} + {document.handoverFrom} + {document.handoverTo} + + + {document.status} + + + {formatDate(document.generatedAt)} + + + + {document.status !== 'ACKNOWLEDGED' && document.status !== 'CANCELLED' ? ( + + ) : null} + {document.status !== 'CANCELLED' ? ( + + ) : null} + {document.status === 'DRAFT' || document.status === 'GENERATED' ? ( + + ) : null} + + + + ))} + +
+
+ )} +
+ + setViewId(null)} title="Interchange Document" size="90%"> + {viewId ? : null} + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index fdd0f3563..8ad915446 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -749,12 +749,12 @@ export const api = { () => ["warehouse-fee-rules"], ), - feePreview: endpoint<{ inventoryId: string }, FeePreview[]>( + feePreview: endpoint<{ inventoryId: string; billingCurrency?: 'ETB' | 'USD' }, FeePreview[]>( "warehouse-inventory", "fee-preview", - ({ inventoryId }) => - warehouseService.feePreview(inventoryId).then((r) => r.data), - ({ inventoryId }) => ["warehouse-inventory", inventoryId, "fee-preview"], + ({ inventoryId, billingCurrency }) => + warehouseService.feePreview(inventoryId, billingCurrency).then((r) => r.data), + ({ inventoryId, billingCurrency }) => ["warehouse-inventory", inventoryId, "fee-preview", billingCurrency ?? 'USD'], ), invoices: endpoint<{ filter?: WarehouseInvoiceFilter }, WarehouseFeeInvoice[]>( @@ -1045,14 +1045,14 @@ export const api = { // ── Invoices ─────────────────────────────────────────────────────────── generateInvoice: endpoint< - { inventoryId: string; confirmZero?: boolean }, + { inventoryId: string; confirmZero?: boolean; billingCurrency?: 'ETB' | 'USD' }, WarehouseFeeInvoice >( "warehouse-fee-invoices", "generate", - ({ inventoryId, confirmZero }) => + ({ inventoryId, confirmZero, billingCurrency }) => warehouseService - .generateInvoice(inventoryId, confirmZero) + .generateInvoice(inventoryId, confirmZero, billingCurrency) .then((r) => r.data), undefined, () => [["warehouse-fee-invoices"], ["warehouse-inventory"]], @@ -1980,4 +1980,4 @@ export const api = { ({ range }) => overviewService.getDashboard(range), ), }, -}; \ No newline at end of file +}; diff --git a/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts new file mode 100644 index 000000000..6e2fd3474 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/interchange-documents.service.ts @@ -0,0 +1,33 @@ +import { api as apiClient } from '../auth/http'; + +import { URL_CONSTANTS } from '@/constants/URLS'; +import type { + GenerateInterchangeDocumentPayload, + InterchangeDocument, + InterchangeDocumentFilter, +} from '@/types/interchangeDocument'; + +const cleanParams = (params: object) => + Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null), + ); + +export const interchangeDocumentsService = { + list: (filter?: InterchangeDocumentFilter) => + apiClient.get(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.BASE, { + params: cleanParams(filter ?? {}), + }), + getById: (id: string) => + apiClient.get(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.BY_ID(id)), + generateFromSchedule: (payload: GenerateInterchangeDocumentPayload) => + apiClient.post( + URL_CONSTANTS.INTERCHANGE_DOCUMENTS.GENERATE_FROM_SCHEDULE, + payload, + ), + acknowledge: (id: string, payload: { acknowledgedBy: string; remarks?: string }) => + apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.ACKNOWLEDGE(id), payload), + dispute: (id: string, payload: { remarks: string }) => + apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.DISPUTE(id), payload), + cancel: (id: string) => + apiClient.patch(URL_CONSTANTS.INTERCHANGE_DOCUMENTS.CANCEL(id), {}), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 6872d300e..779fd96aa 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -63,5 +63,5 @@ export const lastMileService = { update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) => api.patch(LM.BY_ID(id), data), accept: (bookingReference: string) => - api.post(LM.ACCEPT(bookingReference)), + api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), }; diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index a130ac6ae..8a0beafb5 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -251,8 +251,10 @@ export const warehouseService = { updateFeeRule: (id: string, payload: Partial) => apiClient.patch(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id), payload), deleteFeeRule: (id: string) => apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.FEES_BY_ID(id)), - feePreview: (inventoryId: string) => - apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId)), + feePreview: (inventoryId: string, billingCurrency?: 'ETB' | 'USD') => + apiClient.get(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), { + params: cleanParams({ billingCurrency }), + }), // ── Batch 6: Warehouse fee invoices ──────────────────────────────────────── listInvoices: (filter?: WarehouseInvoiceFilter) => @@ -265,8 +267,11 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)), invoicesForBooking: (bookingId: string) => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_BOOKING(bookingId)), - generateInvoice: (inventoryId: string, confirmZero = false) => - apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { confirmZero }), + generateInvoice: (inventoryId: string, confirmZero = false, billingCurrency?: 'ETB' | 'USD') => + apiClient.post(URL_CONSTANTS.WAREHOUSE_INVOICES.GENERATE(inventoryId), { + confirmZero, + billingCurrency, + }), cancelInvoice: (id: string) => apiClient.patch(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)), payInvoice: (id: string, payload: PayInvoicePayload) => diff --git a/apps/edr-freight-web/backoffice/src/types/interchangeDocument.ts b/apps/edr-freight-web/backoffice/src/types/interchangeDocument.ts new file mode 100644 index 000000000..a0b4b2658 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/interchangeDocument.ts @@ -0,0 +1,90 @@ +export type InterchangeDirection = 'IMPORT' | 'EXPORT'; +export type InterchangeDocumentStatus = + | 'DRAFT' + | 'GENERATED' + | 'ACKNOWLEDGED' + | 'DISPUTED' + | 'CANCELLED'; +export type InterchangeItemType = 'CONTAINER' | 'CARGO'; +export type InterchangeConditionStatus = + | 'GOOD' + | 'DAMAGED' + | 'SHORTAGE' + | 'EXCESS' + | 'HOLD' + | 'UNKNOWN'; + +export interface InterchangeDocumentItem { + id: string; + interchangeDocumentId: string; + bookingId: string | null; + bookingReference: string | null; + itemType: InterchangeItemType; + bookingContainerId: string | null; + bookingCargoId: string | null; + containerNumber: string | null; + sealNumber: string | null; + cargoId: string | null; + cargoType: string | null; + cargoDescription: string | null; + weight: number | null; + quantity: number | null; + packageCount: number | null; + wagonNumber: string | null; + conditionStatus: InterchangeConditionStatus; + damageDescription: string | null; + remarks: string | null; +} + +export interface InterchangeDocument { + id: string; + documentNo: string; + direction: InterchangeDirection; + scheduleId: string | null; + trainNo: string | null; + routeId: string | null; + originFacilityId: string | null; + destinationFacilityId: string | null; + handoverLocation: string; + handoverFrom: string; + handoverTo: string; + operatorName: string | null; + portOperatorName: string | null; + shippingLineName: string | null; + customsReference: string | null; + manifestReference: string | null; + status: InterchangeDocumentStatus; + generatedAt: string | null; + acknowledgedAt: string | null; + generatedBy: string | null; + acknowledgedBy: string | null; + remarks: string | null; + createdAt: string; + updatedAt: string; + items?: InterchangeDocumentItem[]; +} + +export interface InterchangeDocumentFilter { + direction?: InterchangeDirection; + status?: InterchangeDocumentStatus; + scheduleId?: string; + documentNo?: string; + dateFrom?: string; + dateTo?: string; + search?: string; +} + +export interface GenerateInterchangeDocumentPayload { + scheduleId: string; + direction: InterchangeDirection; + handoverLocation: string; + handoverFrom: string; + handoverTo: string; + operatorName?: string; + portOperatorName?: string; + shippingLineName?: string; + customsReference?: string; + manifestReference?: string; + generatedBy?: string; + remarks?: string; +} diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index c06ec07cb..d5141f23e 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -466,6 +466,11 @@ export interface AutoUnloadExportDjiboutiResult { unloadedCount: number; skippedCount: number; failedCount: number; + interchangeDocument?: { + id: string; + documentNo: string; + status: string; + }; results: Array<{ bookingId: string; itemType: 'CONTAINER' | 'CARGO'; @@ -660,6 +665,8 @@ export interface FeePreview { freeDays: number; ratePerDay: number; currency: string; + ruleCurrency?: string | null; + billingCurrency?: string; startDate: string | null; endDate: string; endIsOpen: boolean; diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index fc9f57a58..dce8aad63 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'http://localhost:3001';