diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index b24cf6c83..a0a119861 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -23,6 +23,7 @@ "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", + "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f40e4f40f..a81a539ba 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'; @@ -160,6 +161,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidIndodeDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -178,6 +180,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, @@ -199,6 +202,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/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index c27d381e2..47d8f5b3c 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -116,6 +116,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => { freightMigrationsGlob, ], migrationsRun: true, + migrationsTransactionMode: "each", // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, logging: 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..4c2fb5d95 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 contype = 'p' + 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/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts index dd246cb7d..75082c5c4 100644 --- a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -19,6 +19,31 @@ export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterf name = 'CentralizeWarehouseInvoices1829000000000'; public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'booking_id' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'amount' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL; + END IF; + END $$; + `); + // 1. Invoice headers. Keep the same id so items still link, and so any // external reference to the invoice id stays valid. await queryRunner.query(` 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 278d4cca9..958279002 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -867,6 +867,11 @@ export class BillingService { ); } + 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: invoice.sourceId, source: invoice.source, 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 8c693af17..688c6ee18 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -58,10 +58,11 @@ import { RequestOperationDto, OperationReviewDto, StaffRejectDto, -} from "./dto/request-changes.dto"; -import { ContractViewDto } from "./dto/contract-view.dto"; -import { SignContractDto } from "./dto/sign-contract.dto"; -import { UpdateBookingDto } from "./dto/update-booking.dto"; +} 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 { type AuthUserPayload, resolveAuthUserId, @@ -275,7 +276,40 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(":id/tracking") + @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", description: 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 2d3901414..80165cb7e 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 { @@ -92,8 +94,62 @@ 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); + const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); + const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/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, @@ -131,6 +187,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 3ae0fb64f..910d3f103 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/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 732b1a618..df439f28f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -131,6 +131,12 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { + const [existing] = await this.lastMileRepository.findAll({ + where: { bookingId: dto.bookingId }, + take: 1, + }); + if (existing) return existing; + return this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', 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 8d4021389..e87dbdd88 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/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index c1faeed22..5fcf59dd5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -35,6 +35,7 @@ export interface ImportTrainItemRow { wagonNumber: string | null; sequenceNo: number | null; allocatedWeightTons: number | null; + freightType: string | null; containerNumber: string | null; cargoType: string | null; weight: number | null; @@ -274,6 +275,7 @@ export class SchedulingReadFacade { w.wagon_number AS "wagonNumber", tsw.sequence_no AS "sequenceNo", wba.allocated_weight_tons AS "allocatedWeightTons", + b.freight_type AS "freightType", (SELECT c.container_number FROM freight.containers c WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", 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-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 1de6daf81..ff25258d3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -84,9 +84,11 @@ export class WarehouseInspectionService { `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", b.trade_direction AS "tradeDirection", - b.last_mile_delivery_address AS "lastMileDeliveryAddress" + b.last_mile_delivery_address AS "lastMileDeliveryAddress", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [inventoryId], @@ -98,7 +100,10 @@ export class WarehouseInspectionService { readyForPickupAt: new Date(), }); - if (row.bookingReference && row.lastMileDeliveryAddress) { + const hasLastMile = + Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile); + + if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6b2bd8c28..6b30dad1e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -139,8 +139,18 @@ export class WarehouseInventoryController { @Post('import/auto-unload-arrived-bookings') @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) - autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) { - return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy); + autoUnloadArrivedBookings(@Body() dto: { + scheduleId: string; + warehouseId?: string; + performedBy?: string; + assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[]; + }) { + return this.inventoryService.autoUnloadArrivedBookings( + dto.scheduleId, + dto.performedBy, + dto.warehouseId, + dto.assignments, + ); } @Get('import/unloaded-queue') 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..11f34c892 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 @@ -180,6 +180,10 @@ interface LocationRef { zoneId: string; } +interface BookingUnloadLocation extends LocationRef { + bookingId: string; +} + interface LocationNode { capacityWeight?: number | null; capacityContainers?: number | null; @@ -221,6 +225,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 +311,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; @@ -504,8 +518,9 @@ export class WarehouseInventoryService { })); } - /** First warehouse that has at least one yard + zone (fallback location for auto-unload). */ - private async pickDefaultLocation(): Promise { + /** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */ + private async pickDefaultLocation(warehouseId?: string): Promise { + const params = warehouseId ? [warehouseId] : []; const [row]: DefaultLocation[] = await this.dataSource.query( `SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId", yard.id AS "yardId", zone.id AS "zoneId" @@ -513,8 +528,10 @@ export class WarehouseInventoryService { JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL WHERE wh.deleted_at IS NULL + ${warehouseId ? 'AND wh.id = $1' : ''} ORDER BY wh.created_at ASC - LIMIT 1`, + LIMIT 1`, + params, ); return row ?? null; } @@ -592,6 +609,7 @@ export class WarehouseInventoryService { dto.warehouseId && dto.yardId && dto.zoneId ? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null } : null; + if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId); if (!location) location = await this.pickDefaultLocation(); if (!location) { throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading'); @@ -704,7 +722,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 +819,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 @@ -1041,9 +1069,16 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", - CASE WHEN b.last_mile_delivery_address IS NOT NULL + CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false) THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", - (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) 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", @@ -1056,6 +1091,7 @@ export class WarehouseInventoryService { LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -1157,6 +1193,8 @@ export class WarehouseInventoryService { async autoUnloadArrivedBookings( scheduleId: string, performedBy?: string, + warehouseId?: string, + assignments: BookingUnloadLocation[] = [], ): Promise { const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; @@ -1203,7 +1241,21 @@ export class WarehouseInventoryService { [scheduleId], ); - const fallback = await this.pickDefaultLocation(); + const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null; + if (warehouseId && !requestedLocation) { + throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading'); + } + const fallback = requestedLocation ?? (await this.pickDefaultLocation()); + const assignmentByBooking = new Map( + assignments.map((assignment) => [ + assignment.bookingId, + { + warehouseId: assignment.warehouseId, + yardId: assignment.yardId, + zoneId: assignment.zoneId, + } satisfies LocationRef, + ]), + ); const now = new Date(); for (const booking of bookings) { @@ -1223,6 +1275,8 @@ export class WarehouseInventoryService { try { const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0]; + const assignedLocation = assignmentByBooking.get(booking.id) ?? null; + const unloadLocation = assignedLocation ?? requestedLocation; // Already unloaded or further along — leave it (do not regress the lifecycle). if (existing && existing.status !== 'RECEIVED') { @@ -1232,6 +1286,13 @@ export class WarehouseInventoryService { if (existing) { await this.inventoryRepository.update(existing.id, { + ...(unloadLocation + ? { + warehouseId: unloadLocation.warehouseId, + yardId: unloadLocation.yardId, + zoneId: unloadLocation.zoneId, + } + : {}), status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, @@ -1239,7 +1300,7 @@ export class WarehouseInventoryService { await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', inventoryId: existing.id, - warehouseId: existing.warehouseId, + warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId, description: 'Unloaded from arrived import train', performedBy, }); @@ -1254,7 +1315,7 @@ export class WarehouseInventoryService { tradeDirection: booking.tradeDirection, cargoTypeCode: booking.cargoTypeCode, }); - const location = allocated ?? fallback; + const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback; if (!location) { fail('No warehouse/yard/zone configured'); continue; @@ -1336,6 +1397,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; @@ -1631,13 +1702,17 @@ export class WarehouseInventoryService { if (!bookingId) return; const [booking] = await this.dataSource.query( `SELECT reference, - last_mile_delivery_address AS "lastMileDeliveryAddress" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL + last_mile_delivery_address AS "lastMileDeliveryAddress", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - if (!booking?.reference || !booking.lastMileDeliveryAddress) return; + const hasLastMile = + Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile); + if (!booking?.reference || !hasLastMile) return; await this.lastMileService.acceptBooking(booking.reference); } @@ -1955,11 +2030,19 @@ 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; - const reference = dto.reference?.trim() || (await this.generateReleaseReference(item)); - const exitInspectionNote = this.buildExitInspectionNote(dto); + const reference = isTruckLeaving + ? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)) + : dto.reference?.trim() || (await this.generateReleaseReference(item)); + const exitInspectionDto = isTruckLeaving + ? this.preserveTruckArrivalForExit(dto, item.notes) + : dto; + const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { @@ -1967,6 +2050,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 +2128,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 +2275,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 +2301,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 +2408,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 +3410,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 +3438,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 +3458,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, }; } @@ -3543,6 +3667,24 @@ export class WarehouseInventoryService { return rows.filter(Boolean).join('\n'); } + private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto { + const inspection = this.extractExitInspectionNote(notes); + if (!inspection) return dto; + + return { + ...dto, + truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, + trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber, + driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName, + driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense, + driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone, + truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType, + containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber, + gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime, + tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight, + }; + } + private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null { const trimmed = notes?.trim(); if (!exitInspectionNote) return trimmed || null; @@ -3564,6 +3706,18 @@ export class WarehouseInventoryService { return notes.slice(index + marker.length).trim() || null; } + private extractExitInspectionLine(note: string | null | undefined, label: string): string | null { + const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im')); + return match?.[1]?.trim() || null; + } + + private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined { + const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, ''); + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + private extractReceiveSummary(notes?: string | null): string | null { if (!notes?.trim()) return null; const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes; @@ -3622,6 +3776,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/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts new file mode 100644 index 000000000..a57ce84c7 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -0,0 +1,627 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { WagonStatus } from '@edr/types'; +import { In } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { AppDataSource } from '../data-source'; +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CompanyProfile, ProfileStatus, ProfileType } from '../modules/companies/entities/company-profile.entity'; +import { Company, CompanyKind, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { Container } from '../modules/container-management/entities/container.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.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 { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.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 { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.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 { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +type Direction = 'IMPORT' | 'EXPORT'; +type TrainStatus = 'SCHEDULED' | 'ARRIVED'; + +interface ScenarioTrain { + trainNumber: string; + direction: Direction; + status: TrainStatus; + departureOffsetHours: number; + arrivalOffsetHours: number; + bookings: Array<{ + reference: string; + mileVariant: 'FIRST_MILE' | 'LAST_MILE' | 'TERMINAL'; + containerNumber: string; + weightTons: number; + }>; +} + +const SCENARIOS: ScenarioTrain[] = [ + { + trainNumber: 'GP-IMP-ARR-01', + direction: 'IMPORT', + status: 'ARRIVED', + departureOffsetHours: -18, + arrivalOffsetHours: -6, + bookings: [ + { reference: 'GP-IMP-ARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000001', weightTons: 22 }, + { reference: 'GP-IMP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000002', weightTons: 24 }, + ], + }, + { + trainNumber: 'GP-IMP-NARR-01', + direction: 'IMPORT', + status: 'SCHEDULED', + departureOffsetHours: 6, + arrivalOffsetHours: 18, + bookings: [ + { reference: 'GP-IMP-NARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000003', weightTons: 21 }, + { reference: 'GP-IMP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000004', weightTons: 23 }, + ], + }, + { + trainNumber: 'GP-EXP-ARR-01', + direction: 'EXPORT', + status: 'ARRIVED', + departureOffsetHours: -16, + arrivalOffsetHours: -4, + bookings: [ + { reference: 'GP-EXP-ARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000001', weightTons: 20 }, + { reference: 'GP-EXP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000002', weightTons: 22 }, + ], + }, + { + trainNumber: 'GP-EXP-NARR-01', + direction: 'EXPORT', + status: 'SCHEDULED', + departureOffsetHours: 8, + arrivalOffsetHours: 20, + bookings: [ + { reference: 'GP-EXP-NARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000003', weightTons: 19 }, + { reference: 'GP-EXP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000004', weightTons: 21 }, + ], + }, +]; + +const addHours = (date: Date, hours: number): Date => new Date(date.getTime() + hours * 60 * 60 * 1000); + +async function main() { + const dataSource = await AppDataSource.initialize(); + + try { + const seeded = await dataSource.transaction(async (manager) => { + if (await isAlreadySeeded(manager)) { + return null; + } + const refs = await ensureReferences(manager); + const now = new Date(); + const result: Array<{ trainNumber: string; bookings: string[] }> = []; + + for (const scenario of SCENARIOS) { + const schedule = await seedScenarioTrain(manager, scenario, refs, now); + result.push({ + trainNumber: schedule.trainNumber ?? scenario.trainNumber, + bookings: scenario.bookings.map((booking) => booking.reference), + }); + } + + return result; + }); + + console.log('Gate-pass train scenario seed complete.'); + if (seeded) { + for (const row of seeded) { + console.log(`${row.trainNumber}: ${row.bookings.join(', ')}`); + } + } else { + console.log('Gate-pass train scenarios already seeded; nothing changed.'); + } + } finally { + await dataSource.destroy(); + } +} + +async function isAlreadySeeded(manager: any): Promise { + const scheduleRepo = manager.getRepository(TrainSchedule); + const bookingRepo = manager.getRepository(Booking); + const trainNumbers = SCENARIOS.map((scenario) => scenario.trainNumber); + const bookingRefs = SCENARIOS.flatMap((scenario) => scenario.bookings.map((booking) => booking.reference)); + + const [scheduleCount, bookingCount] = await Promise.all([ + scheduleRepo.count({ where: { trainNumber: In(trainNumbers) } }), + bookingRepo.count({ where: { reference: In(bookingRefs) } }), + ]); + + return scheduleCount === trainNumbers.length && bookingCount === bookingRefs.length; +} + +async function ensureReferences(manager: any) { + const yardRepo = manager.getRepository(Yard); + const serviceTypeRepo = manager.getRepository(ServiceType); + const containerTypeRepo = manager.getRepository(ContainerType); + const wagonTypeRepo = manager.getRepository(WagonType); + const companyRepo = manager.getRepository(Company); + const profileRepo = manager.getRepository(CompanyProfile); + const warehouseRepo = manager.getRepository(Warehouse); + const warehouseYardRepo = manager.getRepository(WarehouseYard); + const warehouseZoneRepo = manager.getRepository(WarehouseZone); + + const djiboutiYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'NAGAD', + label: 'Nagad Port', + country: 'Djibouti', + isActive: true, + displayOrder: 90, + }), + )); + + const ethiopiaYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'INDODE', + label: 'Indode Dry Port', + country: 'Ethiopia', + isActive: true, + displayOrder: 91, + }), + )); + + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.save( + serviceTypeRepo.create({ + code: 'RAIL_CONTAINER', + serviceName: 'Rail Container Service', + description: 'Rail container service for gate-pass scenario seed', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }), + )); + + const containerType = + (await containerTypeRepo.findOne({ where: { code: '40FT' } })) ?? + (await containerTypeRepo.findOne({ where: { isActive: true } })) ?? + (await containerTypeRepo.save( + containerTypeRepo.create({ + code: '40FT', + label: '40FT', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }), + )); + + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'GP-FLAT' } })) ?? + (await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ?? + (await wagonTypeRepo.findOne({ where: { isActive: true } })) ?? + (await wagonTypeRepo.save( + wagonTypeRepo.create({ + code: 'GP-FLAT', + name: 'Gate Pass Demo Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }), + )); + + const company = + (await companyRepo.findOne({ where: { tin: 'GTPASS001' } })) ?? + (await companyRepo.save( + companyRepo.create({ + name: 'Gate Pass Scenario Customer', + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: 'GTPASS001', + vatNumber: 'GTPASS001', + fanNumber: 'GTPASS0000001', + country: 'Ethiopia', + address: 'Indode Dry Port', + phone: '251900000555', + email: 'gate-pass-scenarios@edr.local', + contactPersonName: 'Gate Pass Tester', + contactPersonPhone: '251900000555', + }), + )); + + const importerProfile = await ensureProfile(profileRepo, company.id, ProfileType.importer, 'GP-IMP'); + const exporterProfile = await ensureProfile(profileRepo, company.id, ProfileType.exporter, 'GP-EXP'); + + const warehouse = + (await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } })) ?? + (await warehouseRepo.findOne({ where: {} })); + if (!warehouse) { + throw new Error('No warehouse found. Run the Indode/warehouse seed before gate-pass scenarios.'); + } + const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }); + if (!warehouseYard) { + throw new Error(`No warehouse yard found for ${warehouse.code ?? warehouse.id}.`); + } + const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }); + if (!warehouseZone) { + throw new Error(`No warehouse zone found for yard ${warehouseYard.id}.`); + } + + return { + djiboutiYard, + ethiopiaYard, + serviceType, + containerType, + wagonType, + company, + importerProfile, + exporterProfile, + warehouse, + warehouseYard, + warehouseZone, + }; +} + +async function ensureProfile(repo: any, companyId: string, type: ProfileType, reference: string): Promise { + const existing = await repo.findOne({ where: { companyId, type } }); + if (existing) return existing; + return repo.save( + repo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + businessLicense: `${reference}-LICENSE`, + }), + ); +} + +async function seedScenarioTrain(manager: any, scenario: ScenarioTrain, refs: Awaited>, now: Date) { + const locomotiveRepo = manager.getRepository(Locomotive); + const trainSetRepo = manager.getRepository(TrainSet); + const scheduleRepo = manager.getRepository(TrainSchedule); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const wagonRepo = manager.getRepository(Wagon); + + const departure = addHours(now, scenario.departureOffsetHours); + const arrival = addHours(now, scenario.arrivalOffsetHours); + const isArrived = scenario.status === 'ARRIVED'; + const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + const totalWeightTons = scenario.bookings.reduce((sum, booking) => sum + booking.weightTons, 0); + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'GP-DEMO-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'GP-DEMO-LOCO', + name: 'Gate Pass Scenario Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId: originYard.id, + }), + )); + + let schedule = await scheduleRepo.findOne({ where: { trainNumber: scenario.trainNumber } }); + let trainSet: TrainSet | null = schedule?.trainSetId + ? await trainSetRepo.findOne({ where: { id: schedule.trainSetId } }) + : null; + + if (!trainSet) { + trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters: scenario.bookings.length * 14, + wagonCount: scenario.bookings.length, + status: isArrived ? 'COMPLETED' : 'ASSIGNED', + }), + ); + } else { + await trainSetRepo.update(trainSet.id, { + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters: scenario.bookings.length * 14, + wagonCount: scenario.bookings.length, + status: isArrived ? 'COMPLETED' : 'ASSIGNED', + }); + } + if (!trainSet) { + throw new Error(`Could not create train set for ${scenario.trainNumber}`); + } + const trainSetId = trainSet.id; + + if (!schedule) { + schedule = scheduleRepo.create({ trainNumber: scenario.trainNumber }); + } + Object.assign(schedule, { + trainSetId, + originStationId: originYard.id, + destinationStationId: destinationYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: isArrived ? departure : null, + actualArrivalAt: isArrived ? arrival : null, + status: scenario.status, + direction: scenario.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }); + schedule = await scheduleRepo.save(schedule); + + for (const [index, bookingSpec] of scenario.bookings.entries()) { + const sequenceNo = index + 1; + const wagon = await ensureWagon(manager, scenario, sequenceNo, refs.wagonType.id, originYard.id, schedule.id); + const trainSetWagon = await ensureTrainSetWagon( + trainSetWagonRepo, + trainSetId, + refs.wagonType.id, + wagon.id, + sequenceNo, + bookingSpec.weightTons, + isArrived, + ); + await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id }); + + const booking = await ensureBooking(manager, scenario, bookingSpec, refs, departure, now, schedule.id); + const bookingContainer = await ensureBookingContainer(manager, booking.id, refs.containerType.id, bookingSpec); + const allocation = await ensureAllocation(manager, trainSetWagon.id, booking.id, bookingSpec.weightTons, isArrived, now); + const container = await ensureContainer(manager, booking.id, bookingContainer.id, allocation.id, wagon.id, sequenceNo, bookingSpec, refs.containerType.id, isArrived); + await ensureContainerItem(manager, allocation.id, bookingContainer.id, container.id, refs.containerType.id, sequenceNo, bookingSpec); + await ensureScheduleBooking(manager, schedule.id, booking.id); + if (scenario.direction === 'EXPORT') { + await ensureExportInventory(manager, refs, booking.id, container.id, bookingSpec.weightTons, isArrived, now); + } + } + + if (scenario.direction === 'IMPORT') { + await ensureImportOperation(manager, schedule.id, scenario, departure, isArrived); + } + + return schedule; +} + +async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: number, wagonTypeId: string, yardId: string, scheduleId: string): Promise { + const repo = manager.getRepository(Wagon); + const wagonNumber = `${scenario.trainNumber}-W${String(sequenceNo).padStart(2, '0')}`; + const existing = await repo.findOne({ where: { wagonNumber } }); + const values = { + wagonNumber, + wagonTypeId, + trainId: null, + sequenceNumber: sequenceNo, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Assigned, + currentYardId: yardId, + currentTrainScheduleId: scheduleId, + notes: 'Gate-pass scenario seed wagon', + }; + return repo.save(repo.create({ ...(existing ?? {}), ...values })); +} + +async function ensureTrainSetWagon(repo: any, trainSetId: string, wagonTypeId: string, wagonId: string, sequenceNo: number, weightTons: number, isArrived: boolean): Promise { + const existing = await repo.findOne({ where: { trainSetId, sequenceNo } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + trainSetId, + wagonTypeId, + physicalWagonId: wagonId, + sequenceNo, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: weightTons, + status: isArrived ? 'DEPARTED' : 'LOADED', + }), + ); +} + +async function ensureBooking(manager: any, scenario: ScenarioTrain, bookingSpec: ScenarioTrain['bookings'][number], refs: Awaited>, departure: Date, now: Date, scheduleId: string): Promise { + const repo = manager.getRepository(Booking); + const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + const existing = await repo.findOne({ where: { reference: bookingSpec.reference } }); + const profile = scenario.direction === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; + const hasFirstMile = bookingSpec.mileVariant === 'FIRST_MILE'; + const hasLastMile = bookingSpec.mileVariant === 'LAST_MILE'; + + return repo.save( + repo.create({ + ...(existing ?? {}), + reference: bookingSpec.reference, + companyId: refs.company.id, + companyProfileId: profile.id, + originYardId: originYard.id, + destinationYardId: destinationYard.id, + serviceTypeId: refs.serviceType.id, + status: scenario.status === 'ARRIVED' ? 'IN_TRANSIT' : 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + estimatedShipmentDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: scenario.direction, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: `${scenario.direction} gate-pass scenario ${bookingSpec.mileVariant.toLowerCase().replace('_', ' ')}`, + cargoTotalWeightVgm: bookingSpec.weightTons * 1000, + firstMilePickupAddress: hasFirstMile ? 'Customer factory pickup - Addis Ababa' : null, + firstMilePickupLat: hasFirstMile ? 9.03 : null, + firstMilePickupLng: hasFirstMile ? 38.74 : null, + lastMileDeliveryAddress: hasLastMile ? 'Customer warehouse delivery - Addis Ababa' : null, + lastMileDeliveryLat: hasLastMile ? 8.98 : null, + lastMileDeliveryLng: hasLastMile ? 38.8 : null, + trainScheduleId: scheduleId, + schedulingStatus: scenario.status === 'ARRIVED' ? 'DISPATCHED' : 'SCHEDULED', + scheduledAt: now, + wagonsRequired: 1, + }), + ); +} + +async function ensureBookingContainer(manager: any, bookingId: string, containerTypeId: string, bookingSpec: ScenarioTrain['bookings'][number]): Promise { + const repo = manager.getRepository(BookingContainer); + const existing = await repo.findOne({ where: { bookingId } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + bookingId, + containerTypeId, + containerNumber: bookingSpec.containerNumber, + containerSize: '40', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + vgmPerUnitTons: bookingSpec.weightTons, + totalVgmTons: bookingSpec.weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }), + ); +} + +async function ensureAllocation(manager: any, trainSetWagonId: string, bookingId: string, weightTons: number, isArrived: boolean, now: Date): Promise { + const repo = manager.getRepository(WagonBookingAllocation); + const existing = await repo.findOne({ where: { trainSetWagonId, bookingId } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + trainSetWagonId, + bookingId, + allocatedWeightTons: weightTons, + loadType: 'CONTAINER', + status: isArrived ? 'DEPARTED' : 'LOADED', + confirmedAt: now, + }), + ); +} + +async function ensureContainer(manager: any, bookingId: string, bookingContainerId: string, allocationId: string, wagonId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number], containerTypeId: string, isArrived: boolean): Promise { + const repo = manager.getRepository(Container); + const existing = await repo.findOne({ where: { containerNumber: bookingSpec.containerNumber } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + containerNumber: bookingSpec.containerNumber, + containerTypeId, + wagonId, + position, + tareWeight: 3800, + maxGrossWeight: 30480, + sealNumber: `SEAL-${bookingSpec.containerNumber}`, + status: isArrived ? 'IN_TRANSIT' : 'LOADED', + bookingId, + wagonBookingAllocationId: allocationId, + bookingContainerId, + }), + ); +} + +async function ensureContainerItem(manager: any, allocationId: string, bookingContainerId: string, containerId: string, containerTypeId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number]): Promise { + const repo = manager.getRepository(WagonAllocationContainerItem); + await repo.delete({ wagonBookingAllocationId: allocationId }); + await repo.save( + repo.create({ + wagonBookingAllocationId: allocationId, + bookingContainerId, + containerId, + containerNumber: bookingSpec.containerNumber, + containerTypeId, + positionOnWagon: position, + sealNumber: `SEAL-${bookingSpec.containerNumber}`, + chassisNumber: `CHS-${bookingSpec.containerNumber}`, + grossWeightTons: bookingSpec.weightTons, + }), + ); +} + +async function ensureScheduleBooking(manager: any, scheduleId: string, bookingId: string): Promise { + const repo = manager.getRepository(TrainScheduleBooking); + const existing = await repo.findOne({ where: { bookingId } }); + await repo.save(repo.create({ ...(existing ?? {}), trainScheduleId: scheduleId, bookingId })); +} + +async function ensureExportInventory(manager: any, refs: Awaited>, bookingId: string, containerId: string, weightTons: number, isArrived: boolean, now: Date): Promise { + const repo = manager.getRepository(WarehouseInventory); + const existing = await repo.findOne({ where: { bookingId } }); + await repo.save( + repo.create({ + ...(existing ?? {}), + warehouseId: refs.warehouse.id, + yardId: refs.warehouseYard.id, + zoneId: refs.warehouseZone.id, + bookingId, + containerId, + quantity: 1, + weight: weightTons * 1000, + status: 'LOADED', + inspectionStatus: 'PASSED', + arrivedAt: addHours(now, -24), + inspectedAt: addHours(now, -22), + readyForLoadingAt: addHours(now, -20), + loadedAt: isArrived ? addHours(now, -16) : null, + notes: '[GP-SCENARIO] Export train gate-pass scenario inventory', + }), + ); +} + +async function ensureImportOperation(manager: any, scheduleId: string, scenario: ScenarioTrain, departure: Date, isArrived: boolean): Promise { + const repo = manager.getRepository(ImportDjiboutiOperation); + const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); + await repo.save( + repo.create({ + ...(existing ?? {}), + trainScheduleId: scheduleId, + documents: existing?.documents ?? {}, + gatepassGrantedAt: null, + readyForLoadingAt: null, + loadedOnTrainAt: null, + departedFromDjiboutiAt: isArrived ? departure : null, + loadListGeneratedAt: null, + performedBy: 'Gate Pass Scenario Seeder', + notes: `[GP-SCENARIO] ${scenario.trainNumber}; fill gate-pass dates during testing`, + }), + ); +} + +main().catch((error) => { + console.error('Gate-pass train scenario seed failed:', error); + process.exit(1); +}); 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-api/src/types/multer-globals.d.ts b/apps/edr-freight-api/src/types/multer-globals.d.ts new file mode 100644 index 000000000..0bc672a9e --- /dev/null +++ b/apps/edr-freight-api/src/types/multer-globals.d.ts @@ -0,0 +1 @@ +/// 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..32b0b5a5d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -41,7 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; -import { useInventoryInquiry } from '@/hooks/useWarehouses'; +import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses'; import { firstMileService } from '@/services/first-mile.service'; import { warehouseService } from '@/services/warehouse.service'; import type { @@ -54,7 +54,10 @@ import type { ReadyToLoadRow, ReceiveInventoryPayload, TruckEntrancePayload, + Warehouse, WarehouseInventoryItem, + WarehouseYard, + WarehouseZone, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { DeliverInventoryModal } from './DeliverInventoryModal'; @@ -70,12 +73,17 @@ import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } import { openPdfBlob } from './pdf'; import '@/components/overview/overview.css'; +type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string }; +type ImportUnloadAssignmentDraft = Partial>; + interface ReceiveInventoryModalProps { opened: boolean; onClose: () => void; /** When supplied the modal locks to a single booking (legacy single-receive). */ bookingId?: string; bookingLabel?: string; + mode?: 'single' | 'bulk'; + direction?: WarehouseFlowDirection; onReceived?: () => void; } @@ -142,6 +150,7 @@ interface TruckEntranceFormState { packagingType: string; unitCount: number | ''; grossWeightKg: number | ''; + weighingRequired: boolean | null; netWeightKg: number | ''; volumeDimensions: string; conditionAtReceipt: string; @@ -163,11 +172,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 +205,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ packagingType: '', unitCount: '', grossWeightKg: '', + weighingRequired: null, netWeightKg: '', volumeDimensions: '', conditionAtReceipt: '', @@ -206,13 +222,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 +249,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 +272,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 +317,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 +335,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 +383,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 +443,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 +471,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 +486,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 ? ( + <> + onAssignmentChange(it.bookingId, { warehouseId: value ?? undefined })} + searchable + disabled={!pending} + w={210} + /> + + + onAssignmentChange(it.bookingId, { ...draft, zoneId: value ?? undefined })} + searchable + disabled={!pending || !draft.yardId} + w={190} + /> + {it.inspectionStatus ?? 'Not inspected'} @@ -1691,7 +1921,8 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) { {it.pickupOption} - ))} + ); + })} ); @@ -1715,13 +1946,42 @@ function ImportArriveQueueTab({ const { data: trains = [], isLoading } = useQuery( api.warehouses.importArriveQueue.queryOptions({ enabled }), ); + const { data: warehouses = [], isLoading: warehousesLoading } = useQuery( + api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } }, enabled }), + ); + const { data: yards = [] } = useAllWarehouseYards(); + const { data: zones = [] } = useAllWarehouseZones(); const autoUnloadMutation = useMutation( api.warehouses.autoUnloadArrivedBookings.mutationOptions(), ); const [openId, setOpenId] = useState(null); const [busyId, setBusyId] = useState(null); + const [assignmentsBySchedule, setAssignmentsBySchedule] = useState< + Record> + >({}); + const [readyBySchedule, setReadyBySchedule] = useState>({}); const autoUnload = async (train: ImportTrain) => { + const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {}) + .filter((entry): entry is [string, Required] => + Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId), + ) + .map(([bookingId, draft]) => ({ + bookingId, + warehouseId: draft.warehouseId, + yardId: draft.yardId, + zoneId: draft.zoneId, + })); + + if (!readyBySchedule[train.scheduleId] || assignments.length === 0) { + toast({ + variant: 'destructive', + title: 'Assign locations', + description: 'Select warehouse, yard and zone for each pending booking before unloading.', + }); + return; + } + if (isFullyUnloaded(train)) { toast({ title: 'Already unloaded', @@ -1732,7 +1992,7 @@ function ImportArriveQueueTab({ setBusyId(train.scheduleId); try { - const r = await autoUnloadMutation.mutateAsync(train.scheduleId); + const r = await autoUnloadMutation.mutateAsync({ scheduleId: train.scheduleId, assignments }); const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0; const firstReason = r.results.find((item) => item.reason)?.reason; const extra = [ @@ -1831,7 +2091,7 @@ function ImportArriveQueueTab({ color={fullyUnloaded ? 'gray' : 'indigo'} leftSection={} loading={busyId === t.scheduleId} - disabled={fullyUnloaded || t.totalBookings === 0} + disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading} onClick={() => autoUnload(t)} > {fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'} @@ -1842,7 +2102,25 @@ function ImportArriveQueueTab({ {isOpen && ( - + + setAssignmentsBySchedule((current) => ({ + ...current, + [t.scheduleId]: { + ...(current[t.scheduleId] ?? {}), + [bookingId]: draft.warehouseId ? draft : {}, + }, + })) + } + onReadyChange={(ready) => + setReadyBySchedule((current) => ({ ...current, [t.scheduleId]: ready })) + } + /> )} @@ -1934,6 +2212,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { reference: row.bookingReference ?? row.bookingId, tradeDirection: 'IMPORT', lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null, + customerTruckPlateNumber: row.customerTruckPlateNumber, + customerTruckDriverName: row.customerTruckDriverName, + customerTruckType: row.customerTruckType, + customerTruckContainerNumber: row.customerTruckContainerNumber, + customerTruckAssignedAt: row.customerTruckAssignedAt, } : null, }) as unknown as WarehouseInventoryItem; @@ -2262,6 +2545,8 @@ interface WarehouseFlowWorkbenchProps { direction?: WarehouseFlowDirection; enabled?: boolean; onChanged?: () => void; + focusedBookingId?: string; + focusedBookingLabel?: string; } function WarehouseQueueTabs({ @@ -2481,10 +2766,14 @@ function ExportWarehouseTabs({ enabled, location, onChanged, + focusedBookingId, + focusedBookingLabel, }: { enabled: boolean; location: Location; onChanged?: () => void; + focusedBookingId?: string; + focusedBookingLabel?: string; }) { const [activeTab, setActiveTab] = useState('receive-queue'); const { data: eligibleRows = [] } = useQuery( @@ -2543,7 +2832,14 @@ function ExportWarehouseTabs({ {activeTab === 'receive-queue' && ( - + )} {activeTab === 'received' && ( @@ -2568,6 +2864,8 @@ export function WarehouseFlowWorkbench({ direction = 'BOTH', enabled = true, onChanged, + focusedBookingId, + focusedBookingLabel, }: WarehouseFlowWorkbenchProps) { const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); const [tab, setTab] = useState>( @@ -2600,24 +2898,42 @@ export function WarehouseFlowWorkbench({ - + ) : activeDirection === 'IMPORT' ? ( ) : ( - + )} ); } /** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ -function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { +function BulkReceiveModal({ opened, onClose, onReceived, bookingId, bookingLabel, direction = 'BOTH' }: ReceiveInventoryModalProps) { return ( - +