diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 5b333f2b9..580173cc1 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 b764d3428..55d5d2965 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 @@ -52,6 +52,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: 0, diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 8a4d946ad..c48f1fac9 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -1,8 +1,8 @@ -import { Module, forwardRef } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; +import { DynamicModule, Module, forwardRef } from "@nestjs/common"; import { HttpModule } from "@nestjs/axios"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq"; +import { TypeOrmModule } from "@nestjs/typeorm"; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE, @@ -10,33 +10,31 @@ import { PaymentService as PaymentServiceEnum, paymentServiceBindingPattern, } from "@edr/types"; -import { PaymentService } from "./payment.service"; -import { PaymentClientService } from "./payment-client.service"; -import { PaymentController } from "./payment.controller"; -import { PaymentRepository } from "./payment.repository"; -import { PaymentEventsConsumer } from "./payment-events.consumer"; -import { InternalPaymentController } from "./internal-payment.controller"; + import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; -import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; import { FirstMileModule } from "../first-mile/first-mile.module"; -import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; +import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; +import { PaymentEntity } from "./entities/payment.entity"; +import { InternalPaymentController } from "./internal-payment.controller"; +import { PaymentClientService } from "./payment-client.service"; +import { PaymentController } from "./payment.controller"; +import { PaymentEventsConsumer } from "./payment-events.consumer"; +import { PaymentRepository } from "./payment.repository"; +import { PaymentService } from "./payment.service"; const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; -@Module({ - imports: [ - HttpModule.register({ timeout: 10_000 }), - ConfigModule, - DropdownSettingsModule, - forwardRef(() => FirstMileModule), - forwardRef(() => TrainSchedulingModule), - TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), +function rabbitMQImport(): DynamicModule[] { + if (!process.env.PAYMENT_RABBITMQ_URL) return []; + + return [ RabbitMQModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ - uri: config.get("rabbitmq.url") as string, + uri: config.get("rabbitmq.url") ?? process.env.PAYMENT_RABBITMQ_URL ?? "", exchanges: [ { name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } }, { name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } }, @@ -53,6 +51,22 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; connectionInitOptions: { wait: false }, }), }), + ]; +} + +@Module({ + imports: [ + HttpModule.register({ timeout: 10_000 }), + ConfigModule, + DropdownSettingsModule, + forwardRef(() => FirstMileModule), + forwardRef(() => TrainSchedulingModule), + TypeOrmModule.forFeature([ + PaymentEntity, + PaymentWebhookEventEntity, + PaymentRefundEntity, + ]), + ...rabbitMQImport(), ], providers: [ PaymentRepository, @@ -64,4 +78,4 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; controllers: [PaymentController, InternalPaymentController], exports: [PaymentService], }) -export class PaymentModule { } \ No newline at end of file +export class PaymentModule {} 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/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index a5d4e1eeb..815841e54 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -15,6 +15,7 @@ import { WarehouseZone } from './warehouse-zone.entity'; // IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery) export const WAREHOUSE_INVENTORY_STATUSES = [ 'UNLOADED', + 'UNLOADED_AT_DJIBOUTI_PORT', 'RECEIVED', 'STORED', 'RESERVED', @@ -31,12 +32,13 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record + normalized.includes(token), + ); + } + /** Look up a single physical wagon. Returns null if it does not exist. */ async findWagon(wagonId: string): Promise { const rows = await this.dataSource.query( @@ -220,4 +257,187 @@ export class SchedulingReadFacade { ); return rows; } + + /** + * ARRIVED export train schedules at Djibouti-side destinations, with assigned item counts. + * Read-only: this only selects from scheduling/booking/inventory tables. + */ + async exportDjiboutiArrivalQueue( + filter: ExportDjiboutiQueueFilter = {}, + ): Promise { + const params: unknown[] = []; + const where = [ + 'ts.deleted_at IS NULL', + "ts.status = ANY($1)", + `EXISTS ( + SELECT 1 + FROM freight.train_schedule_bookings tsb_exists + JOIN freight.bookings b_exists ON b_exists.id = tsb_exists.booking_id AND b_exists.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory inv_exists ON inv_exists.booking_id = b_exists.id AND inv_exists.deleted_at IS NULL + WHERE tsb_exists.train_schedule_id = ts.id + AND tsb_exists.deleted_at IS NULL + AND (inv_exists.status = ANY($2) OR b_exists.status = ANY($2)) + )`, + ]; + params.push( + filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'], + ['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'], + ); + + if (filter.scheduleId) { + params.push(filter.scheduleId); + where.push(`ts.id = $${params.length}`); + } + if (filter.destination) { + params.push(`%${filter.destination}%`); + where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`); + } + if (filter.dateFrom) { + params.push(filter.dateFrom); + where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) >= $${params.length}`); + } + if (filter.dateTo) { + params.push(filter.dateTo); + where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) <= $${params.length}`); + } + + const rows: Array< + ExportTrainRow & { + originCountry: string | null; + destinationCountry: string | null; + destinationName: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + dy.name AS "destinationName", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + ts.scheduled_departure_date AS "departureTime", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + ts.status, + (SELECT count(*) FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings", + (SELECT count(*) FROM freight.containers c + JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL + WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers", + (SELECT count(*) FROM freight.cargoes cg + JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL + WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes" + 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 ${where.join(' AND ')} + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, + params, + ); + + return rows + .filter((r) => { + const direction = deriveTradeDirection( + { country: r.originCountry }, + { country: r.destinationCountry }, + ); + return ( + direction === 'EXPORT' && + this.isDjiboutiPortDestination(`${r.destination ?? ''} ${r.destinationName ?? ''}`) + ); + }) + .map(({ originCountry: _oc, destinationCountry: _dc, destinationName: _dn, ...rest }) => ({ + ...rest, + totalBookings: Number(rest.totalBookings) || 0, + totalContainers: Number(rest.totalContainers) || 0, + totalCargoes: Number(rest.totalCargoes) || 0, + route: rest.origin || rest.destination ? `${rest.origin ?? '?'} -> ${rest.destination ?? '?'}` : null, + })); + } + + /** Assigned export booking items for an arrived Djibouti-side export train. Read-only. */ + async exportDjiboutiTrainDetail(scheduleId: string): Promise { + const rows: ExportTrainItemRow[] = await this.dataSource.query( + `WITH assigned AS ( + SELECT b.id AS booking_id, + b.reference, + b.company_id, + company.name AS customer_name, + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type, + b.cargo_total_weight_vgm AS booking_weight, + oy.code AS origin, + dy.code AS destination, + ts.train_number, + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS arrival_time, + inv.id AS inventory_id, + COALESCE(inv.status, b.status) AS current_status + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + ) + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + a.company_id AS "customerId", + a.customer_name AS "customerName", + 'CONTAINER' AS "itemType", + c.id AS "itemId", + a.inventory_id AS "inventoryId", + c.container_number AS "containerNumber", + a.cargo_type AS "cargoType", + a.booking_weight AS "weight", + a.origin, + a.destination, + a.train_number AS "trainSchedule", + a.arrival_time AS "arrivalTime", + a.current_status AS "currentStatus" + FROM assigned a + JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + a.company_id AS "customerId", + a.customer_name AS "customerName", + 'CARGO' AS "itemType", + cg.id AS "itemId", + a.inventory_id AS "inventoryId", + NULL AS "containerNumber", + COALESCE(cgt.cargo_type_name, a.cargo_type) AS "cargoType", + COALESCE(cg.weight, a.booking_weight) AS "weight", + a.origin, + a.destination, + a.train_number AS "trainSchedule", + a.arrival_time AS "arrivalTime", + a.current_status AS "currentStatus" + 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", + a.company_id AS "customerId", + a.customer_name AS "customerName", + CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", + a.inventory_id AS "itemId", + a.inventory_id AS "inventoryId", + NULL AS "containerNumber", + a.cargo_type AS "cargoType", + a.booking_weight AS "weight", + a.origin, + a.destination, + a.train_number AS "trainSchedule", + a.arrival_time AS "arrivalTime", + a.current_status AS "currentStatus" + 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.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL) + ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`, + [scheduleId], + ); + return rows; + } } 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.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6e2f17b39..230192a91 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -143,6 +143,36 @@ export class WarehouseInventoryController { return this.inventoryService.importUnloadedQueue(); } + @Get('export/djibouti-arrival-queue') + @ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' }) + exportDjiboutiArrivalQueue( + @Query('scheduleId') scheduleId?: string, + @Query('destination') destination?: string, + @Query('status') status?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + ) { + return this.scheduling.exportDjiboutiArrivalQueue({ + scheduleId, + destination, + status, + dateFrom, + dateTo, + }); + } + + @Get('export/djibouti-trains/:scheduleId/items') + @ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' }) + exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.scheduling.exportDjiboutiTrainDetail(scheduleId); + } + + @Post('export/auto-unload-at-djibouti') + @ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' }) + autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) { + return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy); + } + @Get('import/pickup-ready-queue') @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) importPickupReadyQueue() { 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 bc47e535c..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'; @@ -235,6 +238,23 @@ export interface AutoUnloadArrivedResult { results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; } +export interface AutoUnloadExportDjiboutiResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + interchangeDocument?: Pick; + results: Array<{ + bookingId: string; + itemType: 'CONTAINER' | 'CARGO'; + itemId?: string | null; + inventoryId?: string; + containerNumber?: string | null; + status: string; + message?: string; + reason?: string; + }>; +} + export interface ImportUnloadedRow { id: string; bookingId: string | null; @@ -264,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, ) {} /** @@ -869,6 +891,20 @@ export class WarehouseInventoryService { /** Booking statuses that must never be unloaded into warehouse inventory. */ private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; + private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [ + 'DISPATCHED', + 'IN_TRANSIT', + 'ARRIVED_AT_DJIBOUTI', + 'ARRIVED_AT_PORT', + 'ARRIVED_AT_DESTINATION', + ]; + + private isDjiboutiPortDestination(value: string | null | undefined): boolean { + const normalized = (value ?? '').toUpperCase(); + return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) => + normalized.includes(token), + ); + } /** * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. @@ -1010,6 +1046,243 @@ export class WarehouseInventoryService { return result; } + /** + * Unload eligible EXPORT inventory from an arrived Djibouti-side train. + * This only advances warehouse inventory items assigned to the train and does not write to + * train schedules, wagon assignment, rescheduling, or booking payment state. + */ + async autoUnloadExportAtDjibouti( + scheduleId: string, + performedBy?: string, + ): Promise { + const result: AutoUnloadExportDjiboutiResult = { + unloadedCount: 0, + skippedCount: 0, + failedCount: 0, + results: [], + }; + + const [schedule] = await this.dataSource.query( + `SELECT ts.id, + ts.status, + oy.country AS "originCountry", + dy.country AS "destinationCountry", + dy.code AS "destinationCode", + dy.name AS "destinationName" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + if (direction !== 'EXPORT') { + throw new BadRequestException(`Train schedule route is ${direction}, not EXPORT`); + } + if (!this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + throw new BadRequestException('Train schedule destination is not Djibouti / Doraleh / DMP / DCT / Nagad'); + } + if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) { + throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); + } + + const items: Array<{ + bookingId: string; + inventoryId: string | null; + inventoryStatus: string | null; + bookingStatus: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + itemType: 'CONTAINER' | 'CARGO'; + itemId: string | null; + containerNumber: string | null; + }> = await this.dataSource.query( + `WITH assigned AS ( + SELECT b.id AS booking_id, + b.status AS booking_status, + inv.id AS inventory_id, + inv.status AS inventory_status, + inv.warehouse_id, + inv.yard_id, + inv.zone_id + 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.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + ) + SELECT a.booking_id AS "bookingId", + a.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CONTAINER' AS "itemType", + c.id AS "itemId", + c.container_number AS "containerNumber" + FROM assigned a + JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CARGO' AS "itemType", + cg.id AS "itemId", + NULL AS "containerNumber" + FROM assigned a + JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CARGO' AS "itemType", + a.inventory_id AS "itemId", + NULL AS "containerNumber" + 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.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)`, + [scheduleId], + ); + + const seenInventory = new Set(); + const now = new Date(); + + await this.dataSource.transaction(async (manager) => { + for (const item of items) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId ?? undefined, + containerNumber: item.containerNumber, + status: 'SKIPPED', + reason, + }); + }; + const fail = (reason: string) => { + result.failedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId ?? undefined, + containerNumber: item.containerNumber, + status: 'FAILED', + reason, + }); + }; + + if (!item.inventoryId || !item.warehouseId || !item.yardId || !item.zoneId) { + skip('No warehouse inventory found for assigned export item'); + continue; + } + if (seenInventory.has(item.inventoryId)) { + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Unloaded at Djibouti Port', + }); + continue; + } + + const currentStatus = item.inventoryStatus ?? item.bookingStatus; + if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) { + skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`); + continue; + } + + try { + await manager.getRepository(WarehouseInventory).update(item.inventoryId, { + status: 'UNLOADED_AT_DJIBOUTI_PORT', + unloadedAt: now, + arrivedAt: now, + notes: 'Unloaded at Djibouti Port', + }); + await manager.getRepository(WarehouseInventoryMovement).save( + manager.getRepository(WarehouseInventoryMovement).create({ + inventoryId: item.inventoryId, + fromWarehouseId: item.warehouseId, + fromYardId: item.yardId, + fromZoneId: item.zoneId, + toWarehouseId: item.warehouseId, + toYardId: item.yardId, + toZoneId: item.zoneId, + remarks: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT', + movedBy: performedBy ?? null, + movedAt: now, + }), + ); + await this.activityLog.record( + { + activityType: 'INVENTORY_UNLOADED', + inventoryId: item.inventoryId, + warehouseId: item.warehouseId, + description: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT', + performedBy, + }, + manager, + ); + seenInventory.add(item.inventoryId); + result.unloadedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Unloaded at Djibouti Port', + }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + } + }); + + 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; + } + /** * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING. @@ -1078,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' }); @@ -1090,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 7a14e3ffa..ac8249f4b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,743 +1,703 @@ -import { - Boxes, - Building2, - Container, - FileText, - LayoutDashboard, - LayoutGrid, - Network, - Package, - PackageCheck, - PackageOpen, - Paperclip, - Send, - Settings, - ShieldCheck, - SlidersHorizontal, - Train, - Truck, - Users, - Wallet, -} from "lucide-react"; -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; - -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; -import { useAuth } from "./auth/useAuth"; -import LoadingScreen from "./components/LoadingScreen"; -import LoginPage from "./pages/auth/LoginPage"; -import BookingContractPage from "./pages/bookings/BookingContractPage"; -import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; -import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; -import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; -import NewBookingPage from "./pages/bookings/NewBookingPage"; -import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; -import CustomersPage from "./pages/customers/CustomersPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import MyProfilePage from "./pages/dashboard/MyProfilePage"; -import OverviewPage from "./pages/dashboard/OverviewPage"; -import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; -//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; -import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; -import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; -import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; -import RolesPage from "./pages/dashboard/user-management/RolesPage"; -import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; -import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -import FleetResourcePage from "./pages/fleet/FleetResourcePage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; -import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; -import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; -import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; -import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; -import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; -import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; -import FirstMilePage from "./pages/operations/FirstMilePage"; -import LastMilePage from "./pages/operations/LastMilePage"; -import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; -import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; -import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; -import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; -import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; -import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; -import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; -import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; -import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; -import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; - -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "UM", - href: "/um", - icon: , - }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - { - label: "Customers", - href: "/dashboard/customers", - icon: , - }, - { - label: "Payments", - href: "/dashboard/payments", - icon: , - permission: FREIGHT_PERMS.bookings.view, - }, - ...demoItems, - ], - }, - { - title: "Operations", - items: [ - { - label: "Document Clearance", - href: "/dashboard/clearance", - icon: , - permission: FREIGHT_PERMS.bookings.reviewDocuments, - }, - { - label: "Train Schedules", - href: "/dashboard/operations/train-scheduling-v2", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Batch Board", - href: "/dashboard/operations/batch-board", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "First Mile", - href: "/dashboard/operations/first-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - { - label: "Last Mile", - href: "/dashboard/operations/last-mile", - icon: , - permission: FREIGHT_PERMS.trainScheduling.view, - }, - ], - }, - { - title: "Fleet Management", - items: [ - { - label: "Routes", - href: "/dashboard/routes", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Locomotives", - href: "/dashboard/locomotives", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - - // { - // label: "Wagon types", - // href: "/dashboard/wagon-types", - // icon: , - // }, - { - label: "Wagons", - href: "/dashboard/wagons", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Vehicles", - href: "/dashboard/vehicles", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - { - label: "Drivers", - href: "/dashboard/drivers", - icon: , - permission: FREIGHT_PERMS.fleet.view, - }, - // { - // label: "Containers", - // href: "/dashboard/containers", - // icon: , - // }, - // { - // label: "Cargoes", - // href: "/dashboard/cargoes", - // icon: , - // }, - ], - }, - { - title: "Warehouse Management", - items: [ - { - label: "Warehouse Dashboard", - href: "/dashboard/warehouse-dashboard", - icon: , - }, - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - }, - { - label: "Inventory", - href: "/dashboard/warehouse-inventory", - icon: , - }, - { - label: "Arrival Queue", - href: "/dashboard/arrival-queue", - icon: , - }, - { - label: "Loading Queue", - href: "/dashboard/loading-queue", - icon: , - }, - { - label: "Loaded Inventory", - href: "/dashboard/loaded-inventory", - icon: , - }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - }, - { - label: "Inventory Inquiry", - href: "/dashboard/inventory-inquiry", - icon: , - }, - { - label: "Allocation & Fees", - href: "/dashboard/warehouse-rules", - icon: , - }, - { - label: "Fee Invoices", - href: "/dashboard/warehouse-fee-invoices", - icon: , - }, - ], - }, - { - title: "Administration", - items: [ - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - permission: FREIGHT_PERMS.admin, - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: [ - ...getCategorySidebarChildren("configuration"), - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, - ], - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - ], - }, -]; - -/** Keep only items the user is permitted to see; drop now-empty sections. */ -const filterSidebarByPermission = ( - sections: SidebarSection[], - user: ReturnType["user"], -): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { - if (!item.permission) return true; - const keys = Array.isArray(item.permission) - ? item.permission - : [item.permission]; - return keys.some((key) => hasFreightPermission(user, key)); - }; - - return sections - .map((section) => ({ - ...section, - items: section.items.filter(itemAllowed), - })) - .filter((section) => section.items.length > 0); -}; - -const DashboardShell = () => { - const navigate = useNavigate(); - const location = useLocation(); - const { user, logout } = useAuth(); - - const demoItems: SidebarItem[] = []; - - const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), - user, - ); - const displayName = user?.name?.en || user?.username || user?.email || "User"; - - return ( - - - - ); -}; - -const App = () => { - const { user, loading } = useAuth(); - - if (loading) { - return ; - } - - if (!user) { - return ( - - } /> - } /> - } /> - - ); - } - - return ( - - } /> - } /> - } /> - }> - } /> - } /> - - - - - } - /> - - - - } - /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Legacy embedded user management routes */} - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - - - - - } - /> - - - - } - /> - - } - /> - - - - } - /> - } /> - } /> - } /> - - } - /> - } /> - - } - /> - } /> - - } /> - } /> - - } /> - } /> - - - } /> - - ); -}; - -export default App; +import { + Boxes, + Building2, + Container, + FileText, + LayoutDashboard, + LayoutGrid, + Network, + Package, + PackageCheck, + PackageOpen, + Paperclip, + Send, + Settings, + SlidersHorizontal, + Train, + Truck, + Users, + Wallet, +} from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; + +import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; +import LoginPage from "./pages/auth/LoginPage"; +import BookingContractPage from "./pages/bookings/BookingContractPage"; +import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; +import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; +//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; +import RolesPage from "./pages/dashboard/user-management/RolesPage"; +import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import FirstMilePage from "./pages/operations/FirstMilePage"; +import LastMilePage from "./pages/operations/LastMilePage"; +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"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; +import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; +import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; +import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; + +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "UM", + href: "/um", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + title: "Warehouse Management", + items: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + }, + { + label: "Interchange Documents", + href: "/dashboard/interchange-documents", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], + }, + { + title: "Administration", + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + // { + // label: "Train scheduling rules", + // href: "/dashboard/configuration/train-scheduling-rules", + // }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + ], + }, +]; + +/** Keep only items the user is permitted to see; drop now-empty sections. */ +const filterSidebarByPermission = ( + sections: SidebarSection[], + user: ReturnType["user"], +): SidebarSection[] => { + const itemAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; + + return sections + .map((section) => ({ + ...section, + items: section.items.filter(itemAllowed), + })) + .filter((section) => section.items.length > 0); +}; + +const DashboardShell = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = []; + + const sidebarSections = filterSidebarByPermission( + buildSidebarSections(demoItems), + user, + ); + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( + + + + ); +}; + +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + } /> + + ); + } + + return ( + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + } + /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } /> + } /> + + + } /> + + ); +}; + +export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx index 5fac23ea7..5fc554b20 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx @@ -4,6 +4,7 @@ import { ClipboardCheck, PackageCheck, PackagePlus, + PackageOpen, Send, Truck, Warehouse, @@ -17,12 +18,16 @@ import { formatDate, humanizeEnum } from './options'; const activityIcon: Record = { INVENTORY_RECEIVED: , + INVENTORY_UNLOADED: , INVENTORY_STORED: , INVENTORY_MOVED: , INVENTORY_RESERVED: , READY_FOR_LOADING: , INVENTORY_LOADED: , INVENTORY_DISPATCHED: , + READY_FOR_PICKUP: , + INVENTORY_RELEASED: , + INVENTORY_DELIVERED: , }; export function ActivityTimeline({ inventoryId }: { inventoryId: string }) { 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/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index 8f34de7e7..1f06acd6f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -54,6 +54,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) { const inventoryStatusColor: Record = { UNLOADED: "indigo", + UNLOADED_AT_DJIBOUTI_PORT: "edr-green", RECEIVED: "yellow", STORED: "blue", RESERVED: "grape", diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ede28be37..22236d3d9 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -331,6 +331,10 @@ export const URL_CONSTANTS = { IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings', IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue', IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue', + EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue', + EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) => + `/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`, + EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti', }, WAREHOUSE_LOADINGS: { @@ -362,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/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 2bd7b552b..0f2a23eae 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -282,6 +282,38 @@ export function useImportTrainItems(scheduleId?: string) { export const useAutoUnloadArrivedBookings = () => useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); +/** Arrived EXPORT trains at Djibouti-side ports. Read-only. */ +export function useExportDjiboutiArrivalQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'], + queryFn: () => warehouseService.exportDjiboutiArrivalQueue().then((r) => r.data), + enabled, + }); +} + +/** Assigned export bookings/items for a Djibouti-side arrived export train. Read-only. */ +export function useExportDjiboutiTrainItems(scheduleId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', 'export-djibouti-train-items', scheduleId], + queryFn: () => warehouseService.exportDjiboutiTrainItems(scheduleId as string).then((r) => r.data), + enabled: Boolean(scheduleId), + }); +} + +/** Unload eligible export items assigned to an arrived Djibouti-side train. */ +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) { return useQuery({ @@ -468,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), }); } @@ -512,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() {