From 14a00af3414f99cfef32f1e1a32441b72726f4fe Mon Sep 17 00:00:00 2001 From: hagiye Date: Wed, 1 Jul 2026 16:23:52 +0300 Subject: [PATCH 1/4] train gate pass, Telebirr and Wafi --- apps/edr-freight-api/src/app.module.ts | 4 + ...00000-AddWarehouseAllocationAndFeeRules.ts | 1 + .../1821000000002-CreateInvoices.ts | 88 +- .../1831000000000-AddWarehouseFeeRuleTiers.ts | 19 + ...00-AddCustomerTruckAssignmentToBookings.ts | 29 + .../src/modules/billing/billing.service.ts | 7 +- .../modules/bookings/bookings.controller.ts | 34 + .../src/modules/bookings/bookings.service.ts | 121 ++ .../dto/customer-truck-assignment.dto.ts | 34 + .../bookings/entities/booking.entity.ts | 19 + .../dto/import-djibouti-operation.dto.ts | 23 +- .../import-djibouti-operation.entity.ts | 1 + .../train-scheduling.service.ts | 64 +- .../warehouses/dto/bulk-receive.dto.ts | 12 +- .../modules/warehouses/dto/fee-rule.dto.ts | 28 +- .../entities/warehouse-fee-rule.entity.ts | 9 + .../warehouses/warehouse-fee.service.ts | 164 ++- .../warehouses/warehouse-inventory.service.ts | 106 +- .../warehouse-invoice.controller.ts | 7 + .../warehouses/warehouse-invoice.service.ts | 25 +- .../seed/paid-indode-demo-bookings.seeder.ts | 1131 +++++++++++++++++ .../components/warehouses/FeePreviewModal.tsx | 7 + .../warehouses/ReceiveInventoryModal.tsx | 209 ++- .../warehouses/ReleaseOrderModal.tsx | 27 +- .../backoffice/src/constants/URLS.ts | 1 + .../backoffice/src/constants/apiConfig.ts | 4 +- .../TrainScheduleV2DetailPage.tsx | 129 ++ .../warehouses/WarehouseInvoicesPage.tsx | 65 +- .../pages/warehouses/WarehouseRulesPage.tsx | 238 +++- .../backoffice/src/services/api.ts | 14 + .../src/services/warehouse.service.ts | 4 + .../backoffice/src/types/trainScheduling.ts | 8 + .../backoffice/src/types/warehouse.ts | 54 +- .../portal/src/constants/apiConfig.ts | 4 +- .../src/pages/billing/InvoiceDetailPage.tsx | 45 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 25 +- .../CustomerTruckAssignmentCard.tsx | 151 +++ .../bookings/BookingDetailPage/index.tsx | 2 +- .../portal/src/services/api.ts | 14 + .../portal/src/services/bookings.service.ts | 24 + packages/types/src/freight/index.ts | 7 + 41 files changed, 2807 insertions(+), 151 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts create mode 100644 apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts create mode 100644 apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..ce2ff1ffe 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -59,6 +59,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -156,6 +157,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidIndodeDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -174,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, + private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder, @@ -195,6 +198,7 @@ export class AppModule implements OnApplicationBootstrap { await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.marshallingDemoTrainsSeeder.run(); + await this.paidIndodeDemoBookingsSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts index fa6087faa..1cad0fe66 100644 --- a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts @@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration { name: 'zone_id', type: 'uuid', isNullable: true }, { name: 'free_days', type: 'int', default: 0 }, { name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'tiers', type: 'jsonb', default: "'[]'" }, { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, { name: 'is_active', type: 'boolean', default: true }, { name: 'created_at', type: 'timestamptz', default: 'now()' }, diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 65a3e764b..87a7f39bb 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query( - `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, + ` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(), + ADD COLUMN IF NOT EXISTS invoice_number varchar(64), + ADD COLUMN IF NOT EXISTS company_id uuid, + ADD COLUMN IF NOT EXISTS company_profile_id uuid, + ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB', + ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + ADD COLUMN IF NOT EXISTS source varchar(255), + ADD COLUMN IF NOT EXISTS source_id varchar(255), + ADD COLUMN IF NOT EXISTS type varchar(255), + ADD COLUMN IF NOT EXISTS issued_at timestamptz, + ADD COLUMN IF NOT EXISTS payment_id uuid, + ADD COLUMN IF NOT EXISTS due_at timestamptz, + ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS deleted_at timestamptz; + `, + ); + await queryRunner.query(` + UPDATE freight.invoices + SET due_at = COALESCE(due_at, issued_at, created_at, now()) + WHERE due_at IS NULL; + `); + await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'pk_invoices' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_invoices_invoice_number' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company + FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company_profile' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile + FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_payment' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment + FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`, ); await queryRunner.query(` - CREATE TABLE freight.invoice_lines ( + CREATE TABLE IF NOT EXISTS freight.invoice_lines ( id uuid NOT NULL DEFAULT uuid_generate_v4(), invoice_id uuid NOT NULL, charge_type varchar NOT NULL, @@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query( - `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, ); } diff --git a/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts new file mode 100644 index 000000000..c6da11cc8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface { + name = 'AddWarehouseFeeRuleTiers1831000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + DROP COLUMN IF EXISTS tiers; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts new file mode 100644 index 000000000..c137ca264 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignmentToBookings1832000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customer_truck_arrived_at, + DROP COLUMN IF EXISTS customer_truck_assigned_at, + DROP COLUMN IF EXISTS customer_truck_container_number, + DROP COLUMN IF EXISTS customer_truck_type, + DROP COLUMN IF EXISTS customer_truck_driver_name, + DROP COLUMN IF EXISTS customer_truck_plate_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index f1b58ad5e..421d298ab 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -657,6 +657,11 @@ export class BillingService { throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); } + const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); + if (!(amountDue > 0)) { + throw new BadRequestException("Invoice has no outstanding balance."); + } + const result = await this.payment.initiate({ referenceId: sourceId, source: invoice.source, @@ -667,7 +672,7 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber, - amountMinor: Math.round(Number(invoice.totalAmount)), + amountMinor: Math.round(amountDue), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 0f413199b..6c2dacbe7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -54,6 +54,7 @@ import { StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { @@ -254,6 +255,39 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/customer-truck-assignment') + @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) + async assignCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CustomerTruckAssignmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const assigned = await this.bookingsService.assignCustomerTruck(id, dto); + return this.transitionService.enrichBookingResponse(assigned); + } + + @Get(':id/customer-truck-assignment/freight-order') + @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + async customerTruckFreightOrder( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = + await this.bookingsService.customerTruckFreightOrderCopies(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + @Get(':id/tracking') @ApiOperation({ summary: 'Shipment tracking timeline for a booking', diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a4636cbf7..3b146ca44 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -45,6 +45,8 @@ import { import { Booking } from './entities/booking.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { @@ -81,8 +83,54 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly contractPdfService: ContractPdfService, ) {} + async assignCustomerTruck( + bookingId: string, + dto: CustomerTruckAssignmentDto, + ): Promise { + const booking = await this.findById(bookingId); + if (booking.lastMileDeliveryAddress?.trim()) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when last mile delivery is not selected', + ); + } + if (booking.customerTruckAssignedAt) { + throw new ConflictException('Customer truck assignment is already submitted and locked'); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException('Booking must be paid before assigning an external customer truck'); + } + + await this.bookingsRepository.update(bookingId, { + status: 'TRUCK_ASSIGNED', + customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + customerTruckDriverName: dto.driverName.trim(), + customerTruckType: dto.truckType.trim(), + customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(), + customerTruckAssignedAt: new Date(), + }); + + return this.findById(bookingId); + } + + async customerTruckFreightOrderCopies( + bookingId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + if (!booking.customerTruckAssignedAt) { + throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); + } + + const html = this.buildCustomerTruckFreightOrderHtml(booking); + const buffer = await this.contractPdfService.htmlToPdfBuffer(html); + return { + filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ private async resolveTradeDirectionForBooking( originYardId: string, @@ -120,6 +168,79 @@ export class BookingsService { return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } + private buildCustomerTruckFreightOrderHtml(booking: Booking): string { + const assignedAt = booking.customerTruckAssignedAt + ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') + : '-'; + const rows: Array<[string, string | null | undefined]> = [ + ['Booking Reference', booking.reference], + ['Client Name', booking.company?.name], + ['Client ID', booking.companyId], + ['Trade Direction', booking.tradeDirection], + ['Freight Type', booking.freightType], + ['Truck Plate Number', booking.customerTruckPlateNumber], + ['Driver Name', booking.customerTruckDriverName], + ['Truck Type', booking.customerTruckType], + ['Container Number to Load', booking.customerTruckContainerNumber], + ['Assigned At', assignedAt], + ['Booking Status', booking.status], + ]; + const rowHtml = rows + .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) + .join(''); + const copy = (watermark: string) => ` +
+
${this.escapeHtml(watermark)}
+
+
+

Freight Order

+

Customer external truck assignment

+
+ ${this.escapeHtml(booking.reference)} +
+ ${rowHtml}
+
+
Customer / Carrier Signature
+
Port Operations Verification
+
Gate Security Verification
+
+
`; + + return ` + + + + + + + ${copy('Copy 1: Port Operations Copy')} + ${copy('Copy 2: Gate Security & Carrier Copy')} + + `; + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + /** Build evaluation input from booking freight shape. */ /** * Whether a service type bundles customs clearance. This is the single source diff --git a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts new file mode 100644 index 000000000..9daf7523e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts @@ -0,0 +1,34 @@ +import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; + +export const CUSTOMER_TRUCK_TYPES = [ + 'Flatbed', + 'Container Chassis', + 'Lowboy', + 'Box Truck', + 'Tipper', +] as const; + +export class CustomerTruckAssignmentDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(16) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567', + }) + containerNumberToLoad!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 19aa3a199..97a69e6e7 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [ // Road (truck) drawdown orders skip the train batch pool and wait here for // truck dispatch after Marketing accepts; billed by KM, not wagons. 'ROAD_DISPATCH_PENDING', + 'TRUCK_ASSIGNED', 'OPERATION_REQUESTED', // Operations review gate: customer picks a schedule day and submits the // operation request; the operations team reviews capacity/docs/route before @@ -260,6 +261,24 @@ export class Booking extends BaseEntity { @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) lastMileDeliveryLng?: number | null; + @Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true }) + customerTruckPlateNumber?: string | null; + + @Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true }) + customerTruckDriverName?: string | null; + + @Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true }) + customerTruckType?: string | null; + + @Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true }) + customerTruckContainerNumber?: string | null; + + @Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true }) + customerTruckAssignedAt?: Date | null; + + @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) + customerTruckArrivedAt?: Date | null; + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) customsClearingEnabled!: boolean; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts index 9bd9f3957..d5adce129 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString } from 'class-validator'; +import { IsDateString, IsIn, IsOptional, IsString } from 'class-validator'; export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [ + 'GATE_PASS', 'DELIVERY_ORDER', 'PORT_INVOICE', 'DJIBOUTI_T1', @@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto { } export class ImportDjiboutiActionDto { + @ApiPropertyOptional({ description: 'Gate pass secured date/time. Defaults to now.' }) + @IsOptional() + @IsDateString() + securedAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts index 792792070..47ced8738 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; export type ImportDjiboutiDocumentType = + | 'GATE_PASS' | 'DELIVERY_ORDER' | 'PORT_INVOICE' | 'DJIBOUTI_T1' diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index c71634ccb..4bf0c2140 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -54,7 +54,6 @@ import { type ImportDjiboutiDocumentType, } from './entities/import-djibouti-operation.entity'; import { - IMPORT_DJIBOUTI_DOCUMENT_TYPES, ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; @@ -832,7 +831,7 @@ export class TrainSchedulingService { } async getImportDjiboutiOperation(scheduleId: string) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); return this.mapImportDjiboutiOperation(schedule, operation); } @@ -841,7 +840,7 @@ export class TrainSchedulingService { scheduleId: string, dto: UploadImportDjiboutiDocumentDto, ) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const documents = { ...(operation.documents ?? {}), @@ -865,21 +864,30 @@ export class TrainSchedulingService { } async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); - const missing = this.missingImportDjiboutiDocuments(operation); - if (missing.length) { - throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`); + const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date(); + const documents = { ...(operation.documents ?? {}) }; + if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) { + documents.GATE_PASS = { + fileId: dto.fileId ?? null, + fileUrl: dto.fileUrl ?? null, + reference: dto.reference ?? null, + uploadedAt: new Date().toISOString(), + uploadedBy: dto.performedBy ?? null, + notes: dto.notes ?? null, + }; } await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { - gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(), + documents, + gatepassGrantedAt: securedAt, performedBy: dto.performedBy ?? operation.performedBy ?? null, notes: dto.notes ?? operation.notes ?? null, }); console.log( - `[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`, + `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`, ); return this.getImportDjiboutiOperation(schedule.id); } @@ -1299,16 +1307,28 @@ export class TrainSchedulingService { } private async getImportDjiboutiSchedule(scheduleId: string): Promise { + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); + if (!this.isImportDjiboutiSchedule(schedule)) { + throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti'); + } + return schedule; + } + + private async getDjiboutiGatepassSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!this.isImportDjiboutiSchedule(schedule)) { - throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti'); + if (!this.isDjiboutiGatepassSchedule(schedule)) { + throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes'); } return schedule; } + private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean { + return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule); + } + private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean { const direction = (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? @@ -1323,6 +1343,20 @@ export class TrainSchedulingService { ); } + private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return ( + direction === 'EXPORT' && + this.isDjiboutiPortDestination( + `${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`, + ) + ); + } + private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise { const repo = this.dataSource.getRepository(ImportDjiboutiOperation); const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); @@ -1331,8 +1365,8 @@ export class TrainSchedulingService { } private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] { - const documents = operation?.documents ?? {}; - return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]); + void operation; + return []; } private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void { @@ -1343,6 +1377,7 @@ export class TrainSchedulingService { private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) { const missingDocuments = this.missingImportDjiboutiDocuments(operation); + const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED'; return { trainScheduleId: schedule.id, trainNumber: schedule.trainNumber ?? null, @@ -1350,6 +1385,7 @@ export class TrainSchedulingService { status: { documentsComplete: missingDocuments.length === 0, missingDocuments, + gatepassStatus, gatepassGranted: Boolean(operation.gatepassGrantedAt), readyForLoading: Boolean(operation.readyForLoadingAt), loadedOnTrain: Boolean(operation.loadedOnTrainAt), @@ -1358,6 +1394,8 @@ export class TrainSchedulingService { }, documents: operation.documents ?? {}, gatepassGrantedAt: operation.gatepassGrantedAt ?? null, + gatepassSecuredAt: operation.gatepassGrantedAt ?? null, + gatepassStatus, readyForLoadingAt: operation.readyForLoadingAt ?? null, loadedOnTrainAt: operation.loadedOnTrainAt ?? null, departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null, diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 5e31c8593..8dd0681ce 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @@ -90,6 +90,11 @@ export class TruckEntranceDto { @Min(0) grossWeightKg?: number; + @ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' }) + @IsOptional() + @IsBoolean() + weighingRequired?: boolean; + @ApiPropertyOptional() @IsOptional() @IsNumber() @@ -135,10 +140,11 @@ export class TruckEntranceDto { @IsString() truckType?: string; - @ApiProperty() + @ApiPropertyOptional() + @IsOptional() @IsNumber() @Min(0) - entranceTareWeightKg!: number; + entranceTareWeightKg?: number; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 873f97a6b..e2c20901d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,8 +1,27 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; +export class FeeRuleTierDto { + @ApiProperty({ example: 4 }) + @IsInt() + @Min(1) + fromDay!: number; + + @ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' }) + @IsOptional() + @IsInt() + @Min(1) + toDay?: number | null; + + @ApiProperty({ example: 2500 }) + @IsNumber() + @Min(0) + ratePerDay!: number; +} + export class CreateFeeRuleDto { @ApiProperty() @IsString() @@ -67,6 +86,13 @@ export class CreateFeeRuleDto { @Min(0) ratePerDay!: number; + @ApiPropertyOptional({ type: [FeeRuleTierDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FeeRuleTierDto) + tiers?: FeeRuleTierDto[]; + @ApiPropertyOptional({ default: 'USD' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index f346be282..38dffd235 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm'; export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const; export type FeeRuleType = (typeof FEE_RULE_TYPES)[number]; +export interface WarehouseFeeTier { + fromDay: number; + toDay: number | null; + ratePerDay: number; +} + /** * Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6). * The most specific active rule (highest `specificity` then lowest `priority`) applies to an item. @@ -54,6 +60,9 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) ratePerDay!: number; + @Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" }) + tiers!: WarehouseFeeTier[]; + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) currency!: string; 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 e0a0f2b6c..cfc7fbf6c 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,9 +1,9 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ExchangeService } from '@edr/api-common'; import { DataSource } from 'typeorm'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; -import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; +import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; interface ItemAttributes { @@ -39,6 +39,15 @@ export interface FeePreview { containerCount: number; billableUnits: number; amount: number; + tiers: Array<{ + fromDay: number; + toDay: number | null; + appliedFromDay: number; + appliedToDay: number; + days: number; + ratePerDay: number; + amount: number; + }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -57,11 +66,16 @@ export class WarehouseFeeService { } createRule(dto: CreateFeeRuleDto): Promise { - return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); + return this.feeRuleRepository.create({ + isActive: true, + priority: 100, + currency: 'USD', + ...this.normalizeRuleInput(dto), + }); } async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { - const updated = await this.feeRuleRepository.update(id, dto); + const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto)); if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); return updated; } @@ -70,6 +84,40 @@ export class WarehouseFeeService { return this.feeRuleRepository.softDelete(id); } + private normalizeRuleInput(dto: T): T { + if (dto.tiers === undefined) return dto; + const tiers = (dto.tiers ?? []) + .map((tier) => ({ + fromDay: Number(tier.fromDay), + toDay: tier.toDay == null ? null : Number(tier.toDay), + ratePerDay: Number(tier.ratePerDay), + })) + .filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0); + + for (const tier of tiers) { + if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) { + throw new BadRequestException('Fee tier from day must be a positive whole number.'); + } + if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) { + throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.'); + } + if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) { + throw new BadRequestException('Fee tier rate per day must be zero or greater.'); + } + } + + const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity)); + for (let i = 1; i < sorted.length; i += 1) { + const prev = sorted[i - 1]; + const current = sorted[i]; + if (prev.toDay == null || current.fromDay <= prev.toDay) { + throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.'); + } + } + + return { ...dto, tiers: sorted } as T; + } + private async loadItem(inventoryId: string): Promise { const [row] = await this.dataSource.query( `SELECT inv.arrived_at AS "arrivedAt", @@ -82,16 +130,27 @@ export class WarehouseFeeService { w.facility_id AS "facilityId", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode", - ctt.code AS "containerTypeCode", + COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", + COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + LEFT JOIN LATERAL ( + SELECT bc.container_type_id + FROM freight.booking_container bc + WHERE bc.booking_id = inv.booking_id + AND bc.deleted_at IS NULL + AND bc.container_type_id IS NOT NULL + ORDER BY bc.created_at ASC + LIMIT 1 + ) booking_container_type ON true + LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id LEFT JOIN LATERAL ( SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count FROM freight.booking_container bc @@ -108,16 +167,26 @@ export class WarehouseFeeService { private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null { // Returns specificity score (#matched non-null scope fields), or null if any constraint fails. let score = 0; - const check = (ruleVal: string | null | undefined, itemVal: string | null) => { - if (ruleVal == null) return true; - if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) { + const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null; + const check = ( + ruleVal: string | null | undefined, + itemVal: string | null, + opts: { allowBoth?: boolean } = {}, + ) => { + const ruleCode = normalized(ruleVal); + if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true; + if (opts.allowBoth && ruleCode === 'BOTH') { + score += 1; + return true; + } + if (ruleCode === normalized(itemVal)) { score += 1; return true; } return false; }; if (!check(rule.freightType, item.freightType)) return null; - if (!check(rule.tradeDirection, item.tradeDirection)) return null; + if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; if (!check(rule.facilityId, item.facilityId)) return null; @@ -153,6 +222,60 @@ export class WarehouseFeeService { return Math.round(amount * rate * 100) / 100; } + private calculateTieredAmount( + tiers: WarehouseFeeTier[] | null | undefined, + elapsedDays: number, + containerCount: number, + ): { + sourceAmount: number; + billableUnits: number; + chargeableDays: number; + weightedRatePerDay: number; + tiers: FeePreview['tiers']; + } { + const sourceTiers = (tiers ?? []) + .map((tier) => ({ + fromDay: Number(tier.fromDay), + toDay: tier.toDay == null ? null : Number(tier.toDay), + ratePerDay: Number(tier.ratePerDay), + })) + .filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay)) + .sort((a, b) => a.fromDay - b.fromDay); + + let sourceAmount = 0; + let tierDays = 0; + const appliedTiers: FeePreview['tiers'] = []; + + for (const tier of sourceTiers) { + if (elapsedDays < tier.fromDay) continue; + const appliedFromDay = tier.fromDay; + const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays); + const days = Math.max(0, appliedToDay - appliedFromDay + 1); + if (days <= 0) continue; + + const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100; + sourceAmount += amount; + tierDays += days; + appliedTiers.push({ + fromDay: tier.fromDay, + toDay: tier.toDay, + appliedFromDay, + appliedToDay, + days, + ratePerDay: tier.ratePerDay, + amount, + }); + } + + return { + sourceAmount: Math.round(sourceAmount * 100) / 100, + billableUnits: tierDays * containerCount, + chargeableDays: tierDays, + weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0, + tiers: appliedTiers, + }; + } + private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, @@ -176,13 +299,25 @@ export class WarehouseFeeService { const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) : 0; - const chargeableDays = Math.max(0, elapsedDays - freeDays); - const billableUnits = chargeableDays * containerCount; - const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100; + const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount); + const hasTiers = Boolean(rule?.tiers?.length); + const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays); + const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount; + const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay; const convertedRatePerDay = ruleCurrency - ? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency) + ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency) : 0; + const convertedTiers = ruleCurrency + ? await Promise.all( + tiered.tiers.map(async (tier) => ({ + ...tier, + ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency), + amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency), + })), + ) + : []; return { ruleType, @@ -201,6 +336,7 @@ export class WarehouseFeeService { containerCount, billableUnits, amount, + tiers: hasTiers ? convertedTiers : [], }; } 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 001897b3f..5b6c35afd 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 @@ -221,6 +221,11 @@ export interface EligibleBookingRow { firstMileDriverPhone: string | null; firstMileDriverLicenseNumber: string | null; firstMileTruckType: string | null; + customerTruckPlateNumber: string | null; + customerTruckDriverName: string | null; + customerTruckType: string | null; + customerTruckContainerNumber: string | null; + customerTruckAssignedAt: string | null; } export interface BulkReceiveResult { @@ -302,6 +307,11 @@ export interface ImportUnloadedRow { inspectionStatus: string | null; pickupOption: string; lastMileRequested: boolean; + customerTruckPlateNumber: string | null; + customerTruckDriverName: string | null; + customerTruckType: string | null; + customerTruckContainerNumber: string | null; + customerTruckAssignedAt: string | null; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; @@ -704,7 +714,12 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + b.customer_truck_driver_name AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -796,7 +811,12 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + b.customer_truck_driver_name AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -1044,6 +1064,11 @@ export class WarehouseInventoryService { CASE WHEN b.last_mile_delivery_address IS NOT NULL THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + b.customer_truck_driver_name AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", @@ -1336,6 +1361,16 @@ export class WarehouseInventoryService { if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) { throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); } + const [gatepass] = await this.dataSource.query( + `SELECT gatepass_granted_at AS "gatepassSecuredAt" + FROM freight.import_djibouti_operations + WHERE train_schedule_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!gatepass?.gatepassSecuredAt) { + throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED'); + } const items: Array<{ bookingId: string; @@ -1955,6 +1990,9 @@ export class WarehouseInventoryService { } const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + if (isTruckLeaving) { + await this.invoices.assertClearanceAllowed(id); + } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() : item.releaseDate ?? null; @@ -1967,6 +2005,17 @@ export class WarehouseInventoryService { releaseOrderReference: reference, notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); + if (!isTruckLeaving && item.bookingId) { + await manager.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1 + AND customer_truck_assigned_at IS NOT NULL + AND deleted_at IS NULL`, + [item.bookingId], + ); + } await this.activityLog.record( { activityType: 'INVENTORY_RELEASED', @@ -2034,6 +2083,7 @@ export class WarehouseInventoryService { if (!row.releaseDate) { throw new BadRequestException('A release order must be issued before downloading the exit paper'); } + await this.invoices.assertClearanceAllowed(id); const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -2180,11 +2230,19 @@ export class WarehouseInventoryService { throw new BadRequestException('Please save your signature before approving delivery'); } - const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> = + const [item]: Array<{ + id: string; + warehouseId: string | null; + notes: string | null; + customerTruckAssignedAt: string | null; + customerTruckArrivedAt: string | null; + }> = await this.dataSource.query( `SELECT inv.id, inv.warehouse_id AS "warehouseId", - inv.notes + inv.notes, + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.customer_truck_arrived_at AS "customerTruckArrivedAt" FROM freight.warehouse_inventory inv JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL WHERE inv.booking_id = $1 @@ -2198,6 +2256,10 @@ export class WarehouseInventoryService { if (!item) { throw new BadRequestException('Delivery can be approved after warehouse inspection has passed'); } + if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) { + throw new BadRequestException('Customer truck arrival must be recorded before delivery approval'); + } + await this.invoices.assertClearanceAllowed(item.id); const approvedAt = new Date(); const approval = { @@ -2301,6 +2363,7 @@ export class WarehouseInventoryService { if (!row) { throw new NotFoundException(`Inventory item ${id} not found`); } + await this.invoices.assertClearanceAllowed(id); if (row.inspectionStatus !== 'PASSED') { throw new BadRequestException('Handover document is available after inspection has passed'); } @@ -3302,8 +3365,13 @@ export class WarehouseInventoryService { if (!truckEntrance.driverPhone?.trim()) { throw new BadRequestException('Driver phone is required for entrance registration'); } - if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { - throw new BadRequestException('Entrance tare weight is required for entrance registration'); + if (truckEntrance.weighingRequired) { + if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) { + throw new BadRequestException('Gross weight is required when customer truck weighing is Yes'); + } + if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) { + throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes'); + } } } @@ -3325,6 +3393,11 @@ export class WarehouseInventoryService { firstMileDriverPhone?: string | null; firstMileDriverLicenseNumber?: string | null; firstMileTruckType?: string | null; + customerTruckPlateNumber?: string | null; + customerTruckDriverName?: string | null; + customerTruckType?: string | null; + customerTruckContainerNumber?: string | null; + customerTruckAssignedAt?: string | null; }, ): TruckEntranceDto { return { @@ -3340,16 +3413,22 @@ export class WarehouseInventoryService { booking.containerQuantity !== undefined && booking.containerQuantity !== null ? Number(booking.containerQuantity) : submitted.unitCount, - grossWeightKg: - booking.weight !== undefined && booking.weight !== null - ? Number(booking.weight) - : submitted.grossWeightKg, - truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, + grossWeightKg: submitted.grossWeightKg, + truckPlateNumber: + booking.firstMileTruckPlateNumber?.trim() || + booking.customerTruckPlateNumber?.trim() || + submitted.truckPlateNumber, trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, - driverName: booking.firstMileDriverName?.trim() || submitted.driverName, + driverName: + booking.firstMileDriverName?.trim() || + booking.customerTruckDriverName?.trim() || + submitted.driverName, driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, - truckType: booking.firstMileTruckType?.trim() || submitted.truckType, + truckType: + booking.firstMileTruckType?.trim() || + booking.customerTruckType?.trim() || + submitted.truckType, }; } @@ -3622,6 +3701,7 @@ export class WarehouseInventoryService { truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, + truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null, truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index a66cf91d0..8b16e3bec 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -86,4 +87,10 @@ export class WarehouseInvoiceController { pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { return this.invoiceService.pay(id, dto); } + + @Post('warehouse-fee-invoices/:id/pay-online') + @ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' }) + payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) { + return this.invoiceService.initiatePayment(id, dto); + } } 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 6f7219781..f40153dad 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 @@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; import { DataSource } from 'typeorm'; +import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import { Invoice } from '../billing/entities/invoice.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; @@ -171,8 +172,12 @@ export class WarehouseInvoiceService { feeType, description: p.ruleType === 'STORAGE_FEE' - ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` - : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, + ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${ + p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free` + }` + : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${ + p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free` + }`, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, @@ -305,6 +310,22 @@ export class WarehouseInvoiceService { return detail; } + /** Initiate a wallet/gateway payment for the invoice. */ + async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) { + const invoice = await this.loadWarehouseInvoice(id); + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('Invoice is already fully paid.'); + } + + return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, { + method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'), + platform: dto.platform ?? 'web', + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } + /** * Notify on online (gateway) settlement — the domain side-effect of a warehouse * fee being paid through billing's payment flow. The counter {@link pay} path diff --git a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts new file mode 100644 index 000000000..e33baa7fd --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts @@ -0,0 +1,1131 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CargoUnitOfMeasure, TrainScheduleStatus, WagonStatus } from '@edr/types'; +import { randomUUID } from 'crypto'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { + Company, + CompanyKind, + CompanyNationality, + CompanyStatus, + CompanyType, +} from '../modules/companies/entities/company.entity'; +import { + CompanyProfile, + ProfileStatus, + ProfileType, +} from '../modules/companies/entities/company-profile.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseActivityLog } from '../modules/warehouses/entities/warehouse-activity-log.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Driver, DriverStatus } from '../modules/drivers/entities/driver.entity'; +import { FuelType, Vehicle, VehicleStatus, VehicleType } from '../modules/vehicles/entities/vehicle.entity'; + +const CUSTOMER_TIN = 'US12DEMO01'; + +const DEMO_TRAINS = [ + { + trainNumber: 'US12-DJI-IND-01', + direction: 'IMPORT', + originCode: 'NAGAD', + destinationCode: 'INDODE', + departureHoursAgo: 30, + arrivalHoursAgo: 14, + }, + { + trainNumber: 'US12-IND-DJI-01', + direction: 'EXPORT', + originCode: 'INDODE', + destinationCode: 'NAGAD', + departureHoursAgo: 28, + arrivalHoursAgo: 12, + }, + { + trainNumber: 'US12-DJI-IND-LM-02', + direction: 'IMPORT', + originCode: 'NAGAD', + destinationCode: 'INDODE', + departureHoursAgo: 24, + arrivalHoursAgo: 8, + }, + { + trainNumber: 'US12-IND-DJI-LM-02', + direction: 'EXPORT', + originCode: 'INDODE', + destinationCode: 'NAGAD', + departureHoursAgo: 22, + arrivalHoursAgo: 6, + }, +] as const; + +const TRAIN_DEMO_BOOKINGS = [ + { + reference: 'US12-IMP-FM-001', + trainNumber: 'US12-DJI-IND-01', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + withFirstMile: true, + withLastMile: true, + containerCode: '40FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 27, + totalAmount: 18450, + pickupAddress: 'Doraleh Container Terminal, Djibouti', + pickupLat: 11.5881, + pickupLng: 43.1372, + deliveryAddress: 'Indode bonded warehouse gate, Ethiopia', + deliveryLat: 8.7566, + deliveryLng: 38.9846, + }, + { + reference: 'US12-IMP-NOFM-001', + trainNumber: 'US12-DJI-IND-01', + tradeDirection: 'IMPORT', + freightType: 'BULK', + withFirstMile: false, + withLastMile: false, + containerCode: null, + cargoCode: 'BULK', + weightTons: 42, + totalAmount: 22100, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + }, + { + reference: 'US12-EXP-FM-001', + trainNumber: 'US12-IND-DJI-01', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: true, + withLastMile: true, + containerCode: '20FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 19, + totalAmount: 15680, + pickupAddress: 'Indode export truck gate, Ethiopia', + pickupLat: 8.7566, + pickupLng: 38.9846, + deliveryAddress: 'Nagad Terminal customer handover yard, Djibouti', + deliveryLat: 11.5536, + deliveryLng: 43.1103, + }, + { + reference: 'US12-EXP-NOFM-001', + trainNumber: 'US12-IND-DJI-01', + tradeDirection: 'EXPORT', + freightType: 'BULK', + withFirstMile: false, + withLastMile: false, + containerCode: null, + cargoCode: 'BULK', + weightTons: 55, + totalAmount: 29800, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + }, + { + reference: 'US12-IMP-LM-TRAIN-001', + trainNumber: 'US12-DJI-IND-LM-02', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + withFirstMile: false, + withLastMile: true, + containerCode: '40FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 31, + totalAmount: 20300, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: 'Indode last-mile customer delivery bay, Ethiopia', + deliveryLat: 8.7581, + deliveryLng: 38.9834, + }, + { + reference: 'US12-EXP-LM-TRAIN-001', + trainNumber: 'US12-IND-DJI-LM-02', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: false, + withLastMile: true, + containerCode: '20FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 21, + totalAmount: 17600, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: 'Nagad last-mile consignee handover yard, Djibouti', + deliveryLat: 11.5549, + deliveryLng: 43.1121, + }, +] as const; + +const CUSTOMER_TRUCK_DEMO_BOOKINGS = [ + { + reference: 'US12-EXP-FM-TRUCK-001', + trainNumber: null, + originCode: 'INDODE', + destinationCode: 'NAGAD', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: true, + withLastMile: false, + containerCode: '40FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 24, + totalAmount: 14800, + pickupAddress: 'Customer factory gate, Addis Ababa', + pickupLat: 8.9806, + pickupLng: 38.8736, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + }, + { + reference: 'US12-EXP-NOFM-TRUCK-001', + trainNumber: null, + originCode: 'INDODE', + destinationCode: 'NAGAD', + tradeDirection: 'EXPORT', + freightType: 'CONTAINER', + withFirstMile: false, + withLastMile: false, + containerCode: '20FT', + cargoCode: 'GENERAL_CARGO', + weightTons: 18, + totalAmount: 11200, + pickupAddress: null, + pickupLat: null, + pickupLng: null, + deliveryAddress: null, + deliveryLat: null, + deliveryLng: null, + customerTruckPlateNumber: 'ET-CUS-2046', + customerTruckDriverName: 'Dawit Customer Carrier', + customerTruckType: 'Container Chassis', + customerTruckContainerNumber: 'USDU1234567', + }, +] as const; + +const DEMO_BOOKINGS = [...TRAIN_DEMO_BOOKINGS, ...CUSTOMER_TRUCK_DEMO_BOOKINGS] as const; + +@Injectable() +export class PaidIndodeDemoBookingsSeeder { + private readonly logger = new Logger(PaidIndodeDemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + await this.dataSource.transaction(async (manager) => { + const refs = await this.ensureReferenceData(manager); + const schedules = await this.ensureArrivedTrains(manager, refs); + const bookings = await this.ensureBookings(manager, refs, schedules); + await this.ensureTrainLinks(manager, refs, schedules, bookings); + await this.ensureGatepasses(manager, schedules); + await this.ensureImportWarehouseInventory(manager, bookings); + }); + + this.logger.log( + `US12 paid Indode demo bookings ready: ${DEMO_BOOKINGS.length} booking(s), ${DEMO_TRAINS.length} arrived train(s)`, + ); + } catch (error) { + this.logger.error( + `PaidIndodeDemoBookingsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async ensureReferenceData(manager: EntityManager) { + await manager.getRepository(Yard).upsert( + [ + { + code: 'INDODE', + label: 'Indode Terminal', + country: 'Ethiopia', + isActive: true, + displayOrder: 1, + }, + { + code: 'NAGAD', + label: 'Nagad Terminal, Djibouti', + country: 'Djibouti', + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + [ + { + code: 'RAIL_CONTAINER_FIRST_LAST', + serviceName: 'Rail Freight with First and Last Mile', + description: 'Rail movement with first-mile pickup and last-mile delivery', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 15, + isActive: true, + displayOrder: 3, + }, + { + code: 'RAIL_CONTAINER_LAST_MILE', + serviceName: 'Rail Freight with Last Mile', + description: 'Rail movement with last-mile delivery from terminal', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 8, + isActive: true, + displayOrder: 4, + }, + { + code: 'RAIL_CONTAINER', + serviceName: 'Rail Freight', + description: 'Rail movement without first-mile pickup', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { + code: 'RAIL_CONTAINER_FIRST_MILE', + serviceName: 'Rail Freight with First Mile', + description: 'Rail movement with first-mile pickup to terminal', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 10, + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + [ + { + code: '20FT', + label: '20FT Standard', + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }, + { + code: '40FT', + label: '40FT Standard', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(CargoType).upsert( + [ + { + code: 'GENERAL_CARGO', + cargoTypeName: 'General Cargo', + showFreeTextBox: true, + unitOfMeasure: null, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: 'BULK', + cargoTypeName: 'Bulk Cargo', + showFreeTextBox: true, + unitOfMeasure: CargoUnitOfMeasure.PerTon, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 2, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(WagonType).upsert( + { + code: 'US12-DEMO', + name: 'US12 Demo Flat/Bulk Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER', 'BULK'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 14, + supportsContainer: true, + maxContainerGrossT: 40, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'US12 Indode Demo Customer PLC', + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: CUSTOMER_TIN, + vatNumber: 'VAT-US12-001', + fanNumber: 'US12000000000001', + country: 'Ethiopia', + nationality: CompanyNationality.Ethiopian, + address: 'Bole Road, Addis Ababa, Ethiopia', + phone: '251911120012', + email: 'us12.indode.demo@edr.local', + website: 'https://edr.local/us12-demo', + contactPersonName: 'Aster Bekele', + contactPersonPhone: '251911120013', + generalManagerName: 'Mekonnen Desta', + generalManagerEmail: 'manager.us12.demo@edr.local', + generalManagerPhone: '251911120014', + licenceNumber: 'LIC-US12-2026', + region: 'Addis Ababa', + zone: 'Bole', + woreda: '03', + kebele: '12', + houseNo: 'US12-01', + attributes: { + seededBy: 'PaidIndodeDemoBookingsSeeder', + note: 'Paid customer with import/export demo bookings for US12.', + } as any, + }, + { conflictPaths: { tin: true } }, + ); + + const company = await manager.getRepository(Company).findOneByOrFail({ tin: CUSTOMER_TIN }); + await manager.getRepository(CompanyProfile).upsert( + [ + { + companyId: company.id, + type: ProfileType.importer, + reference: 'US12-IMP', + status: ProfileStatus.Active, + businessLicense: 'BL-US12-IMP-2026', + attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, + }, + { + companyId: company.id, + type: ProfileType.exporter, + reference: 'US12-EXP', + status: ProfileStatus.Active, + businessLicense: 'BL-US12-EXP-2026', + attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, + }, + ], + { conflictPaths: { reference: true } }, + ); + + const [yards, serviceTypes, containerTypes, cargoTypes, wagonType, importerProfile, exporterProfile] = + await Promise.all([ + manager.getRepository(Yard).find({ where: { code: In(['INDODE', 'NAGAD']) } }), + manager + .getRepository(ServiceType) + .find({ + where: { + code: In([ + 'RAIL_CONTAINER', + 'RAIL_CONTAINER_FIRST_MILE', + 'RAIL_CONTAINER_LAST_MILE', + 'RAIL_CONTAINER_FIRST_LAST', + ]), + }, + }), + manager.getRepository(ContainerType).find({ where: { code: In(['20FT', '40FT']) } }), + manager.getRepository(CargoType).find({ where: { code: In(['GENERAL_CARGO', 'BULK']) } }), + manager.getRepository(WagonType).findOneByOrFail({ code: 'US12-DEMO' }), + manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-IMP' }), + manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-EXP' }), + ]); + + return { + company, + importerProfile, + exporterProfile, + yards: new Map(yards.map((yard) => [yard.code, yard])), + serviceTypes: new Map(serviceTypes.map((serviceType) => [serviceType.code, serviceType])), + containerTypes: new Map(containerTypes.map((containerType) => [containerType.code, containerType])), + cargoTypes: new Map(cargoTypes.map((cargoType) => [cargoType.code, cargoType])), + wagonType, + }; + } + + private async ensureArrivedTrains( + manager: EntityManager, + refs: Awaited>, + ): Promise> { + const schedules = new Map(); + const now = new Date(); + + for (const demo of DEMO_TRAINS) { + const origin = refs.yards.get(demo.originCode); + const destination = refs.yards.get(demo.destinationCode); + if (!origin || !destination) { + throw new Error(`US12 demo train missing yard: ${demo.trainNumber}`); + } + + const departure = this.addHours(now, -demo.departureHoursAgo); + const arrival = this.addHours(now, -demo.arrivalHoursAgo); + const locomotive = await this.ensureLocomotive(manager, origin.id); + const trainSet = await this.ensureTrainSet(manager, demo.trainNumber, locomotive.id); + const schedule = await this.ensureTrainSchedule(manager, { + trainNumber: demo.trainNumber, + trainSetId: trainSet.id, + originStationId: origin.id, + destinationStationId: destination.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: departure, + actualArrivalAt: arrival, + direction: demo.direction, + }); + + await manager.getRepository(TrainSet).update(trainSet.id, { + totalWeightTons: TRAIN_DEMO_BOOKINGS.filter((booking) => booking.trainNumber === demo.trainNumber) + .reduce((sum, booking) => sum + booking.weightTons, 0), + totalLengthMeters: 28, + wagonCount: 2, + status: 'COMPLETED', + }); + schedules.set(demo.trainNumber, schedule); + } + + return schedules; + } + + private async ensureBookings( + manager: EntityManager, + refs: Awaited>, + schedules: Map, + ): Promise> { + const bookingRepo = manager.getRepository(Booking); + const bookingContainerRepo = manager.getRepository(BookingContainer); + const firstMileRepo = manager.getRepository(FirstMile); + const lastMileRepo = manager.getRepository(LastMile); + const now = new Date(); + const references = DEMO_BOOKINGS.map((booking) => booking.reference); + const existingBookings = await bookingRepo.find({ where: { reference: In(references) } }); + const existingBookingIds = existingBookings.map((booking) => booking.id); + const firstMileVehicle = await this.ensureFirstMileVehicle(manager); + + if (existingBookingIds.length) { + const existingInventory = await manager.getRepository(WarehouseInventory).find({ + where: { bookingId: In(existingBookingIds) }, + select: { id: true }, + }); + const existingInventoryIds = existingInventory.map((item) => item.id); + if (existingInventoryIds.length) { + await manager.getRepository(WarehouseActivityLog).delete({ + inventoryId: In(existingInventoryIds), + }); + await manager.getRepository(WarehouseInventory).delete({ + id: In(existingInventoryIds), + }); + } + await this.deleteBookingTrainChildren(manager, existingBookingIds); + await bookingContainerRepo.delete({ bookingId: In(existingBookingIds) }); + await firstMileRepo.delete({ bookingId: In(existingBookingIds) }); + await lastMileRepo.delete({ bookingId: In(existingBookingIds) }); + } + + for (const demo of DEMO_BOOKINGS) { + const schedule = demo.trainNumber ? schedules.get(demo.trainNumber) : null; + if (demo.trainNumber && !schedule) { + throw new Error(`US12 demo booking missing train: ${demo.reference}`); + } + + const train = demo.trainNumber ? DEMO_TRAINS.find((item) => item.trainNumber === demo.trainNumber) : null; + const originCode = train?.originCode ?? ('originCode' in demo ? demo.originCode : undefined); + const destinationCode = train?.destinationCode ?? ('destinationCode' in demo ? demo.destinationCode : undefined); + const origin = originCode ? refs.yards.get(originCode) : null; + const destination = destinationCode ? refs.yards.get(destinationCode) : null; + const serviceType = refs.serviceTypes.get( + demo.withFirstMile && demo.withLastMile + ? 'RAIL_CONTAINER_FIRST_LAST' + : demo.withFirstMile + ? 'RAIL_CONTAINER_FIRST_MILE' + : demo.withLastMile + ? 'RAIL_CONTAINER_LAST_MILE' + : 'RAIL_CONTAINER', + ); + const cargoType = refs.cargoTypes.get(demo.cargoCode); + const profile = demo.tradeDirection === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; + + if (!origin || !destination || !serviceType || !cargoType) { + throw new Error(`US12 demo booking missing reference data: ${demo.reference}`); + } + + await bookingRepo.upsert( + { + reference: demo.reference, + companyId: refs.company.id, + companyProfileId: profile.id, + isGovernment: false, + status: + 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber + ? 'TRUCK_ASSIGNED' + : demo.trainNumber + ? 'IN_TRANSIT' + : 'PAID', + scheduledDate: schedule?.scheduledDepartureDate ?? now, + estimatedShipmentDate: schedule?.scheduledDepartureDate ?? now, + totalAmount: demo.totalAmount, + paymentStatus: 'PAID', + contractType: 'NEW', + serviceTypeId: serviceType.id, + firstMilePickupAddress: demo.pickupAddress, + firstMilePickupLat: demo.pickupLat, + firstMilePickupLng: demo.pickupLng, + lastMileDeliveryAddress: demo.deliveryAddress, + lastMileDeliveryLat: demo.deliveryLat, + lastMileDeliveryLng: demo.deliveryLng, + customerTruckPlateNumber: + 'customerTruckPlateNumber' in demo ? demo.customerTruckPlateNumber : null, + customerTruckDriverName: + 'customerTruckDriverName' in demo ? demo.customerTruckDriverName : null, + customerTruckType: + 'customerTruckType' in demo ? demo.customerTruckType : null, + customerTruckContainerNumber: + 'customerTruckContainerNumber' in demo ? demo.customerTruckContainerNumber : null, + customerTruckAssignedAt: + 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber + ? this.addHours(now, -2) + : null, + customerTruckArrivedAt: null, + customsClearingEnabled: false, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demo.tradeDirection, + freightType: demo.freightType, + cargoTypeId: cargoType.id, + cargoFreeText: demo.freightType === 'BULK' ? 'Seeded paid bulk cargo' : 'Seeded paid container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demo.weightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + pnrCode: `PNR-${demo.reference}`, + versionNumber: 1, + approvedByStaffAt: now, + customerSignedAt: now, + fullyExecutedAt: now, + pricingBreakdown: { + paid: true, + source: 'PaidIndodeDemoBookingsSeeder', + firstMileIncluded: demo.withFirstMile, + lastMileIncluded: demo.withLastMile, + }, + priorityScore: demo.withFirstMile ? 30 : demo.withLastMile ? 25 : 20, + wagonsRequired: 1, + schedulingStatus: demo.trainNumber ? 'DISPATCHED' : 'NOT_SCHEDULED', + scheduledAt: demo.trainNumber ? now : null, + trainScheduleId: schedule?.id ?? null, + paymentDeadline: null, + selectedForBatchAt: demo.trainNumber ? now : null, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await bookingRepo.findOneByOrFail({ reference: demo.reference }); + + if (demo.freightType === 'CONTAINER' && demo.containerCode) { + const containerType = refs.containerTypes.get(demo.containerCode); + if (!containerType) { + throw new Error(`US12 demo booking missing container type: ${demo.reference}`); + } + await bookingContainerRepo.insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + containerNumber: this.containerNumber(demo.reference), + containerSize: demo.containerCode.startsWith('40') ? '40ft' : '20ft', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + vgmPerUnitTons: demo.weightTons, + totalVgmTons: demo.weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }); + } + + if (demo.withFirstMile) { + await firstMileRepo.insert({ + id: randomUUID(), + bookingId: booking.id, + status: 'RECEIVED_TO_PORT', + advancedPayment: demo.totalAmount, + remainingPayment: 0, + estimatedKm: demo.tradeDirection === 'IMPORT' ? 12 : 35, + exactKm: demo.tradeDirection === 'IMPORT' ? 11.8 : 34.6, + vehicleId: firstMileVehicle.id, + }); + } + + if (demo.withLastMile) { + await lastMileRepo.insert({ + id: randomUUID(), + bookingId: booking.id, + status: 'DELIVERED', + advancedPayment: demo.totalAmount, + remainingPayment: 0, + estimatedKm: demo.tradeDirection === 'IMPORT' ? 18 : 14, + exactKm: demo.tradeDirection === 'IMPORT' ? 17.5 : 13.8, + vehicleId: null, + }); + } + } + + const savedBookings = await bookingRepo.find({ where: { reference: In(references) } }); + return new Map(savedBookings.map((booking) => [booking.reference, booking])); + } + + private async ensureTrainLinks( + manager: EntityManager, + refs: Awaited>, + schedules: Map, + bookings: Map, + ): Promise { + const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const allocationRepo = manager.getRepository(WagonBookingAllocation); + const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); + const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; + const wagonLength = Number(refs.wagonType.lengthMeters) || 14; + const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; + + for (const demo of TRAIN_DEMO_BOOKINGS) { + const schedule = schedules.get(demo.trainNumber); + const booking = bookings.get(demo.reference); + if (!schedule || !booking) continue; + + const trainBookings = TRAIN_DEMO_BOOKINGS.filter((item) => item.trainNumber === demo.trainNumber); + const sequence = trainBookings.findIndex((item) => item.reference === demo.reference) + 1; + const wagon = await this.ensureWagon(manager, { + wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, + wagonTypeId: refs.wagonType.id, + yardId: schedule.destinationStationId, + trainScheduleId: schedule.id, + trainSetWagonId: null, + tareWeight, + capacityTons: wagonCapacity, + }); + + let trainSetWagon = await trainSetWagonRepo.findOne({ + where: { trainSetId: schedule.trainSetId, sequenceNo: sequence }, + }); + trainSetWagon = await trainSetWagonRepo.save( + trainSetWagonRepo.create({ + ...(trainSetWagon ? { id: trainSetWagon.id } : {}), + trainSetId: schedule.trainSetId, + wagonTypeId: refs.wagonType.id, + physicalWagonId: wagon.id, + sequenceNo: sequence, + capacityTons: wagonCapacity, + lengthMeters: wagonLength, + assignedWeightTons: demo.weightTons, + status: 'DEPARTED', + }), + ); + + await manager.getRepository(Wagon).update(wagon.id, { + trainSetWagonId: trainSetWagon.id, + currentTrainScheduleId: schedule.id, + currentYardId: schedule.destinationStationId, + status: WagonStatus.Assigned, + }); + + const allocation = await allocationRepo.save( + allocationRepo.create({ + trainSetWagonId: trainSetWagon.id, + bookingId: booking.id, + allocatedWeightTons: demo.weightTons, + loadType: demo.freightType, + status: 'DEPARTED', + confirmedAt: schedule.actualDepartureAt ?? new Date(), + }), + ); + + if (demo.freightType === 'CONTAINER') { + const bookingContainer = await manager.getRepository(BookingContainer).findOne({ + where: { bookingId: booking.id }, + }); + const containerType = demo.containerCode ? refs.containerTypes.get(demo.containerCode) : null; + await containerItemRepo.insert({ + id: randomUUID(), + wagonBookingAllocationId: allocation.id, + bookingContainerId: bookingContainer?.id ?? null, + containerNumber: this.containerNumber(demo.reference), + containerTypeId: containerType?.id ?? null, + positionOnWagon: 1, + sealNumber: `SEAL-${demo.reference}`, + chassisNumber: `CHS-${demo.reference}`, + grossWeightTons: demo.weightTons, + }); + } + + await scheduleBookingRepo.insert({ + id: randomUUID(), + trainScheduleId: schedule.id, + bookingId: booking.id, + }); + } + } + + private async ensureGatepasses( + manager: EntityManager, + schedules: Map, + ): Promise { + const repo = manager.getRepository(ImportDjiboutiOperation); + const securedAt = this.addHours(new Date(), -20); + + for (const schedule of schedules.values()) { + const existing = await repo.findOne({ where: { trainScheduleId: schedule.id } }); + await repo.save( + repo.create({ + ...(existing ? { id: existing.id } : {}), + trainScheduleId: schedule.id, + documents: { + ...(existing?.documents ?? {}), + GATE_PASS: { + reference: `GP-${schedule.trainNumber}`, + uploadedAt: securedAt.toISOString(), + uploadedBy: 'PaidIndodeDemoBookingsSeeder', + notes: 'Seeded secured gate pass for import/export Djibouti port entry testing.', + }, + }, + gatepassGrantedAt: securedAt, + performedBy: 'PaidIndodeDemoBookingsSeeder', + notes: 'Seeded SECURED gate pass for US12 warehouse workflow testing.', + }), + ); + } + } + + private async ensureImportWarehouseInventory( + manager: EntityManager, + bookings: Map, + ): Promise { + const warehouse = await manager.getRepository(Warehouse).findOne({ where: { code: 'INDODE_OPEN' } }); + if (!warehouse) { + this.logger.warn('INDODE_OPEN warehouse missing; skipping US12 import warehouse inventory seed'); + return; + } + + for (const demo of TRAIN_DEMO_BOOKINGS.filter((booking) => booking.tradeDirection === 'IMPORT')) { + const booking = bookings.get(demo.reference); + if (!booking) continue; + + const yard = await this.findWarehouseYard(manager, warehouse.id, demo.freightType); + if (!yard) { + this.logger.warn(`No warehouse yard found for ${warehouse.code}; skipping ${demo.reference}`); + continue; + } + const zone = await manager.getRepository(WarehouseZone).findOne({ where: { yardId: yard.id } }); + if (!zone) { + this.logger.warn(`No warehouse zone found for ${yard.code}; skipping ${demo.reference}`); + continue; + } + + const arrivedAt = this.addHours(new Date(), -Number(demo.trainNumber.includes('LM') ? 7 : 13)); + const grnNumber = `GRN-IMP-${demo.reference.replace(/[^A-Z0-9]/g, '')}`; + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: warehouse.id, + yardId: yard.id, + zoneId: zone.id, + bookingId: booking.id, + quantity: demo.freightType === 'CONTAINER' ? 1 : 1, + weight: demo.weightTons, + volume: null, + grnNumber, + status: 'UNLOADED', + inspectionStatus: null, + arrivedAt, + unloadedAt: arrivedAt, + notes: [ + `GRN Number: ${grnNumber}`, + 'Direction: IMPORT', + `Train: ${demo.trainNumber}`, + `Seeded For: ${demo.withLastMile ? 'Import with last mile' : 'Import terminal pickup / no last mile'}`, + 'Seeded by PaidIndodeDemoBookingsSeeder for Receive at Warehouse testing.', + ].join('\n'), + }), + ); + + await manager.getRepository(WarehouseActivityLog).save( + manager.getRepository(WarehouseActivityLog).create({ + inventoryId: saved.id, + warehouseId: warehouse.id, + activityType: 'INVENTORY_UNLOADED', + description: `Seeded import train arrival ${demo.trainNumber} into warehouse queue`, + performedBy: 'PaidIndodeDemoBookingsSeeder', + }), + ); + } + } + + private async findWarehouseYard( + manager: EntityManager, + warehouseId: string, + freightType: string, + ): Promise { + const preferredType = freightType === 'CONTAINER' ? 'CONTAINER_YARD' : 'BULK_YARD'; + return ( + (await manager.getRepository(WarehouseYard).findOne({ + where: { warehouseId, type: preferredType as any }, + })) ?? + (await manager.getRepository(WarehouseYard).findOne({ + where: { warehouseId }, + })) + ); + } + + private async deleteBookingTrainChildren(manager: EntityManager, bookingIds: string[]): Promise { + const allocationRepo = manager.getRepository(WagonBookingAllocation); + const allocations = await allocationRepo.find({ + where: { bookingId: In(bookingIds) }, + select: { id: true }, + }); + const allocationIds = allocations.map((allocation) => allocation.id); + if (allocationIds.length) { + await manager.getRepository(WagonAllocationContainerItem).delete({ + wagonBookingAllocationId: In(allocationIds), + }); + } + await allocationRepo.delete({ bookingId: In(bookingIds) }); + await manager.getRepository(TrainScheduleBooking).delete({ bookingId: In(bookingIds) }); + } + + private async ensureLocomotive( + manager: EntityManager, + currentYardId: string, + ): Promise { + const repo = manager.getRepository(Locomotive); + const existing = await repo.findOne({ where: { code: 'US12-DEMO-LOCO' } }); + if (existing) { + await repo.update(existing.id, { currentYardId, status: 'AVAILABLE' }); + return { ...existing, currentYardId, status: 'AVAILABLE' }; + } + + return repo.save( + repo.create({ + code: 'US12-DEMO-LOCO', + name: 'US12 Demo Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId, + }), + ); + } + + private async ensureTrainSet( + manager: EntityManager, + trainNumber: string, + locomotiveId: string, + ): Promise { + const schedule = await manager.getRepository(TrainSchedule).findOne({ + where: { trainNumber }, + }); + if (schedule) { + const existing = await manager.getRepository(TrainSet).findOneByOrFail({ + id: schedule.trainSetId, + }); + await manager.getRepository(TrainSet).update(existing.id, { + locomotiveId, + status: 'COMPLETED', + }); + return { ...existing, locomotiveId, status: 'COMPLETED' }; + } + + return manager.getRepository(TrainSet).save( + manager.getRepository(TrainSet).create({ + locomotiveId, + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'COMPLETED', + }), + ); + } + + private async ensureTrainSchedule( + manager: EntityManager, + input: { + trainNumber: string; + trainSetId: string; + originStationId: string; + destinationStationId: string; + scheduledDepartureDate: Date; + scheduledArrivalDate: Date; + actualDepartureAt: Date; + actualArrivalAt: Date; + direction: 'IMPORT' | 'EXPORT'; + }, + ): Promise { + const repo = manager.getRepository(TrainSchedule); + const existing = await repo.findOne({ where: { trainNumber: input.trainNumber } }); + const nextSchedule = repo.create({ + ...(existing ? { id: existing.id } : {}), + trainSetId: input.trainSetId, + originStationId: input.originStationId, + destinationStationId: input.destinationStationId, + scheduledDepartureDate: input.scheduledDepartureDate, + scheduledArrivalDate: input.scheduledArrivalDate, + actualDepartureAt: input.actualDepartureAt, + actualArrivalAt: input.actualArrivalAt, + status: TrainScheduleStatus.Arrived, + trainNumber: input.trainNumber, + direction: input.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }); + return repo.save(nextSchedule); + } + + private async ensureWagon( + manager: EntityManager, + input: { + wagonNumber: string; + wagonTypeId: string; + yardId: string; + trainScheduleId: string; + trainSetWagonId: string | null; + tareWeight: number; + capacityTons: number; + }, + ): Promise { + const repo = manager.getRepository(Wagon); + const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); + return repo.save( + repo.create({ + ...(existing ? { id: existing.id } : {}), + wagonNumber: input.wagonNumber, + wagonTypeId: input.wagonTypeId, + currentYardId: input.yardId, + currentTrainScheduleId: input.trainScheduleId, + trainSetWagonId: input.trainSetWagonId, + tareWeight: input.tareWeight, + maxPayloadWeight: input.capacityTons, + status: WagonStatus.Assigned, + notes: 'US12 paid Indode demo seed wagon', + }), + ); + } + + private async ensureFirstMileVehicle(manager: EntityManager): Promise { + const driverRepo = manager.getRepository(Driver); + const vehicleRepo = manager.getRepository(Vehicle); + const licenseNumber = 'US12-FM-LIC-001'; + const plateNumber = 'ET-FM-1201'; + + await driverRepo.upsert( + { + licenseNumber, + firstName: 'Tesfaye', + lastName: 'Firstmile', + email: 'tesfaye.firstmile@edr.local', + phoneNumber: '251911120120', + licenseExpiryDate: this.addHours(new Date(), 24 * 365), + status: DriverStatus.ACTIVE, + vehicleTypesAuthorized: [VehicleType.TRUCK, VehicleType.FLATBED], + notes: 'Seeded first-mile driver for US12 receive-to-warehouse testing', + }, + { conflictPaths: { licenseNumber: true } }, + ); + const driver = await driverRepo.findOneByOrFail({ licenseNumber }); + + await vehicleRepo.upsert( + { + plateNumber, + registrationNumber: 'US12-FM-REG-001', + vehicleType: VehicleType.TRUCK, + manufacturer: 'Sinotruk', + model: 'HOWO Container Carrier', + year: 2024, + fuelType: FuelType.DIESEL, + capacity: 40, + status: VehicleStatus.ACTIVE, + assignedDriverId: driver.id, + assignedDriverName: `${driver.firstName} ${driver.lastName}`, + description: 'Seeded first-mile truck for US12 receive-to-warehouse testing', + estimatedDistanceKm: 35, + actualDistanceKm: 34.6, + }, + { conflictPaths: { plateNumber: true } }, + ); + const vehicle = await vehicleRepo.findOneByOrFail({ plateNumber }); + await manager.query( + `UPDATE freight.vehicles + SET trailer_plate_no = $2, + assigned_driver_id = $3, + assigned_driver_name = $4, + updated_at = NOW() + WHERE id = $1`, + [vehicle.id, 'ET-TRL-1201', driver.id, `${driver.firstName} ${driver.lastName}`], + ); + return vehicleRepo.findOneByOrFail({ plateNumber }); + } + + private containerNumber(reference: string): string { + const suffix = reference.replace(/[^A-Z0-9]/g, '').slice(-7); + return `US12${suffix}`; + } + + private addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); + } +} 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 efdca3ae4..1d192e2b2 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -72,6 +72,13 @@ function FeeCard({ fee }: { fee: FeePreview }) { + {(fee.tiers ?? []).map((tier) => ( + + ))} )} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 924d4456e..6e5245f87 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -142,6 +142,7 @@ interface TruckEntranceFormState { packagingType: string; unitCount: number | ''; grossWeightKg: number | ''; + weighingRequired: boolean | null; netWeightKg: number | ''; volumeDimensions: string; conditionAtReceipt: string; @@ -163,11 +164,17 @@ interface LockedTruckEntranceFields { tin?: boolean; edrDigitalBookingId?: boolean; customerPhone?: boolean; + truckPlateNumber?: boolean; + trailerPlateNumber?: boolean; assignedEquipmentNumber?: boolean; itemDescription?: boolean; packagingType?: boolean; unitCount?: boolean; grossWeightKg?: boolean; + driverName?: boolean; + driverPhone?: boolean; + driverLicenseNumber?: boolean; + truckType?: boolean; } type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED'; @@ -190,6 +197,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ packagingType: '', unitCount: '', grossWeightKg: '', + weighingRequired: null, netWeightKg: '', volumeDimensions: '', conditionAtReceipt: '', @@ -206,13 +214,24 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ }); const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ + ownerName: form.ownerName.trim() || undefined, + consigneeDetails: form.consigneeDetails.trim() || undefined, + edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined, + tin: form.tin.trim() || undefined, + customerPhone: form.customerPhone.trim() || undefined, truckPlateNumber: form.truckPlateNumber.trim(), trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, + assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, incoterms: form.incoterms.trim() || undefined, hsCodes: form.hsCodes.trim() || undefined, itemCode: form.itemCode.trim() || undefined, + itemDescription: form.itemDescription.trim() || undefined, + packagingType: form.packagingType.trim() || undefined, + unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), + weighingRequired: form.weighingRequired ?? undefined, + grossWeightKg: form.weighingRequired && form.grossWeightKg !== '' ? Number(form.grossWeightKg) : undefined, netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg), volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, @@ -222,8 +241,11 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl driverPhone: form.driverPhone.trim(), driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, truckType: form.truckType.trim() || undefined, - entranceTareWeightKg: Number(form.entranceTareWeightKg), - exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg), + entranceTareWeightKg: + form.entranceTareWeightKg === '' + ? undefined + : Number(form.entranceTareWeightKg), + exitTareWeightKg: form.weighingRequired && form.exitTareWeightKg !== '' ? Number(form.exitTareWeightKg) : undefined, driverSignatoryName: form.driverSignatoryName.trim() || undefined, warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); @@ -242,22 +264,31 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin)); const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone)); const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer)); - const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber)); + const assignedEquipmentNumber = commonNonEmptyValue( + bookings.map((booking) => booking.customerTruckContainerNumber || booking.containerNumber), + ); const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo)); const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType)); + const truckPlateNumber = commonNonEmptyValue( + bookings.map((booking) => booking.firstMileTruckPlateNumber || booking.customerTruckPlateNumber), + ); + const trailerPlateNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileTrailerPlateNumber)); + const driverName = commonNonEmptyValue( + bookings.map((booking) => booking.firstMileDriverName || booking.customerTruckDriverName), + ); + const driverPhone = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverPhone)); + const driverLicenseNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverLicenseNumber)); + const truckType = commonNonEmptyValue( + bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType), + ); const edrDigitalBookingId = bookings.length === 1 ? bookings[0]?.reference ?? bookings[0]?.id ?? '' : commonNonEmptyValue(bookings.map((booking) => booking.reference)); - const firstMileBooking = bookings.length === 1 ? bookings[0] : null; const unitCount = bookings.length === 1 && bookings[0]?.containerQuantity != null ? Number(bookings[0].containerQuantity) : ''; - const grossWeightKg = - bookings.length === 1 && bookings[0]?.weight != null - ? Number(bookings[0].weight) - : ''; const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))]; const packagingFreightType = freightTypes.length === 1 && freightTypes[0] === 'CONTAINER' @@ -278,13 +309,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { itemDescription, packagingType, unitCount, - grossWeightKg, - truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '', - trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '', - driverName: firstMileBooking?.firstMileDriverName ?? '', - driverPhone: firstMileBooking?.firstMileDriverPhone ?? '', - driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '', - truckType: firstMileBooking?.firstMileTruckType ?? '', + grossWeightKg: '', + truckPlateNumber, + trailerPlateNumber, + driverName, + driverPhone, + driverLicenseNumber, + truckType, }, lockedFields: { ownerName: Boolean(ownerName), @@ -296,7 +327,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { itemDescription: Boolean(itemDescription), packagingType: Boolean(packagingType), unitCount: unitCount !== '', - grossWeightKg: grossWeightKg !== '', + grossWeightKg: false, + truckPlateNumber: Boolean(truckPlateNumber), + trailerPlateNumber: Boolean(trailerPlateNumber), + driverName: Boolean(driverName), + driverPhone: Boolean(driverPhone), + driverLicenseNumber: Boolean(driverLicenseNumber), + truckType: Boolean(truckType), }, packagingFreightType, }; @@ -338,11 +375,13 @@ function TruckEntranceFields({ onChange, lockedFields, packagingFreightType = 'MIXED', + allowTruckWeighing = true, }: { value: TruckEntranceFormState; onChange: (next: TruckEntranceFormState) => void; lockedFields?: LockedTruckEntranceFields; packagingFreightType?: PackagingFreightType; + allowTruckWeighing?: boolean; }) { const packagingOptions = packagingOptionsFor(packagingFreightType); const quantityLabel = @@ -396,11 +435,13 @@ function TruckEntranceFields({ label="Truck plate number" required value={value.truckPlateNumber} + readOnly={lockedFields?.truckPlateNumber} onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })} /> onChange({ ...value, trailerPlateNumber: e.currentTarget.value })} /> @@ -422,12 +463,14 @@ function TruckEntranceFields({ label="Driver name" required value={value.driverName} + readOnly={lockedFields?.driverName} onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })} /> onChange({ ...value, driverPhone: e.currentTarget.value })} /> @@ -435,29 +478,61 @@ function TruckEntranceFields({ onChange({ ...value, driverLicenseNumber: e.currentTarget.value })} /> onChange({ ...value, truckType: e.currentTarget.value })} /> - - onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })} - /> - onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })} - /> - + {allowTruckWeighing ? ( + <> +