diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0fa1056dd..5b027448c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -33,6 +33,13 @@ async function bootstrap() { "delegator-position-id", "current-project-id", "current-position-id", + // x-prefixed variants sent by the user-management / record-management + // frontend modules (same values, different naming convention) + "x-organization-unit-id", + "x-delegator-id", + "x-delegator-position-id", + "x-current-project-id", + "x-current-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev diff --git a/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts new file mode 100644 index 000000000..fadacda69 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the + * vehicle link is optional and only for acquisitions that ARE a fleet vehicle. + */ +export class AddAcquisitionItemName2470000000000 implements MigrationInterface { + name = 'AddAcquisitionItemName2470000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + ADD COLUMN IF NOT EXISTS item_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + DROP COLUMN IF EXISTS item_name + `); + } +} 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 bc8c80982..ac5b3c3cd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -480,6 +480,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index eb4699008..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -576,4 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6ca4473cd..266a00044 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -196,7 +196,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // Staff see every contract; customers are force-scoped to their own company. - if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { return this.contractsService.findAll(filter); } const userId = user?.id; @@ -273,7 +276,8 @@ export class ContractsController { if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && - !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) + !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } @@ -475,7 +479,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { view, html, signatures } = @@ -510,7 +517,10 @@ export class ContractsController { @Res() res: Response, ): Promise { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { stream, record } = await this.transitionService.streamContractPdf(id); @@ -566,9 +576,12 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // H12(c): a customer may only renew a contract their company owns. Staff - // with bookings.view bypass, mirroring getContractView/downloadContractDocument. + // with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.transitionService.renew(id, resolveAuthUserId(user)); @@ -592,9 +605,12 @@ export class ContractsController { @UploadedFiles() files: Express.Multer.File[], ) { // H12(c): only the owning company's customer may upload clearance docs. - // Staff with bookings.view bypass, mirroring the other contract handlers. + // Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.clearanceService.uploadDocuments(id, files ?? []); diff --git a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts index 943d79296..cfab53a5d 100644 --- a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, IsEnum, IsBoolean, + MinLength, } from 'class-validator'; import { VendorType } from '../entities/vendor.entity'; import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; @@ -72,6 +73,11 @@ export class UpdateVendorDto { } export class CreateAcquisitionDto { + /** WHAT was acquired — required so an acquisition can't be saved empty. */ + @IsString() + @MinLength(2) + itemName!: string; + @IsOptional() @IsUUID() vehicleId?: string; @@ -120,6 +126,11 @@ export class CreateAcquisitionDto { } export class UpdateAcquisitionDto { + @IsOptional() + @IsString() + @MinLength(2) + itemName?: string; + @IsOptional() @IsUUID() vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts index d4f781c15..e1a9f4ff5 100644 --- a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -18,6 +18,12 @@ export enum AcquisitionStatus { @Entity({ name: 'asset_acquisitions', schema: 'freight' }) @Index(['vehicleId', 'acquisitionDate']) export class AssetAcquisition extends BaseEntity { + /** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */ + @Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true }) + itemName?: string; + + /** Optional link — only when the acquisition IS a fleet vehicle. Parts and + * general procurement stay unlinked so reports don't misattribute them. */ @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts new file mode 100644 index 000000000..8004d9d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ProcurementService } from './procurement.service'; +import { AcquisitionType } from './entities/asset-acquisition.entity'; + +// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may. +describe('ProcurementService acquisition lease-field guard', () => { + const repo = { + createAcquisition: jest.fn(async (dto) => dto), + findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })), + updateAcquisition: jest.fn(async (_id, dto) => dto), + }; + const svc = new ProcurementService(repo as never); + + it('rejects a PURCHASE with lease dates', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + } as never), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a LEASE with lease dates and a plain PURCHASE', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Rented crane', + acquisitionType: AcquisitionType.LEASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + leaseEnd: '2027-07-01', + } as never), + ).resolves.toBeDefined(); + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + } as never), + ).resolves.toBeDefined(); + }); + + it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => { + await expect( + svc.updateAcquisition('a1', { monthlyPayment: 500 } as never), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts index e799d5ff9..7095a6a09 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { ProcurementRepository } from './procurement.repository'; import { Vendor } from './entities/vendor.entity'; -import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity'; import { AssetDisposal } from './entities/asset-disposal.entity'; import { CreateVendorDto, @@ -51,7 +51,23 @@ export class ProcurementService { } // ---- Acquisitions ---- + /** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */ + private assertLeaseFieldsValid(dto: { + acquisitionType?: string; + leaseStart?: string; + leaseEnd?: string; + monthlyPayment?: number; + }): void { + if (dto.acquisitionType !== AcquisitionType.PURCHASE) return; + if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) { + throw new BadRequestException( + 'Lease start/end and monthly payment are not valid for a PURCHASE acquisition', + ); + } + } + async createAcquisition(dto: CreateAcquisitionDto): Promise { + this.assertLeaseFieldsValid(dto); return this.procurementRepository.createAcquisition(dto); } @@ -64,6 +80,20 @@ export class ProcurementService { } async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + // Validate against the resulting record, not just the patch — switching an + // acquisition to PURCHASE must also shed any stored lease terms. + const existing = await this.procurementRepository.findAcquisitionById(id); + if (existing) { + const next = { ...existing, ...dto }; + if (next.acquisitionType === AcquisitionType.PURCHASE) { + this.assertLeaseFieldsValid({ + acquisitionType: next.acquisitionType, + leaseStart: dto.leaseStart, + leaseEnd: dto.leaseEnd, + monthlyPayment: dto.monthlyPayment, + }); + } + } return this.procurementRepository.updateAcquisition(id, dto); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 463ae7ea8..3e4fab0d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -253,12 +253,18 @@ export function minLocomotiveLimits( maxTrainLengthMeters: Math.min( ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), - // Weakest locomotive's tolerance governs the set, same as its caps. - overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))), - overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))), + // Weakest CONFIGURED tolerance governs the set — a locomotive with no + // tolerance set has no opinion, it does not zero out the others. + overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)), + overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)), }; } +function minConfigured(values: Array): number { + const configured = values.filter((v) => v != null).map((v) => num(v)); + return configured.length ? Math.min(...configured) : 0; +} + /** Per-booking train length from wagon count and freight-specific wagon type length. */ export function bookingTrainLengthMeters( freightType: string | null | undefined, 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 13624691a..4a26236b0 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 @@ -2811,13 +2811,12 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); - const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; @@ -2826,8 +2825,6 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} - ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -2909,8 +2906,6 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity - Customer Name - Customer ID Cargo Type Container No Chassis No @@ -2918,7 +2913,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} 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 bd702542b..8e9e0bc76 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 @@ -1578,6 +1578,14 @@ export class WarehouseInventoryService { notes: `Bulk received (${dto.direction})`, truckEntrance, }); + + // Validate capacity before saving + const weight = Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); + this.assertCapacity('Yard', yard, weight, 0, containerCount); + this.assertCapacity('Zone', zone, weight, 0, containerCount); + const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1585,7 +1593,7 @@ export class WarehouseInventoryService { zoneId: dto.zoneId, bookingId, quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight: Number(booking.weight) || 0, + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1593,6 +1601,9 @@ export class WarehouseInventoryService { }), ); + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + // Receiving the booking flags every container unit as received into the // port (self-haul export: the delivering truck's goods are now in) so // staff can raise the per-container GRN over what's received. diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 3279e9092..5b5e2b227 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -44,6 +44,7 @@ export class WarehouseYardsService { // Ensure the parent warehouse exists. await this.warehousesService.findById(warehouseId); await this.assertCodeUnique(warehouseId, dto.code.trim()); + await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.yardsRepository.create({ warehouseId, @@ -69,14 +70,22 @@ export class WarehouseYardsService { await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed warehouse limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.yardsRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -97,4 +106,39 @@ export class WarehouseYardsService { throw new ConflictException(`Yard code ${code} already exists in this warehouse`); } } + + private async assertCapacityWithinWarehouse( + warehouseId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeYardId?: string, + ): Promise { + const warehouse = await this.warehousesService.findById(warehouseId); + const yards = await this.findByWarehouse(warehouseId); + + // Sum existing yard capacities, excluding the yard being updated if provided + const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards; + const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0); + const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && warehouse.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > warehouse.capacityWeight) { + throw new BadRequestException( + `Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && warehouse.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > warehouse.capacityContainers) { + throw new BadRequestException( + `Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index b4ae2e0de..367a5a75e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; @@ -43,6 +43,7 @@ export class WarehouseZonesService { // Ensure the parent yard exists. await this.yardsService.findById(yardId); await this.assertCodeUnique(yardId, dto.code.trim()); + await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.zonesRepository.create({ yardId, @@ -68,14 +69,22 @@ export class WarehouseZonesService { await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed yard limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.zonesRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -96,4 +105,39 @@ export class WarehouseZonesService { throw new ConflictException(`Zone code ${code} already exists in this yard`); } } + + private async assertCapacityWithinYard( + yardId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeZoneId?: string, + ): Promise { + const yard = await this.yardsService.findById(yardId); + const zones = await this.findByYard(yardId); + + // Sum existing zone capacities, excluding the zone being updated if provided + const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones; + const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0); + const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && yard.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > yard.capacityWeight) { + throw new BadRequestException( + `Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && yard.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > yard.capacityContainers) { + throw new BadRequestException( + `Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 1f82f6e93..741ceafb1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -808,41 +808,41 @@ export const POSITION_PERMISSION_PRESETS = { // permission catalog (all CRUD across bookings, contracts, scheduling, // fleet, warehouse, mile, finance, settings, staff). operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), - // Dispatcher: warehouse floor operations — receive/GRN, move, load/unload, - // inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, - // plus truck dispatch on the mile legs and read-only operational context. - // Allocation & fee rules are VIEW-ONLY — never create/update/delete. + // Dispatcher: full CRUD on warehouse management (incl. import/export/intercity + // inventory flows) and fleet management, plus truck dispatch on the mile legs + // and operational context. The ONE carve-out: allocation & fee rules stay + // VIEW-ONLY — a dispatcher never creates/updates/deletes those rules. dispatcher: dedupe([ + // Warehouse management — full CRUD. FREIGHT_PERMS.warehouseDashboard.view, - FREIGHT_PERMS.warehouses.view, - FREIGHT_PERMS.warehouseYards.view, - FREIGHT_PERMS.warehouseZones.view, - FREIGHT_PERMS.warehouseInventory.view, - FREIGHT_PERMS.warehouseInventory.receive, - FREIGHT_PERMS.warehouseInventory.move, - FREIGHT_PERMS.warehouseInventory.load, - FREIGHT_PERMS.warehouseInventory.unload, - FREIGHT_PERMS.warehouseInventory.dispatch, - FREIGHT_PERMS.warehouseInventory.gatePass, - FREIGHT_PERMS.warehouseInventory.release, - FREIGHT_PERMS.warehouseInventory.deliver, - FREIGHT_PERMS.warehouseInventory.inspect, - FREIGHT_PERMS.warehouseInspectionReports.view, - FREIGHT_PERMS.warehouseInspectionReports.create, - FREIGHT_PERMS.warehouseInspectionReports.update, - FREIGHT_PERMS.interchangeDocuments.view, - FREIGHT_PERMS.interchangeDocuments.generate, - FREIGHT_PERMS.interchangeDocuments.acknowledge, - FREIGHT_PERMS.warehouseFeeInvoices.view, - FREIGHT_PERMS.warehouseFeeInvoices.generate, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), // View-only on the rules that govern allocation and fees. FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view, + // Fleet management — full CRUD. + ...Object.values(FREIGHT_PERMS.fleet), + FREIGHT_PERMS.fleetDashboard.view, + ...Object.values(FREIGHT_PERMS.fleetReports), + ...Object.values(FREIGHT_PERMS.vehicles), + ...Object.values(FREIGHT_PERMS.drivers), + ...Object.values(FREIGHT_PERMS.tracking), + ...Object.values(FREIGHT_PERMS.fuel), + ...Object.values(FREIGHT_PERMS.maintenance), + ...Object.values(FREIGHT_PERMS.locomotives), + ...Object.values(FREIGHT_PERMS.wagons), + ...Object.values(FREIGHT_PERMS.trains), + ...Object.values(FREIGHT_PERMS.routes), + ...Object.values(FREIGHT_PERMS.containers), + ...Object.values(FREIGHT_PERMS.cargoes), // Truck dispatch on the EDR mile legs + operational context. - FREIGHT_PERMS.firstMile.view, - FREIGHT_PERMS.firstMile.assignVehicles, - FREIGHT_PERMS.lastMile.view, - FREIGHT_PERMS.lastMile.assignVehicles, + ...Object.values(FREIGHT_PERMS.firstMile), + ...Object.values(FREIGHT_PERMS.lastMile), FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.bookings.operations, ]), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 70bcc4f9b..a36ca98b3 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -146,6 +146,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Overview", href: "/dashboard/overview", icon: , + permission: FREIGHT_PERMS.overview.view, }, { label: "Customers", @@ -189,7 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Support", href: "/dashboard/support", icon: , - permission: FREIGHT_PERMS.bookings.view, + permission: FREIGHT_PERMS.support.view, }, ...demoItems, ], diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx index 546bd0def..6591ffa7c 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx @@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea'; import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; /** * Customer Pickup + Proof of Delivery capture for a LOADED cargo. @@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on toast({ title: 'Receiver name is required', variant: 'destructive' }); return; } + if (isBackdated(pickupDate)) { + toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' }); + return; + } try { await deliver.mutateAsync({ id: cargoId, @@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on setPickupDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index 2088db32d..c9748d80a 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from 'react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated } from '@/lib/no-backdate'; import { lastMileService, type LastMileRecord } from '@/services/last-mile.service'; interface TruckDetentionModalProps { @@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM description="Detention clock start" value={arrived} onChange={(v) => setArrived(v ? new Date(v) : null)} + minDate={new Date()} clearable /> setDelivered(v ? new Date(v) : null)} + minDate={new Date()} clearable /> - diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 9e66d9861..681f12ad5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; import { warehouseService } from '@/services/warehouse.service'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -440,6 +441,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + // No backdating: gate times are recorded as they happen. The locked + // entrance (exit step) keeps its original past gate-in untouched. + if (!isEntranceLocked && isBackdated(gateInTime)) { + toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' }); + return; + } if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { toast({ variant: 'destructive', @@ -447,6 +454,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + if (isExitStep && isBackdated(gateOutTime)) { + toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' }); + return; + } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; @@ -646,7 +657,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} - setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> {hasContainerWeights && ( @@ -679,7 +690,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`} - setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> + setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> {weightMismatch && ( } color="red" variant="light"> diff --git a/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts new file mode 100644 index 000000000..0ce0541ee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts @@ -0,0 +1,20 @@ +/** + * Backdating guard for operational time entries (gate in/out, mile truck + * times, delivery pickups): times must be recorded as they happen, never + * dated back. A one-hour grace covers real-world lag (weighbridge queue, + * operator finishing the form after the event). + */ +export const BACKDATE_GRACE_MS = 60 * 60 * 1000; + +/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */ +export const nowLocalDateTimeInput = (): string => + new Date(Date.now() - new Date().getTimezoneOffset() * 60_000) + .toISOString() + .slice(0, 16); + +/** True when the value is more than the grace period in the past. */ +export const isBackdated = (value: string | Date | null | undefined): boolean => { + if (!value) return false; + const t = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS; +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index b11d7bffa..f793f7006 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -2,6 +2,12 @@ import type { AuthUser } from "@/auth/types"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; export const FREIGHT_PERMS = { + overview: { + view: "edr_freight_app:overview:view", + }, + support: { + view: "edr_freight_app:support:view", + }, bookings: { view: "edr_freight_app:bookings:view", create: "edr_freight_app:bookings:create", diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx index 0c6bbabf0..584e3e13b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx @@ -61,6 +61,7 @@ const clean = >(obj: T): Partial => ) as Partial; const emptyAcquisition = { + itemName: "", vehicleId: "", vendorId: "", acquisitionType: "PURCHASE" as AcquisitionType, @@ -239,6 +240,7 @@ export default function ProcurementPage() { + Item / Asset Vehicle Type Date @@ -249,7 +251,7 @@ export default function ProcurementPage() { {loadingAcquisitions ? ( - + @@ -257,7 +259,7 @@ export default function ProcurementPage() { ) : acquisitions.length === 0 ? ( - + No acquisitions recorded yet. @@ -266,6 +268,7 @@ export default function ProcurementPage() { ) : null} {acquisitions.map((a: AssetAcquisition) => ( + {a.itemName || "—"} {vehicleLabel(a.vehicle, a.vehicleId)} @@ -411,31 +414,51 @@ export default function ProcurementPage() { size="lg" > + setAcqForm({ ...acqForm, itemName: e.currentTarget.value })} + required + /> setAcqForm({ ...acqForm, vendorId: val || "" })} - searchable - clearable - /> + + - setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" }) - } + onChange={(val) => { + const acquisitionType = (val as AcquisitionType) || "PURCHASE"; + // Lease terms are invalid on a purchase — drop them on switch. + setAcqForm( + acquisitionType === "PURCHASE" + ? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined } + : { ...acqForm, acquisitionType }, + ); + }} required /> - setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })} - /> - setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })} - /> - - setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined }) - } - decimalScale={2} - min={0} - /> + {acqForm.acquisitionType !== "PURCHASE" && ( + <> + setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })} + /> + setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })} + /> + + setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined }) + } + decimalScale={2} + min={0} + /> + + )}
+ + + Plate Number + Driver Name + Truck Type + Containers + + + + {parsed.map((truck, idx) => ( + + + {truck.truckPlateNumber} + + + {truck.driverName} + + + {truck.truckType} + + + {truck.containerNumbers?.length ? ( + + {truck.containerNumbers.map((c) => ( + + {c} + + ))} + + ) : ( + + — + + )} + + + ))} + +
+ + + + + Ready to upload {parsed.length} truck(s) + + + + + )} + + {uploadMutation.isError && ( + } color="red"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed"} + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 7c9cbeb0e..5d2bac886 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -23,6 +23,7 @@ import { api } from "@/services/api"; import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; +import { BulkTruckUploadModal } from "./BulkTruckUploadModal"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; @@ -65,6 +66,7 @@ export function CustomerTruckAssignmentCard({ const [containers, setContainers] = useState([]); const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); + const [bulkModalOpen, setBulkModalOpen] = useState(false); // Container numbers on the booking that aren't already loaded onto a truck. const assignedNumbers = new Set( @@ -163,6 +165,14 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment + {pendingAssignmentCount > 0 && ( {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment @@ -325,6 +335,16 @@ export function CustomerTruckAssignmentCard({ )} + + setBulkModalOpen(false)} + bookingId={booking.id} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + onAssigned(); + }} + /> ); } diff --git a/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts new file mode 100644 index 000000000..7a0fef1cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts @@ -0,0 +1,108 @@ +import * as XLSX from 'xlsx'; + +export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void { + const data = [ + { + 'Truck Plate Number': '3-12345/67890', + 'Driver Name': 'John Doe', + 'Truck Type': 'Flatbed', + 'Container 1': 'MAEU1234567', + 'Container 2': 'HLXU7654321', + }, + { + 'Truck Plate Number': '3-98765/43210', + 'Driver Name': 'Jane Smith', + 'Truck Type': 'Flatbed', + 'Container 1': 'COSCO1111111', + 'Container 2': '', + }, + ]; + + const instructions = [ + ['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'], + [], + ['Column', 'Required', 'Notes'], + ['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'], + ['Driver Name', 'Yes', 'Full name of truck driver'], + ['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'], + ['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'], + ['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'], + [], + ['CONTAINER RULES'], + ['- A 40ft container fills one truck (max 1 per truck)'], + ['- Two 20ft containers fit on one truck (max 2 per truck)'], + ['- No size mixing on same truck'], + ['- Containers must be from the booking'], + [], + ['Example Data Below →'], + ]; + + const wb = XLSX.utils.book_new(); + + // Instructions sheet + const wsInstructions = XLSX.utils.aoa_to_sheet(instructions); + wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }]; + XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions'); + + // Data template sheet + const wsData = XLSX.utils.json_to_sheet(data, { + header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'], + }); + wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }]; + XLSX.utils.book_append_sheet(wb, wsData, 'Trucks'); + + XLSX.writeFile(wb, filename); +} + +export function parseTruckAssignmentFile( + file: File, +): Promise< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> +> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result as ArrayBuffer; + const wb = XLSX.read(data, { type: 'array' }); + const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0]; + + if (!wsData) { + reject(new Error('No data sheet found in Excel file')); + return; + } + + const jsonData = XLSX.utils.sheet_to_json(wsData) as Array>; + + const trucks = jsonData.map((row) => { + const containers = [ + row['Container 1'], + row['Container 2'], + ] + .filter((c) => c && c.trim()) + .map((c) => c.trim().toUpperCase()); + + return { + truckPlateNumber: row['Truck Plate Number']?.trim() || '', + driverName: row['Driver Name']?.trim() || '', + truckType: row['Truck Type']?.trim() || '', + containerNumbers: containers.length > 0 ? containers : undefined, + }; + }); + + resolve(trucks); + } catch (error) { + reject(error); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsArrayBuffer(file); + }); +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index 63fd8be85..0ce77ad5b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; import { AuthModule } from '../auth/auth.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; +import { TicketsModule } from '../tickets/tickets.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 64dcb2c6c..8b37f9841 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; +import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; @@ -101,6 +102,7 @@ export class BookingsService { private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private readonly seatsService: SeatsService, + private readonly ticketsService: TicketsService, private readonly eventEmitter: EventEmitter2, private readonly verifaydaService: VerifaydaService, private readonly currencyService: CurrencyService, @@ -1903,6 +1905,39 @@ export class BookingsService { }; } + // Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing + // (ticket generation failed silently after payment — see finalizePaymentSuccess in + // payments.service.ts), attempt to generate them now so the confirmation page + // doesn't show "Not yet issued". + if ( + booking.status === 'CONFIRMED' && + (booking as any).tickets?.length === 0 && + (booking as any).paymentIntent?.status === 'SUCCEEDED' + ) { + try { + await this.ticketsService.generate(booking.id); + } catch (err) { + this.logger.warn(`getByRef: generate failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}. Trying smart assign.`); + try { + await this.ticketsService.smartAssignAndGenerate(booking.id); + } catch (retryErr) { + this.logger.error(`getByRef: smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`); + } + } + // Re-fetch to include any newly created tickets + const refreshed = await this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, + paymentIntent: true, tickets: true, + priceTier: { select: { priceMinor: true } }, + }, + }); + if (refreshed) Object.assign(booking, refreshed); + } + const outboundSegment = this.resolveSegmentStations( (booking as any).schedule, (booking as any).originStationId, diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 7ee67be76..b3befbaea 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -3,6 +3,7 @@ import { Logger, NotFoundException, BadRequestException, + ConflictException, } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service"; import { SeatsService } from "../seats/seats.service"; @@ -897,12 +898,25 @@ export class PaymentsService { try { await this.ticketsService.generate(booking.id); } catch (err) { - this.logger.error( - `Error generating ticket for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`, - ); - // Re-throw so callers (e.g. force-confirm) know tickets weren't issued. - // Webhook handlers catch this themselves and still return 200 to avoid redelivery. - throw err; + const msg = err instanceof Error ? err.message : String(err); + // Only reassign seats when a *different* booking genuinely holds the seat + // (ConflictException). Any other error (transient DB issue, etc.) is logged + // and swallowed — the passenger keeps their original seat and the ticket can + // be retried via "Generate Missing" in the backoffice. + if (err instanceof ConflictException) { + this.logger.warn( + `Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`, + ); + try { + await this.ticketsService.smartAssignAndGenerate(booking.id); + } catch (retryErr) { + this.logger.error( + `Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, + ); + } + } else { + this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`); + } } try { diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index cc53be263..4ecaeb267 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -9,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; export class TicketsController { constructor(private service: TicketsService) {} + @Post('generate-missing') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Generate tickets for all confirmed bookings that are missing them', + description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.', + }) + generateMissing() { + return this.service.generateMissing(); + } + @Post('smart-assign/:bookingId') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @@ -23,10 +34,11 @@ export class TicketsController { } @Post('generate/:bookingId') - @SetMetadata('isPublic', true) + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ - summary: 'Generate ticket for booking (confirmation page)', - description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' + summary: 'Generate ticket for booking', + description: 'Creates a ticket for a confirmed booking with succeeded payment. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' }) generateTicket(@Param('bookingId') bookingId: string) { return this.service.generate(bookingId); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 1cff652e0..736b781bd 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -210,15 +210,6 @@ export class TicketsService { }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); - // Seats taken by other confirmed/boarded bookings on this schedule - const takenByOthers = await this.prisma.bookingSeat.findMany({ - where: { - booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, - seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } }, - }, - select: { seatId: true }, - }).then(rows => new Set(rows.map(r => r.seatId))); - // Seats held by any active SeatHold (not yet expired) const heldSeatIds = await this.prisma.seatHold.findMany({ where: { expiresAt: { gt: new Date() } }, @@ -230,42 +221,62 @@ export class TicketsService { select: { seatId: true }, }).then(rows => new Set(rows.map(r => r.seatId))); - // Union of all unavailable seat IDs (excluding the booking's own seats) const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string)); + const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; + + // Track newly assigned seats so the same seat isn't given to two passengers const unavailableIds = new Set([ - ...[...takenByOthers].filter(id => !ownSeatIds.has(id)), ...[...heldSeatIds], ...[...blockedSeatIds], ]); - const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; - for (const bs of (booking as any).seats) { const originalSeatId: string = bs.seatId; + // Use the per-seat scheduleId — for ROUND_TRIP leg 2 this is the return schedule, + // not booking.scheduleId (the outbound schedule). + const legScheduleId: string = bs.scheduleId ?? booking.scheduleId; - // Case 1: original seat is still free — nothing to do - if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue; + // Seats taken by other confirmed/boarded bookings on THIS leg's schedule + const takenByOthersOnLeg = await this.prisma.bookingSeat.findMany({ + where: { + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + seat: { coach: { assignments: { some: { scheduleId: legScheduleId } } } }, + }, + select: { seatId: true }, + }).then(rows => new Set(rows.map(r => r.seatId))); - // Case 2: original seat is unavailable — find a truly available seat in the same coach type + // Case 1: original seat is still free on this leg — nothing to do + if ( + !takenByOthersOnLeg.has(originalSeatId) && + !heldSeatIds.has(originalSeatId) && + !blockedSeatIds.has(originalSeatId) + ) continue; + + // Case 2: original seat is unavailable — find a free seat of the same coach type on this leg's schedule const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId; + const allUnavailable = new Set([ + ...[...takenByOthersOnLeg].filter(id => !ownSeatIds.has(id)), + ...[...unavailableIds], + ]); + const candidate = await this.prisma.seat.findFirst({ where: { status: 'AVAILABLE', seatNumber: { not: '' }, NOT: [ { seatNumber: { startsWith: '-' } }, - { id: { in: [...unavailableIds] } }, + { id: { in: [...allUnavailable] } }, ], coach: { - assignments: { some: { scheduleId: booking.scheduleId } }, + assignments: { some: { scheduleId: legScheduleId } }, ...(coachTypeId ? { coachTypeId } : {}), }, }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); - // Case 3: no seats left in that class + // Case 3: no seats left in that class on this leg if (!candidate) { const className = bs.seat?.coach?.coachType?.name ?? 'the same class'; throw new ConflictException( @@ -278,10 +289,7 @@ export class TicketsService { data: { seatId: candidate.id }, }); - // Mark the newly assigned seat as taken so subsequent passengers in the - // same booking don't get assigned the same seat. unavailableIds.add(candidate.id); - reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); } @@ -352,26 +360,93 @@ export class TicketsService { } } - // Check for seat conflicts before deleting existing tickets or issuing new ones + await this.prisma.ticket.deleteMany({ where: { bookingId } }); + + // Remove any SeatBlock rows left over from a previous generate() run for this + // booking — they reference the old ticket IDs which are now deleted, and would + // otherwise cause the conflict check below to see this booking's own seats as + // blocked by another booking. const seatIds = (booking as any).seats.map((bs: any) => bs.seatId); - const conflictingSeats = await this.prisma.bookingSeat.findMany({ - where: { - seatId: { in: seatIds }, - booking: { - id: { not: bookingId }, - status: { in: ['CONFIRMED', 'BOARDED'] }, - }, - }, - include: { seat: true }, + await this.prisma.seatBlock.deleteMany({ + where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM' }, }); - if (conflictingSeats.length > 0) { - const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', '); - throw new ConflictException( - `Seat(s) ${labels} are already confirmed for another booking.`, - ); + + // Check for seat conflicts — only seats confirmed/boarded by a *different* booking + // on the SAME schedule AND with OVERLAPPING segments are a real conflict. + // Segment overlap: two bookings conflict on a seat when their stop-sequence ranges + // overlap: A.originSeq < B.destSeq AND B.originSeq < A.destSeq. + // We resolve sequences via TripStopTime using each booking's originStationId / + // destinationStationId. Bookings with no station IDs (full-route) are treated as + // seq 0 → ∞ and always overlap. + const thisBookingSeats = (booking as any).seats as Array<{ seatId: string; scheduleId: string | null }>; + + // Resolve this booking's stop sequences per leg schedule + const thisSeqMap = new Map(); + const legScheduleIds = [...new Set(thisBookingSeats.map(bs => bs.scheduleId ?? booking.scheduleId))]; + for (const schedId of legScheduleIds) { + const originId = (booking as any).originStationId; + const destId = (booking as any).destinationStationId; + if (!originId || !destId) { + thisSeqMap.set(schedId, { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }); + continue; + } + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: schedId, stationId: { in: [originId, destId] } }, + select: { stationId: true, sequence: true }, + }); + const oStop = stops.find(s => s.stationId === originId); + const dStop = stops.find(s => s.stationId === destId); + thisSeqMap.set(schedId, { + originSeq: oStop?.sequence ?? 0, + destSeq: dStop?.sequence ?? Number.MAX_SAFE_INTEGER, + }); } - await this.prisma.ticket.deleteMany({ where: { bookingId } }); + // Find other confirmed/boarded bookings that share any (seatId, scheduleId) pair + const candidateConflicts = await this.prisma.bookingSeat.findMany({ + where: { + OR: thisBookingSeats.map(bs => ({ + seatId: bs.seatId, + scheduleId: bs.scheduleId ?? booking.scheduleId, + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + })), + }, + include: { + seat: true, + booking: { select: { id: true, originStationId: true, destinationStationId: true } }, + }, + }); + + const trueConflicts: string[] = []; + for (const other of candidateConflicts) { + const legScheduleId = other.scheduleId ?? booking.scheduleId; + const thisSeq = thisSeqMap.get(legScheduleId) ?? { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }; + + const otherOriginId = (other.booking as any).originStationId; + const otherDestId = (other.booking as any).destinationStationId; + let otherOriginSeq = 0; + let otherDestSeq = Number.MAX_SAFE_INTEGER; + if (otherOriginId && otherDestId) { + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: legScheduleId, stationId: { in: [otherOriginId, otherDestId] } }, + select: { stationId: true, sequence: true }, + }); + otherOriginSeq = stops.find(s => s.stationId === otherOriginId)?.sequence ?? 0; + otherDestSeq = stops.find(s => s.stationId === otherDestId)?.sequence ?? Number.MAX_SAFE_INTEGER; + } + + // Segments overlap when: thisOrigin < otherDest AND otherOrigin < thisDest + if (thisSeq.originSeq < otherDestSeq && otherOriginSeq < thisSeq.destSeq) { + trueConflicts.push((other as any).seat.seatNumber); + } + } + + if (trueConflicts.length > 0) { + const labels = [...new Set(trueConflicts)].join(', '); + throw new ConflictException( + `Seat(s) ${labels} are already confirmed for another booking on the same schedule and overlapping segment.`, + ); + } // Generate one ticket per passenger per leg. // Round-trip / transit bookings have seats on multiple legs — each leg needs its own @@ -830,6 +905,34 @@ export class TicketsService { }; } + async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> { + const confirmedWithNoTickets = await this.prisma.booking.findMany({ + where: { + status: 'CONFIRMED', + tickets: { none: {} }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + select: { id: true, bookingRef: true }, + }); + + const details: any[] = []; + let generated = 0; + let failed = 0; + + for (const booking of confirmedWithNoTickets) { + try { + await this.generate(booking.id); + generated++; + details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' }); + } catch (err) { + failed++; + details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) }); + } + } + + return { processed: confirmedWithNoTickets.length, generated, failed, details }; + } + async delete(id: string) { const ticket = await this.prisma.ticket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('Ticket not found'); diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 4b55b37be..5c6354fd4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -98,6 +98,21 @@ export default function TicketsPage() { queryFn: () => apiClient.get('/fleet/coaches'), }); + const [generateMissingResult, setGenerateMissingResult] = useState(null); + + const generateMissingMutation = useMutation({ + mutationFn: () => ticketsApi.generateMissing(), + onSuccess: (result: any) => { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + setGenerateMissingResult(result); + setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`); + setTimeout(() => setSuccessMessage(''), 6000); + }, + onError: (error: any) => { + alert(error?.response?.data?.message || error?.message || 'Failed to generate missing tickets'); + }, + }); + const boardMutation = useMutation({ mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }), @@ -546,7 +561,17 @@ export default function TicketsPage() {

Tickets

Manage tickets and validations

- setExportModalOpen(true)}>Export +
+ generateMissingMutation.mutate()} + > + Generate Missing + + setExportModalOpen(true)}>Export +
{/* Filters */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 1b917afe2..0d35ed2d3 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -224,6 +224,7 @@ export const ticketsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/tickets/${id}`), + generateMissing: () => apiClient.post('/tickets/generate-missing', {}), validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), regenerate: (ticketId: string) => apiClient.post(`/tickets/${ticketId}/regenerate`), diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index cd24bbe0c..19bae536b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -70,7 +70,9 @@ export default function ConfirmationPage() { const CONFIRMATION_GRACE_PERIOD_MS = 10_000; const FAST_POLL_INTERVAL_MS = 2_500; const SLOW_POLL_INTERVAL_MS = 10_000; + const MAX_TICKET_POLL_ATTEMPTS = 12; // 12 × 2.5s = 30s max wait for tickets const mountTimeRef = useRef(Date.now()); + const ticketPollAttemptsRef = useRef(0); const [withinGracePeriod, setWithinGracePeriod] = useState(true); useEffect(() => { @@ -122,7 +124,13 @@ export default function ConfirmationPage() { if (!data || data.status !== "CONFIRMED") return false; const adultCount = searchCriteria?.adultCount ?? passengers.filter((p) => !isChild(p)).length; const expectedTickets = Math.max(1, adultCount); - return (data.tickets?.length ?? 0) >= expectedTickets ? false : FAST_POLL_INTERVAL_MS; + if ((data.tickets?.length ?? 0) >= expectedTickets) { + ticketPollAttemptsRef.current = 0; + return false; + } + if (ticketPollAttemptsRef.current >= MAX_TICKET_POLL_ATTEMPTS) return false; + ticketPollAttemptsRef.current += 1; + return FAST_POLL_INTERVAL_MS; }, }); diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index c6f823053..3ac0efd56 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -117,6 +117,19 @@ function BookingDetailContent() { retry: 1, }); + // When the booking is CONFIRMED but has no tickets (generate failed silently at + // payment time), call generate now so tickets are ready before the user clicks Download. + useEffect(() => { + if ( + booking?.status === 'CONFIRMED' && + booking?.id && + Array.isArray(booking?.tickets) && + booking.tickets.length === 0 + ) { + apiClient.post(`/tickets/generate/${booking.id}`, {}).then(() => refetch()).catch(() => {}); + } + }, [booking?.id, booking?.status, booking?.tickets?.length]); + const { data: paymentMethods } = useQuery({ queryKey: ["payment-methods"], queryFn: () => apiClient.get("/payments/methods"), @@ -246,19 +259,9 @@ function BookingDetailContent() { alert("Booking data not available. Please try again."); return; } - setIsGeneratingVoucher(true); try { - // If tickets are missing (generate failed silently at payment time), issue them now. - if (!booking.tickets?.length && booking.id) { - try { - await apiClient.post(`/tickets/generate/${booking.id}`, {}); - } catch { - // ignore — generate() will throw if payment not succeeded; voucher will show - // "Not yet issued" in that case, which is correct - } - } - // Always refetch so the voucher has the latest ticket barcodes. + // Always fetch fresh booking data so tickets are included. const fresh = await apiClient.get(`/bookings/${booking.bookingRef}`); const bookingData = (fresh as any)?.data || fresh; const { generateVoucherPDF } = await import("@/lib/generate-voucher"); diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts index a2acffe24..dda8c1703 100644 --- a/e2e/freight/cypress.config.ts +++ b/e2e/freight/cypress.config.ts @@ -59,6 +59,22 @@ export default defineConfig({ * user seeders are disabled in app code, so the fixture replicates * their output. Idempotent — safe to run before every spec file. */ + /** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */ + async "db:seedFile"(file: string) { + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + const sql = readFileSync( + join(process.cwd(), "cypress", "fixtures", file), + "utf8", + ); + await client.query(sql); + return true; + } finally { + await client.end(); + } + }, + async "db:seedUsers"() { // cwd = the e2e/freight project root when Cypress runs. // seed-company.sql depends on rows from seed-users.sql — keep order. diff --git a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts new file mode 100644 index 000000000..8e7c6334d --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts @@ -0,0 +1,524 @@ +/** + * Export ONE_TIME journeys — two contracts ride the same export train + * (Mojo Dry Port → Dire Dawa Yard → Djibouti Port Terminal): + * + * A. CONTAINER (20ft + 40ft): + * portal wizard → staff approval chain → OTP sign → counter-sign + * → AWAITING_CLEARANCE_DOCUMENTS (export self-clear has no required + * docs in e2e) → ops finalize → FULLY_EXECUTED → customer books + * 2 × 20ft + 1 × 40ft picking a real Shipment day → ops accepts the + * operation request → EXPORT is FCFS, so accept reserves the train slot + * immediately: SELECTED_FOR_BATCH with a pay deadline clamped to the + * export window close → staff mark-paid → PAID + SCHEDULED + linked. + * + * B. BULK (E2E Wheat, 60 tons): same journey through the bulk wizard and + * bulk booking form, riding CW4 covered wagons on the same train. + * + * Infrastructure (route, built train, distances, rates, cargo types) comes + * from seed-intercity.sql + seed-export.sql; the export route and the + * departing-today schedule are created through the UI when missing. + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql +const opsStaff = "operation@edr.local"; + +const ORIGIN_YARD = "Mojo Dry Port"; +const MID_YARD = "Dire Dawa Yard"; +const PORT_YARD = "Djibouti Port Terminal"; +const TRAIN_CODE = "TRN-E2E-1"; + +// Container numbers must be ISO (4 letters + 7 digits) and unused — stamp per run. +const stamp = String(Date.now()); +const isoNumber = (prefix: string, offset: number) => + `${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`; + +const apiUrl = () => Cypress.env("apiUrl") as string; + +function dbContract(freight: "CONTAINER" | "BULK") { + return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>( + "db:query", + { + sql: `SELECT ct.id, ct.reference, ct.status + FROM freight.contracts ct + JOIN freight.companies c ON c.id = ct.company_id + WHERE c.tin = $1 AND ct.trade_direction = 'EXPORT' AND ct.freight_type = $2 + ORDER BY ct.created_at DESC LIMIT 1`, + params: [companyTin, freight], + }, + ); +} + +function withContract( + freight: "CONTAINER" | "BULK", + fn: (c: { id: string; reference: string; status: string }) => void, +) { + dbContract(freight).then(({ rows }) => { + expect(rows, `latest EXPORT ${freight} contract`).to.have.length(1); + fn(rows[0]); + }); +} + +function expectContractStatus(freight: "CONTAINER" | "BULK", expected: string) { + dbContract(freight).then(({ rows }) => { + expect(rows[0]?.status, "contract status").to.eq(expected); + }); +} + +function dbBooking(freight: "CONTAINER" | "BULK") { + return cy.task<{ + rows: Array<{ + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }>; + }>("db:query", { + sql: `SELECT b.id, b.reference, b.status, b.scheduling_status, + b.train_schedule_id, b.payment_deadline, b.scheduled_date + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + WHERE c.tin = $1 AND b.trade_direction = 'EXPORT' AND b.freight_type = $2 + ORDER BY b.created_at DESC LIMIT 1`, + params: [companyTin, freight], + }); +} + +function withBooking( + freight: "CONTAINER" | "BULK", + fn: (b: { + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }) => void, +) { + dbBooking(freight).then(({ rows }) => { + expect(rows, `EXPORT ${freight} booking`).to.have.length(1); + fn(rows[0]); + }); +} + +/** + * The journey's export schedule. Export trains must be scheduled ≥ the booking + * lead (24h) ahead, and their window OPENS at departure − lead — so the spec + * departs at now + 24h + a couple of minutes: creatable now, window opens + * minutes later. (The intercity spec's train leaves in 2 days — outside 25h.) + */ +function dbUpcomingSchedule() { + return cy.task<{ + rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>; + }>("db:query", { + sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status + FROM freight.train_schedules ts + WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL + AND ts.scheduled_departure_date > now() + AND ts.scheduled_departure_date < now() + interval '25 hours' + ORDER BY ts.created_at DESC LIMIT 1`, + }); +} + +/** The train departs ~24h out — bookings ride its departure day (tomorrow). */ +const SHIPMENT_DAY = new Date(Date.now() + 24 * 3_600_000 + 150_000); + +function fill(label: string | RegExp, value: string) { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */ +function fillNth(label: RegExp, index: number, value: string) { + cy.get("label").then(($labels) => { + const matches = $labels.filter((_, el) => label.test(el.textContent ?? "")); + expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index); + const id = matches.eq(index).attr("for"); + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** + * Choose the train's departure day on the Schedule card's INLINE calendar + * (cargo-aware: days only unlock once the cargo details above are valid). + */ +function pickShipmentDay() { + cy.contains(/available day/, { timeout: 30000 }).should("exist"); + // Day cells are plain buttons in a div grid; only bookable days are enabled + // (out-of-month duplicates stay disabled). + const day = String(SHIPMENT_DAY.getDate()); + cy.get("button:not(:disabled)", { timeout: 15000 }) + .contains(new RegExp(`^${day}$`)) + .click({ force: true }); +} + +/** Shared staff steps: accept + LINE_STAFF approve, then director approve. */ +function approveChain(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice("marketer@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains("button", "Accept for approval", { timeout: 20000 }).click(); + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + cy.loginBackoffice("director@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + expectContractStatus(freight, "CONTRACT_READY"); +} + +/** Shared customer OTP-signature step. */ +function customerSigns(freight: "CONTAINER" | "BULK") { + cy.loginPortal(customer); + withContract(freight, (c) => cy.visitPortal(`/contracts/${c.id}/view`)); + + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + const el = win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "SIGNED_CUSTOMER"); +} + +/** Shared counter-sign + ops finalize (export self-clear: no required docs in e2e). */ +function counterSignAndFinalize(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice("marketer@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`)); + cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); + }); + cy.drawSignature(); + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS"); + + cy.loginBackoffice(opsStaff); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click(); + cy.contains("button", "Finalize document approval", { timeout: 30000 }) + .should("not.be.disabled") + .click(); + cy.contains("finalized", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "FULLY_EXECUTED"); +} + +/** Ops accept: EXPORT is FCFS — accept reserves the slot and opens the pay window. */ +function acceptAndAssertReserved(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice(opsStaff); + withBooking(freight, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`)); + cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click(); + cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click(); + cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist"); + + dbUpcomingSchedule().then(({ rows: schedules }) => { + expect(schedules, "departing-today export schedule").to.have.length(1); + withBooking(freight, (b) => { + expect(b.status, "FCFS reservation").to.eq("SELECTED_FOR_BATCH"); + expect(b.train_schedule_id).to.eq(schedules[0].id); + // Export parity: the pay window never outlives the booking window close. + expect(b.payment_deadline, "pay deadline set").to.be.a("string"); + expect(new Date(b.payment_deadline!).getTime()).to.be.at.most( + new Date(schedules[0].window_closes_at).getTime(), + ); + }); + }); +} + +function markPaidAndAssertAllocated(freight: "CONTAINER" | "BULK") { + withBooking(freight, (b) => { + cy.apiLogin(opsStaff).then(({ token }) => { + cy.request({ + method: "POST", + url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + }); + withBooking(freight, (b) => { + expect(b.status).to.eq("PAID"); + expect(b.scheduling_status).to.eq("SCHEDULED"); + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND deleted_at IS NULL`, + params: [b.id], + }).then(({ rows }) => { + expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1); + }); + }); +} + +describe("export one-time journeys: container + bulk on one train", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-intercity.sql"); + cy.task("db:seedFile", "seed-export.sql"); + }); + + // ── Shared infrastructure ───────────────────────────────────────────────── + + it("operations ensures the export route exists", () => { + cy.loginBackoffice(opsStaff); + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/routes"); + cy.contains("button", "Add route", { timeout: 20000 }).click(); + cy.contains("Add Route", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", "Add milestone").click(); + const pickYard = (index: number, yard: string) => { + cy.get('.mantine-Modal-content input[placeholder="Select yard"]') + .eq(index) + .click({ force: true }); + cy.get('[role="option"]:visible').contains(yard).click(); + }; + pickYard(0, ORIGIN_YARD); + pickYard(1, MID_YARD); + pickYard(2, PORT_YARD); + cy.get(".mantine-Modal-content").contains("button", "Save").click(); + }); + }); + + it("operations schedules the export train — booking window opens", () => { + cy.loginBackoffice(opsStaff); + + dbUpcomingSchedule().then(({ rows }) => { + if (rows.length > 0) return; + + cy.visit("/dashboard/operations/train-scheduling-v2"); + cy.contains("button", "New schedule", { timeout: 20000 }).click(); + cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible"); + cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD)); + + // Just past the 24h scheduling lead: creatable now, and the export + // window (opens departure − lead) flips OPEN a couple of minutes later. + const local = new Date( + SHIPMENT_DAY.getTime() - SHIPMENT_DAY.getTimezoneOffset() * 60000, + ) + .toISOString() + .slice(0, 16); + cy.get('.mantine-Modal-content input[type="datetime-local"]') + .clear({ force: true }) + .type(local, { force: true }); + cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE)); + cy.get(".mantine-Modal-content").contains("button", "Create").click(); + cy.location("pathname", { timeout: 30000 }).should( + "match", + /\/dashboard\/operations\/train-scheduling-v2\/.+/, + ); + }); + + // The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes. + const waitForOpenWindow = (attempt: number) => { + dbUpcomingSchedule().then(({ rows }) => { + expect(rows, "upcoming export schedule").to.have.length(1); + if (rows[0].booking_window_status === "OPEN") return; + expect(attempt, "export booking window OPEN").to.be.lessThan(40); + cy.wait(10000).then(() => waitForOpenWindow(attempt + 1)); + }); + }; + waitForOpenWindow(0); + }); + + // ── Journey A: container 20ft + 40ft ────────────────────────────────────── + + it("customer submits an export container contract (20ft + 40ft)", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + cy.mantineSelect(/^Operation Type/, /^Export$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('[role="checkbox"][aria-label="40ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics"); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, PORT_YARD); + cy.contains("button", "Continue").click({ force: true }); + + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + expectContractStatus("CONTAINER", "SUBMITTED"); + }); + + it("staff approve the container contract (marketer + director)", () => { + approveChain("CONTAINER"); + }); + + it("customer signs the container contract with OTP", () => { + customerSigns("CONTAINER"); + }); + + it("staff counter-sign and operations finalize the container contract", () => { + counterSignAndFinalize("CONTAINER"); + }); + + it("customer books 2 × 20ft + 1 × 40ft with a shipment day", () => { + cy.loginPortal(customer); + withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + // Two size editors, each with its own "Quantity *" (20ft first, then 40ft). + fillNth(/^Quantity/, 0, "2"); + fillNth(/^Quantity/, 1, "1"); + + cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3); + cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0)); + cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", 1)); + cy.get('input[placeholder*="MSCU"]').eq(2).type(isoNumber("FSCU", 2)); + + cy.get('input[placeholder*="24.5"]').each(($input) => { + cy.wrap($input).clear({ force: true }).type("10", { force: true }); + }); + + pickShipmentDay(); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking("CONTAINER", (b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string"); + }); + }); + + it("operations accepts the container request — FCFS reserves today's train", () => { + acceptAndAssertReserved("CONTAINER"); + }); + + it("staff mark the container booking paid — allocated onto the train", () => { + markPaidAndAssertAllocated("CONTAINER"); + }); + + // ── Journey B: bulk (E2E Wheat) ─────────────────────────────────────────── + + it("customer submits an export bulk contract (wheat)", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + cy.mantineSelect(/^Operation Type/, /^Export$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/); + cy.mantineSelect(/^Bulk Cargo Type/, "E2E Grains"); + cy.mantineSelect(/^Commodity/, "E2E Wheat"); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, PORT_YARD); + cy.contains("button", "Continue").click({ force: true }); + + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + expectContractStatus("BULK", "SUBMITTED"); + }); + + it("staff approve the bulk contract (marketer + director)", () => { + approveChain("BULK"); + }); + + it("customer signs the bulk contract with OTP", () => { + customerSigns("BULK"); + }); + + it("staff counter-sign and operations finalize the bulk contract", () => { + counterSignAndFinalize("BULK"); + }); + + it("customer books 60 tons of wheat with a shipment day", () => { + cy.loginPortal(customer); + withContract("BULK", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + fill(/^Quantity \(tons\)/, "60"); + pickShipmentDay(); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking("BULK", (b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + }); + }); + + it("operations accepts the bulk request — FCFS reserves today's train", () => { + acceptAndAssertReserved("BULK"); + }); + + it("staff mark the bulk booking paid — allocated onto the train", () => { + markPaidAndAssertAllocated("BULK"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts new file mode 100644 index 000000000..b8ca2fd1b --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -0,0 +1,574 @@ +/** + * Intercity ONE_TIME journey — the full life of a domestic ride-along shipment: + * + * 1. portal — customer creates an INTERCITY / One-Time / Container contract + * (Mojo Dry Port → Dire Dawa Yard) and submits it + * 2. backoffice — marketer REJECTS it with a reason + * 3. portal — customer sees the rejection banner + reason, then submits a + * fresh contract + * 4. backoffice — marketer accepts + approves LINE_STAFF, director approves + * → CONTRACT_READY + * 5. portal — customer OTP-signs → SIGNED_CUSTOMER + * 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS + * (ONE_TIME intercity always routes through the + * intercity-documents step; the fixture seeds one REQUIRED + * document so the step is real) + * 6b. portal — customer uploads the required intercity document + * → CLEARANCE_UNDER_REVIEW + * 7. backoffice — operations approves the document, then finalizes document + * approval → FULLY_EXECUTED + * 8. portal — customer books 2 × 20ft under the contract (intercity has + * no shipment date) → booking OPERATION_REQUEST_PENDING + * 9. backoffice — operations accepts the operation request → booking + * FULLY_EXECUTED (intercity waiting pool) + * 10. backoffice — operations creates the EXPORT route + * Mojo → Dire Dawa → Djibouti Port (distances seeded) + * 11. backoffice — operations schedules the export train (built Train-Builder + * train seeded by seed-intercity.sql) + * 12. backoffice — operations accepts the intercity booking onto the train + * (Workspace → Intercity ride-along) → SELECTED_FOR_BATCH with + * a pay deadline that never outlives the export window close + * 13. staff mark-paid (API — the batch panel has no mounted UI button) + * → PAID + SCHEDULED + linked to the schedule + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql +const opsStaff = "operation@edr.local"; + +const ORIGIN_YARD = "Mojo Dry Port"; +const DEST_YARD = "Dire Dawa Yard"; +const PORT_YARD = "Djibouti Port Terminal"; +const TRAIN_CODE = "TRN-E2E-1"; + +const apiUrl = () => Cypress.env("apiUrl") as string; + +/** Latest contract of the seeded company — the journey's contract. */ +function dbContract() { + return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>( + "db:query", + { + sql: `SELECT ct.id, ct.reference, ct.status + FROM freight.contracts ct + JOIN freight.companies c ON c.id = ct.company_id + WHERE c.tin = $1 AND ct.trade_direction = 'DOMESTIC' + ORDER BY ct.created_at DESC LIMIT 1`, + params: [companyTin], + }, + ); +} + +function withContract(fn: (c: { id: string; reference: string; status: string }) => void) { + dbContract().then(({ rows }) => { + expect(rows, "latest contract for the seeded company").to.have.length(1); + fn(rows[0]); + }); +} + +function expectContractStatus(expected: string) { + dbContract().then(({ rows }) => { + expect(rows[0]?.status, "contract status").to.eq(expected); + }); +} + +/** Latest DOMESTIC booking of the seeded company — the journey's booking. */ +function dbBooking() { + return cy.task<{ + rows: Array<{ + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }>; + }>("db:query", { + sql: `SELECT b.id, b.reference, b.status, b.scheduling_status, + b.train_schedule_id, b.payment_deadline, b.scheduled_date + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + WHERE c.tin = $1 AND b.trade_direction = 'DOMESTIC' + ORDER BY b.created_at DESC LIMIT 1`, + params: [companyTin], + }); +} + +function withBooking( + fn: (b: { + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }) => void, +) { + dbBooking().then(({ rows }) => { + expect(rows, "intercity booking for the seeded company").to.have.length(1); + fn(rows[0]); + }); +} + +/** Latest export schedule created by this journey. */ +function dbSchedule() { + return cy.task<{ + rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>; + }>("db:query", { + sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at + FROM freight.train_schedules ts + ORDER BY ts.created_at DESC LIMIT 1`, + }); +} + +/** Fill a labelled Mantine input (label[for] → input id). */ +function fill(label: string | RegExp, value: string) { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** + * Run the portal wizard for an INTERCITY / One-Time / Container contract and + * submit it. Reused for the initial (to-be-rejected) and the second contract. + */ +function createIntercityContract() { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + // Step 0 — Setup. Intercity forces ETB and hides the customs section. + cy.mantineSelect(/^Operation Type/, /^Intercity$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click(); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + // Step 1 — Cargo & Route (Ethiopian yards only for intercity). + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type( + "E2E intercity electronics between Ethiopian yards", + ); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, DEST_YARD); + cy.contains("button", "Continue").click({ force: true }); + + // Step 2 — Review & Submit → quotation modal. + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + dbContract().then(({ rows }) => { + expect(rows, "contract row").to.have.length(1); + expect(rows[0].status).to.eq("SUBMITTED"); + expect(rows[0].reference).to.match(/^CTR-/); + }); +} + +describe("intercity one-time journey: contract → booking → export train", { retries: 0 }, () => { + before(() => { + // Container types, locomotives, built train, yard distances — the + // infrastructure the UI journey cannot create in-flow. + cy.task("db:seedFile", "seed-intercity.sql"); + }); + + // ── Contract: submit → reject → resubmit → approve → sign ──────────────── + + it("customer submits an intercity one-time contract", () => { + createIntercityContract(); + }); + + it("marketer rejects the submission with a reason", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Reject contract", { timeout: 20000 }).click(); + cy.get(".mantine-Modal-content") + .contains("label", "Reason for rejection") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).type("E2E rejection — cargo details incomplete"); + }); + cy.get(".mantine-Modal-content").contains("button", /^Reject$/).click(); + + // Modal closes on success; the status pill can sit inside clipped layout, + // so the authoritative check is the DB row. + cy.get(".mantine-Modal-content", { timeout: 20000 }).should("not.exist"); + expectContractStatus("REJECTED"); + }); + + it("customer sees the rejection reason on the contracts list", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts"); + + // The list is a collapsed table — expand the rejected contract's row to + // reveal its step banner with the staff reason. + withContract((c) => { + cy.contains("tr", c.reference, { timeout: 20000 }) + .find("button") + .first() + .click(); + }); + cy.contains("This contract was rejected.", { timeout: 20000 }).should("be.visible"); + cy.contains("Reason: E2E rejection — cargo details incomplete").should("be.visible"); + }); + + it("customer submits a fresh intercity contract", () => { + createIntercityContract(); + }); + + it("marketer accepts the submission and approves the LINE_STAFF step", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Accept for approval", { timeout: 20000 }).click(); + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + expectContractStatus("PENDING_APPROVAL"); + }); + + it("director approves the final step — contract PDF becomes ready", () => { + cy.loginBackoffice("director@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + expectContractStatus("CONTRACT_READY"); + }); + + it("customer signs the contract with OTP", () => { + cy.loginPortal(customer); + withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`)); + + // Scroll the contract iframe to the bottom so the consent bar unlocks. + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + const el = win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + + cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible"); + expectContractStatus("SIGNED_CUSTOMER"); + }); + + it("marketer counter-signs — intercity one-time enters the documents step", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`)); + + cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); + }); + cy.drawSignature(); + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); + + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + + // DOMESTIC one-time always routes through the intercity-documents step — + // unlike GENERAL, it does NOT go straight to CONTRACT_ACTIVE. + expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS"); + }); + + it("customer uploads the required intercity document", () => { + cy.loginPortal(customer); + // Deep link auto-opens the clearance documents modal. + withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`)); + + cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist"); + cy.get('.mantine-Modal-content input[type="file"]') + .first() + .selectFile("cypress/fixtures/docs/license.pdf", { force: true }); + cy.contains("button", "Submit documents", { timeout: 15000 }) + .should("not.be.disabled") + .click(); + + // Upload hands the contract to Operations review — the card flips to + // "Under review" (the host modal may linger while queries refetch). + cy.contains("Under review", { timeout: 30000 }).should("exist"); + expectContractStatus("CLEARANCE_UNDER_REVIEW"); + }); + + it("operations approves the document and finalizes — contract fully executed", () => { + cy.loginBackoffice(opsStaff); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + // The clearance review section lives behind its own tab on the detail page. + cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click(); + + // Approve the uploaded Cargo Manifest, then finalize. + cy.contains("button", /Approve all/, { timeout: 30000 }).click(); + cy.contains("1/1 approved", { timeout: 30000 }).should("exist"); + + cy.contains("button", "Finalize document approval", { timeout: 30000 }) + .should("not.be.disabled") + .click(); + + cy.contains("finalized", { timeout: 30000 }).should("be.visible"); + expectContractStatus("FULLY_EXECUTED"); + }); + + // ── Booking under the contract ──────────────────────────────────────────── + + it("customer books 2 × 20ft under the contract (no shipment date for intercity)", () => { + cy.loginPortal(customer); + withContract((c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + // 20ft quantities must be even (pairs share a wagon). + fill(/^Quantity/, "2"); + + // One ISO container number per unit. + cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2); + cy.get('input[placeholder*="MSCU"]').eq(0).type("MSCU1234567"); + cy.get('input[placeholder*="MSCU"]').eq(1).type("TCLU7654321"); + + // VGM per unit — column inputs carry a placeholder, not a linked label. + cy.get('input[placeholder*="24.5"]').each(($input) => { + cy.wrap($input).clear({ force: true }).type("10", { force: true }); + }); + + // Intercity: no "Shipment day" picker — the ride-along note renders instead. + cy.contains("Shipment day").should("not.exist"); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking((b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null); + }); + }); + + it("operations accepts the operation request — booking joins the intercity pool", () => { + cy.loginBackoffice(opsStaff); + withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`)); + + cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click(); + cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click(); + + withBooking((b) => { + expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED"); + expect(b.train_schedule_id).to.eq(null); + }); + }); + + // ── Route + export schedule ─────────────────────────────────────────────── + + it("operations creates the export route Mojo → Dire Dawa → Djibouti Port", () => { + cy.loginBackoffice(opsStaff); + + // Skip creation when a previous run already added the route (unique yards + // pair) — the journey stays re-runnable against a warm DB. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/routes"); + cy.contains("button", "Add route", { timeout: 20000 }).click(); + cy.contains("Add Route", { timeout: 15000 }).should("be.visible"); + + // Third stop row, then fill Origin / Milestone / Destination in order. + cy.get(".mantine-Modal-content").contains("button", "Add milestone").click(); + const pickYard = (index: number, yard: string) => { + cy.get('.mantine-Modal-content input[placeholder="Select yard"]') + .eq(index) + .click({ force: true }); + // Three yard selects share option texts — only the open dropdown counts. + cy.get('[role="option"]:visible').contains(yard).click(); + }; + pickYard(0, ORIGIN_YARD); + pickYard(1, DEST_YARD); + pickYard(2, PORT_YARD); + + // Distances (when the build has them) resolve from the seeded rows. + cy.get(".mantine-Modal-content").contains("button", "Save").click(); + }); + + cy.task<{ rows: Array<{ direction: string }> }>("db:query", { + sql: `SELECT r.direction + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + expect(rows, "export route").to.have.length.at.least(1); + expect(rows[0].direction).to.eq("EXPORT"); + }); + }); + + it("operations schedules the export train from the built consist", () => { + cy.loginBackoffice(opsStaff); + + // One departure per route per day — a warm DB from a previous run already + // has this train scheduled, so only create when none is live. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.train_schedules ts + JOIN freight.routes r ON r.id = ts.route_id + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + WHERE ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/operations/train-scheduling-v2"); + cy.contains("button", "New schedule", { timeout: 20000 }).click(); + cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible"); + + cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD)); + + // Two days out, local datetime-local format. + const departure = new Date(Date.now() + 2 * 86400000); + const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + cy.get('.mantine-Modal-content input[type="datetime-local"]') + .clear({ force: true }) + .type(local, { force: true }); + + cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE)); + cy.get(".mantine-Modal-content").contains("button", "Create").click(); + + // Create navigates straight to the new schedule's detail page. + cy.location("pathname", { timeout: 30000 }).should( + "match", + /\/dashboard\/operations\/train-scheduling-v2\/.+/, + ); + }); + + dbSchedule().then(({ rows }) => { + expect(rows, "created schedule").to.have.length(1); + expect(rows[0].direction).to.eq("EXPORT"); + }); + }); + + // ── Intercity ride-along: accept → pay → allocated ──────────────────────── + + it("operations accepts the intercity booking onto the export train", () => { + cy.loginBackoffice(opsStaff); + dbSchedule().then(({ rows: schedules }) => { + cy.visit(`/dashboard/operations/train-scheduling-v2/${schedules[0].id}`); + }); + + cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click(); + // Presence, not viewport visibility — the panel can sit below the fold / + // inside clipped layout once earlier runs' rows stack up. + cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist"); + + withBooking((b) => { + cy.contains("tr", b.reference, { timeout: 30000 }) + .find('input[type="checkbox"]') + .check({ force: true }); + cy.contains("button", /Accept .*onto this train/).click(); + + // Accepted table shows the pay-window state (inside a horizontal + // Table.ScrollContainer — assert presence, not viewport visibility). + cy.contains("Awaiting payment", { timeout: 30000 }).should("exist"); + }); + + // Export parity: the ride-along's pay deadline never outlives the export + // booking window (reserve() clamps it to window_closes_at). + dbSchedule().then(({ rows: schedules }) => { + withBooking((b) => { + expect(b.status).to.eq("SELECTED_FOR_BATCH"); + expect(b.train_schedule_id).to.eq(schedules[0].id); + expect(b.payment_deadline, "pay deadline set").to.be.a("string"); + expect(new Date(b.payment_deadline!).getTime()).to.be.at.most( + new Date(schedules[0].window_closes_at).getTime(), + ); + }); + }); + }); + + it("staff mark the ride-along paid — booking allocates onto the train", () => { + // ScheduleBatchPanel (the only "Mark paid" button) is not mounted in the + // current UI, so drive the staff override endpoint directly. + withBooking((b) => { + cy.apiLogin(opsStaff).then(({ token }) => { + cy.request({ + method: "POST", + url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + }); + + withBooking((b) => { + expect(b.status).to.eq("PAID"); + expect(b.scheduling_status).to.eq("SCHEDULED"); + expect(b.train_schedule_id, "still pinned to the export train").to.be.a("string"); + + // The schedule↔booking link row is what makes the booking visible on the + // train board, in yard work, and to the wagon planner. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND deleted_at IS NULL`, + params: [b.id], + }).then(({ rows }) => { + expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1); + }); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/fixtures/seed-export.sql b/e2e/freight/cypress/fixtures/seed-export.sql new file mode 100644 index 000000000..574f43f26 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-export.sql @@ -0,0 +1,85 @@ +-- Arrange-data for flows/export_one_time.cy.ts. Idempotent. +-- Run AFTER seed-intercity.sql (reuses its container types, locomotives, +-- built train TRN-E2E-1 and yard distances). +-- +-- 1. bulk cargo hierarchy: group "E2E Grains" → commodity "E2E Wheat", +-- carried on CW4 covered wagons +-- 2. two CW4 wagons coupled onto the train (bulk capacity) +-- 3. LIVE export rates for Mojo → Djibouti Port: container (per container) +-- and bulk (per ton) — booking pricing hard-blocks without them + +-- 0. Approved exporter profile — picking "Export" in the wizard opens the +-- "Set up your exporter profile" modal unless the company already has an +-- active exporter profile (seed-company.sql only creates the importer). +INSERT INTO freight.company_profiles (id, company_id, type, status, reference) +SELECT gen_random_uuid(), c.id, 'exporter', 'active', 'EXP-E2E-0001' +FROM freight.companies c +WHERE c.tin = '0102030405' + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles p + WHERE p.company_id = c.id AND p.type = 'exporter' + ); + +-- 1a. Cargo type group + commodity. +INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active) +SELECT gen_random_uuid(), 'E2E_GRAINS', 'E2E Grains', true +WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_GRAINS'); + +INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active) +SELECT gen_random_uuid(), 'E2E_WHEAT', 'E2E Wheat', g.id, true +FROM freight.cargo_types g +WHERE g.code = 'E2E_GRAINS' + AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_WHEAT'); + +-- 1b. Wheat rides CW4 covered wagons. +INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.cargo_types ct +JOIN freight.wagon_types wt ON wt.code = 'CW4' +WHERE ct.code IN ('E2E_WHEAT', 'E2E_GRAINS') + AND NOT EXISTS ( + SELECT 1 FROM freight.cargo_type_wagon_types x + WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id + ); + +-- 2. Couple two free CW4 wagons onto the train, parked at Mojo with it. +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = 100 + sub.rn, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO') +FROM freight.trains t, + LATERAL ( + SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn + FROM freight.wagons w2 + JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4' + WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL + ORDER BY w2.wagon_number + LIMIT 2 + ) sub +WHERE t.code = 'TRN-E2E-1' + AND w.id = sub.id + AND NOT EXISTS ( + SELECT 1 FROM freight.wagons wx + JOIN freight.wagon_types wxt ON wxt.id = wx.wagon_type_id AND wxt.code = 'CW4' + WHERE wx.train_id = t.id + ); + +-- 3. LIVE export rates Mojo → Djibouti Port. +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value, + v.unit, 'LIVE', a.id, b.id, u.id +FROM (VALUES + ('CONTAINER_EXPORT', 'CONTAINER', 600, 'PER_CONTAINER'), + ('BULK_EXPORT', 'BULK', 25, 'PER_TON') + ) AS v(rate_type, applies_to, value, unit) +JOIN freight.yards a ON a.code = 'MOJO' +JOIN freight.yards b ON b.code = 'DJIB_PORT' +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = v.rate_type + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL +); diff --git a/e2e/freight/cypress/fixtures/seed-intercity.sql b/e2e/freight/cypress/fixtures/seed-intercity.sql new file mode 100644 index 000000000..732624ff4 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-intercity.sql @@ -0,0 +1,131 @@ +-- Arrange-data for flows/intercity_one_time.cy.ts. Idempotent. +-- +-- The e2e DB boots with yards + a wagon fleet only: no container types, no +-- locomotives, no Train-Builder train, no yard distances, no routes. The spec +-- drives route + schedule creation through the UI; this fixture provides only +-- the infrastructure the UI journey cannot reasonably create in-flow: +-- +-- 1. container types (booking form resolves 20ft/40ft by size_ft) +-- 2. container-type → wagon-type allow-list (wagon planner) +-- 3. two locomotives (a schedulable train needs >= 2) +-- 4. a built Train-Builder train at Mojo with four NW5 flat wagons +-- 5. yard distances for Mojo–Dire Dawa–Djibouti Port (route creation +-- refuses unconfigured pairs) + +-- 1. Container types. +INSERT INTO freight.container_types (id, code, label, size_ft, is_active) +SELECT gen_random_uuid(), v.code, v.label, v.size_ft, true +FROM (VALUES ('20FT', '20FT', 20), ('40FT', '40FT', 40)) AS v(code, label, size_ft) +WHERE NOT EXISTS (SELECT 1 FROM freight.container_types t WHERE t.code = v.code); + +-- 2. 20ft/40ft containers ride NW5 flat wagons. +INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.container_types ct +JOIN freight.wagon_types wt ON wt.code = 'NW5' +WHERE ct.code IN ('20FT', '40FT') + AND NOT EXISTS ( + SELECT 1 FROM freight.container_type_wagon_types x + WHERE x.container_type_id = ct.id AND x.wagon_type_id = wt.id + ); + +-- 3. Two locomotives at Mojo (status defaults to AVAILABLE). +INSERT INTO freight.locomotives + (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id) +SELECT gen_random_uuid(), v.code, 4000, 760, y.id +FROM (VALUES ('LOCO-E2E-1'), ('LOCO-E2E-2')) AS v(code) +JOIN freight.yards y ON y.code = 'MOJO' +WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); + +-- 4a. Built train at Mojo (status defaults to AVAILABLE). +INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id) +SELECT gen_random_uuid(), 'TRN-E2E-1', 'E2E Export Carrier', 2000, y.id +FROM freight.yards y +WHERE y.code = 'MOJO' + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-E2E-1'); + +-- 4b. Couple both locomotives (available-trains filter requires >= 2). +INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no) +SELECT gen_random_uuid(), t.id, l.id, + row_number() OVER (ORDER BY l.code) - 1 +FROM freight.trains t +JOIN freight.locomotives l ON l.code IN ('LOCO-E2E-1', 'LOCO-E2E-2') +WHERE t.code = 'TRN-E2E-1' + AND NOT EXISTS ( + SELECT 1 FROM freight.train_locomotives tl + WHERE tl.train_id = t.id AND tl.locomotive_id = l.id + ); + +-- 4c. Couple four free NW5 flat wagons onto the train and park them at Mojo +-- with it (the seeded fleet sits at Doraleh; the planner reads the consist by +-- train_id, the yard only matters for warnings). +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = sub.rn, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO') +FROM freight.trains t, + LATERAL ( + SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn + FROM freight.wagons w2 + JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5' + WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL + ORDER BY w2.wagon_number + LIMIT 4 + ) sub +WHERE t.code = 'TRN-E2E-1' + AND w.id = sub.id + AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id); + +-- 4d. One REQUIRED intercity clearance document, so the journey exercises the +-- real customer-upload → ops-review → finalize step (the seeder leaves the +-- intercity_documents setting empty). +INSERT INTO freight.file_upload_fields + (id, setting_id, file_key, file_label, is_required, is_multiple, max_files, + allowed_extensions, max_size_mb, display_order) +SELECT gen_random_uuid(), s.id, 'cargo_manifest', 'Cargo Manifest', true, false, 1, + '{pdf,jpg,jpeg,png}'::text[], 10, 1 +FROM freight.file_upload_settings s +WHERE s.code = 'intercity_documents' + AND NOT EXISTS ( + SELECT 1 FROM freight.file_upload_fields f + WHERE f.setting_id = s.id AND f.file_key = 'cargo_manifest' AND f.deleted_at IS NULL + ); + +-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing +-- hard-blocks any container line without a rate on its exact leg; rates are +-- configured in USD and converted to the booking currency). +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), 'INTERCITY_CONTAINER', 'INTERCITY', 'ALWAYS', 'USD', 500, + 'PER_CONTAINER', 'LIVE', a.id, b.id, u.id +FROM freight.yards a +JOIN freight.yards b ON b.code = 'DIRE_DAWA' +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE a.code = 'MOJO' + AND NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = 'INTERCITY_CONTAINER' + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL + ); + +-- 6. Segment distances (symmetric — one row covers both directions). Guarded: +-- an e2e image built from a branch that predates the yard_distances feature +-- has no table, and its route form doesn't require distances either. +DO $$ +BEGIN + IF to_regclass('freight.yard_distances') IS NOT NULL THEN + INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km) + SELECT gen_random_uuid(), a.id, b.id, v.km + FROM (VALUES ('MOJO', 'DIRE_DAWA', 300), ('DIRE_DAWA', 'DJIB_PORT', 450)) + AS v(from_code, to_code, km) + JOIN freight.yards a ON a.code = v.from_code + JOIN freight.yards b ON b.code = v.to_code + WHERE NOT EXISTS ( + SELECT 1 FROM freight.yard_distances d + WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id) + OR (d.from_yard_id = b.id AND d.to_yard_id = a.id) + ); + END IF; +END $$; diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts index 6a958d5be..2d18350a0 100644 --- a/e2e/freight/cypress/support/commands.ts +++ b/e2e/freight/cypress/support/commands.ts @@ -110,7 +110,10 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string | .then((id) => { cy.get(`[id="${id}"]`).click({ force: true }); }); - cy.get('[role="option"]').contains(option).click(); + // :visible — closed dropdowns can linger in the DOM, and two selects on one + // page may list the same option text (e.g. the intercity wizard's origin + + // destination both list every Ethiopian yard). + cy.get('[role="option"]:visible').contains(option).click(); }); /** Type a 6-digit code into a Mantine PinInput. */