diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts index b8a6f8afb..58b2d733a 100644 --- a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -1,20 +1,33 @@ -import type { ScheduleTradeDirection } from '@edr/types'; +import { YardCountry, type ScheduleTradeDirection } from '@edr/types'; type YardLike = { country?: string | null }; -/** Derive booking/schedule trade direction from origin and destination yard countries. */ +/** + * Derive trade direction from origin and destination yard countries. + * Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country = + * DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now). + * Comparison is strict against the YardCountry enum values the yards table is + * constrained to; the trim/case fold only shields legacy rows. + */ export function deriveTradeDirection( originYard: YardLike, destinationYard: YardLike, ): ScheduleTradeDirection { - const originCountry = originYard.country?.trim().toLowerCase(); - const destinationCountry = destinationYard.country?.trim().toLowerCase(); + const origin = normalizeCountry(originYard.country); + const destination = normalizeCountry(destinationYard.country); - if (originCountry === 'djibouti') { + if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) { return 'IMPORT'; } - if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') { + if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) { return 'EXPORT'; } return 'DOMESTIC'; } + +function normalizeCountry(country: string | null | undefined): YardCountry | null { + const folded = country?.trim().toLowerCase(); + if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA; + if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI; + return null; +} diff --git a/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts new file mode 100644 index 000000000..a47ff6297 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Structured import handover records. Replaces the ad-hoc handover notes so a + * booking can carry one handover (single truck) or several (one per truck when + * multiple trucks are used). Timing differs by mile type: + * - SELF_HAUL: generated on first truck arrival, signed before the truck leaves. + * - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery. + */ +export class AddBookingHandovers1980000000000 implements MigrationInterface { + name = 'AddBookingHandovers1980000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_handovers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL, + truck_plate varchar(32), + mile_type varchar(20) NOT NULL, + reference varchar(100) NOT NULL, + generated_at timestamptz NOT NULL DEFAULT now(), + signed_at timestamptz, + signed_by_user_id uuid, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`, + ); + // At most one live handover per (booking, customer truck). EDR trucks (which + // aren't customer_truck_assignments) and per-booking handovers are de-duped + // in the service, since a NULL truck_assignment_id can't be uniquely indexed. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck" + ON freight.booking_handovers (booking_id, truck_assignment_id) + WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts b/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts new file mode 100644 index 000000000..3274b4d1c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route + * freezes its trade direction from the yard countries: + * Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, + * same country = DOMESTIC (shown as "Intercity"; disabled for scheduling + * and contracts for now). + * + * Existing yard rows are normalized case-insensitively; anything mentioning + * Djibouti maps there, everything else maps to Ethiopia (the line only serves + * these two countries). A CHECK constraint keeps future writes honest. + */ +export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface { + name = 'YardCountryEnumAndRouteDirection1980000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.yards + SET country = CASE + WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti' + ELSE 'Ethiopia' + END + `); + await queryRunner.query(` + ALTER TABLE freight.yards + DROP CONSTRAINT IF EXISTS chk_yards_country, + ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti')) + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + await queryRunner.query(` + UPDATE freight.routes r + SET direction = CASE + WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT' + WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT' + ELSE 'DOMESTIC' + END + FROM freight.yards o, freight.yards d + WHERE o.id = r.origin_yard_id + AND d.id = r.destination_yard_id + `); + // Orphan origin/destination (deleted yard) — no way to classify; park as + // DOMESTIC, which is blocked everywhere, so nothing can schedule on it. + await queryRunner.query(` + UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.routes + ALTER COLUMN direction SET NOT NULL, + DROP CONSTRAINT IF EXISTS chk_routes_direction, + ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC')) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.routes + DROP CONSTRAINT IF EXISTS chk_routes_direction, + DROP COLUMN IF EXISTS direction + `); + await queryRunner.query(` + ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country + `); + } +} 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 106b7ed5b..eda249868 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -64,6 +64,7 @@ import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; @@ -358,6 +359,33 @@ export class BookingsController { return this.customerTruckService.removeTruck(id, assignmentId); } + @Get(':id/customer-trucks/loadable-containers') + @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) + async loadableContainers( + @Param('id', ParseUUIDPipe) id: string, + @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.getLoadableContainers(id); + } + + @Post(':id/customer-trucks/:assignmentId/load') + @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) + async loadCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: LoadCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can load a truck'); + } + return this.customerTruckService.loadTruck(id, assignmentId, dto); + } + @Post(':id/customer-trucks/:assignmentId/depart') @ApiOperation({ summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 7a6169b7b..d4791baf7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -176,6 +176,44 @@ export class BookingsService { } /** Resolve trade direction from yard countries; reject client mismatch. */ + /** + * An intercity corridor is valid when both yards are Ethiopian and at least + * one non-retired route passes the origin strictly before the destination in + * its milestone order — that is the corridor an import/export train can + * serve the booking on. + */ + private async assertIntercityCorridorExists( + originYardId: string, + destinationYardId: string, + ): Promise { + const yards = await this.dataSource.getRepository(Yard).find({ + where: { id: In([originYardId, destinationYardId]) }, + }); + if (yards.some((y) => y.country !== 'Ethiopia')) { + throw new BadRequestException( + 'Intercity bookings only run between Ethiopian yards', + ); + } + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT r.id + FROM freight.routes r + JOIN freight.route_milestones mo + ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL + JOIN freight.route_milestones md + ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL + WHERE mo.sequence_no < md.sequence_no + AND r.status = 'AVAILABLE' + AND r.deleted_at IS NULL + LIMIT 1`, + [originYardId, destinationYardId], + ); + if (rows.length === 0) { + throw new BadRequestException( + 'No route passes through this origin and destination in order — intercity service is not available on this corridor', + ); + } + } + private async resolveTradeDirectionForBooking( originYardId: string, destinationYardId: string, @@ -607,6 +645,23 @@ export class BookingsService { dto.tradeDirection, ); + // Intercity (DOMESTIC) bookings never get their own train — they ride on a + // passing import/export train, so there is no booking window and no date to + // pin. All we require at creation is that the corridor actually lies on a + // route (origin before destination in some route's milestone order); staff + // accept the booking onto a concrete train at finalize time. + if (tradeDirection === 'DOMESTIC') { + if (dto.scheduledDate || dto.trainScheduleId) { + throw new BadRequestException( + 'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later', + ); + } + await this.assertIntercityCorridorExists( + dto.originYardId, + dto.destinationYardId, + ); + } + // Stamp the operational profile this booking belongs to (importer/exporter) // so the customer portal can scope lists/KPIs to the active mode. Best-effort // for non-government bookings with a resolved company; never blocks creation. diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts index fde3ab797..fea73603a 100644 --- a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -38,7 +38,7 @@ export class ContainerReceiptService { SET received_to_port = true, received_at = COALESCE(bcu.received_at, NOW()), updated_at = NOW() - FROM freight.booking_containers bc, + FROM freight.booking_container bc, freight.customer_truck_containers ctc WHERE bc.id = bcu.booking_container_id AND bc.booking_id = $1 @@ -60,7 +60,7 @@ export class ContainerReceiptService { bcu.received_at AS "receivedAt", bcu.grn_number AS "grnNumber" FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL @@ -92,7 +92,7 @@ export class ContainerReceiptService { const pending: ReceivedUnitRow[] = await manager.query( `SELECT bcu.id, bcu.container_number AS "containerNumber" FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL @@ -109,7 +109,7 @@ export class ContainerReceiptService { const [{ batches }]: Array<{ batches: string }> = await manager.query( `SELECT COUNT(DISTINCT bcu.grn_number) AS batches FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, [bookingId], @@ -129,7 +129,7 @@ export class ContainerReceiptService { const [{ remaining }]: Array<{ remaining: string }> = await manager.query( `SELECT COUNT(*) AS remaining FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, [bookingId], 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 5d0650219..6f0ec6f55 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 @@ -198,6 +198,87 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** Booking container numbers not yet loaded onto any truck. */ + async getLoadableContainers(bookingId: string): Promise { + const [all, assigned] = await Promise.all([ + this.bookingContainerNumbers(bookingId), + this.assignedContainerNumbers(bookingId), + ]); + const taken = new Set(assigned); + return all.filter((n) => !taken.has(n)); + } + + /** + * Truck_dispatch (load): assign the selected containers to a truck after it has + * arrived, and set a provisional gross weight from their VGM. The truck is + * weighed for real on departure. Locked once the truck has left. + */ + async loadTruck( + bookingId: string, + assignmentId: string, + dto: { containerNumbers: string[] }, + ): Promise { + await this.loadBookingGuard(bookingId); + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.departedAt) { + throw new ConflictException('This truck has already left — its load is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!requested.length) { + throw new BadRequestException('Select at least one container to load onto the truck'); + } + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + + const grossKg = await this.vgmKgForContainers(bookingId, requested); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + // Provisional gross from the loaded containers' VGM — overridden by the + // weighed gross on departure. + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: grossKg, + }); + }); + return this.listTrucks(bookingId); + } + + private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise { + const [row]: Array<{ kg: string }> = await this.dataSource.query( + `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.container_number = ANY($2::varchar[]) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return Number(row?.kg ?? 0); + } + /** * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * receive flow. When every truck on the booking has arrived, the booking-level @@ -288,7 +369,7 @@ export class CustomerTruckService { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber" FROM freight.booking_container_units bcu - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, [bookingId], diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts new file mode 100644 index 000000000..11c80f687 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; + +/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ +export class LoadCustomerTruckDto { + @IsArray() + @ArrayMinSize(1) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 873daaf15..5e80b1301 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -119,12 +119,27 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Intercity (DOMESTIC) bookings ride on a passing import/export train: + // there is no window and no date — staff accept them onto a train at + // finalize time, so both the window gate and scheduledDate are skipped. + const isIntercity = contract.tradeDirection === 'DOMESTIC'; + if (isIntercity && dto.scheduledDate) { + throw new BadRequestException( + 'Intercity bookings do not pick a date — staff assign them to a passing train', + ); + } + // Every other direction keeps the binding shipment day (the DTO field went + // optional only for intercity). + if (!isIntercity && !dto.scheduledDate) { + throw new BadRequestException('A binding shipment day is required'); + } + // Booking-window gate (config-driven): an operations booking may only be // created while the route's booking window is open — import: the day's window // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); // export: within exportBookingLeadHours of departure. Customs Path B bookings // enter clearance first and are scheduled later, so they are not gated here. - if (!generalCustoms) { + if (!generalCustoms && !isIntercity) { await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 73360f7f5..0e81f8dca 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { insertWithGeneratedReference } from '@edr/api-common'; +import { YardCountry } from '@edr/types'; + +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContractsRepository } from './contracts.repository'; @@ -110,6 +114,49 @@ export class ContractsService { } } + /** + * Every route must match the contract's declared trade direction as derived + * from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC = + * intercity). Intercity is Ethiopian-domestic only: both yards must be in + * Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches + * (e.g. an export lane on an import contract) are rejected for every kind. + */ + private async assertRoutesMatchDirection( + tradeDirection: string, + routes: CreateContractDto['routes'], + ): Promise { + const yardIds = [ + ...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])), + ]; + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: yardIds.map((id) => ({ id })) }); + const yardById = new Map(yards.map((y) => [y.id, y])); + + for (const route of routes) { + const origin = yardById.get(route.originYardId); + const destination = yardById.get(route.destinationYardId); + if (!origin || !destination) { + throw new BadRequestException('Route references a yard that does not exist'); + } + const derived = deriveTradeDirection(origin, destination); + if (derived !== tradeDirection) { + throw new BadRequestException( + `Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`, + ); + } + if ( + derived === 'DOMESTIC' && + (origin.country !== YardCountry.ETHIOPIA || + destination.country !== YardCountry.ETHIOPIA) + ) { + throw new BadRequestException( + `Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`, + ); + } + } + } + /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ async create( dto: CreateContractDto, @@ -144,6 +191,7 @@ export class ContractsService { this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); + await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); // Stamp the operational profile (importer/exporter) for portal scoping. let companyProfileId: string | null = null; @@ -175,6 +223,13 @@ export class ContractsService { // Customs clearing is owned by the service type, not the customer. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); + // Intercity never crosses a border, so a customs-including service type is + // a contradiction — the wizard hides them, the API enforces it. + if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) { + throw new BadRequestException( + 'Intercity contracts cannot use a service type that includes customs clearing', + ); + } // An explicit reference is caller-chosen — a collision there is a real // conflict and should surface. Auto-generated references retry past a @@ -360,6 +415,12 @@ export class ContractsService { if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); if (dto.routes) this.assertRouteShape(contractKind, dto.routes); + if (dto.routes) { + await this.assertRoutesMatchDirection( + dto.tradeDirection ?? existing.tradeDirection, + dto.routes, + ); + } const updates: Record = { contractKind, @@ -385,6 +446,11 @@ export class ContractsService { const includesCustoms = await this.resolveIncludesCustoms( dto.serviceTypeId ?? existing.serviceTypeId, ); + if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) { + throw new BadRequestException( + 'Intercity contracts cannot use a service type that includes customs clearing', + ); + } updates.customsClearingEnabled = includesCustoms; updates.customsClearingAgent = includesCustoms ? null diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index ac404c9df..e3130da95 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto { @IsUUID() contractRouteId?: string; - @ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' }) + @ApiPropertyOptional({ + description: + 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.', + example: '2026-07-15', + }) + @IsOptional() @IsDateString() - scheduledDate!: string; + scheduledDate?: string; @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index 9c25778dc..a79a54503 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import type { ScheduleTradeDirection } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; @@ -26,6 +27,14 @@ export class Route extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' }) status!: RouteStatus; + /** + * Trade direction frozen from the yard countries at create/update + * (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity"). + * Consumers (scheduling, booking windows) read this instead of re-deriving. + */ + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction!: ScheduleTradeDirection; + @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) milestones?: RouteMilestone[]; } diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 937ffb0ae..34d007c63 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; @@ -83,6 +84,7 @@ export class RoutesService { originYardId: validated.originYardId, destinationYardId: validated.destinationYardId, status: dto.status ?? 'AVAILABLE', + direction: validated.direction, }), ); @@ -115,6 +117,7 @@ export class RoutesService { originYardId: milestoneInput?.originYardId ?? existing.originYardId, destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, + ...(milestoneInput ? { direction: milestoneInput.direction } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), }); @@ -187,9 +190,18 @@ export class RoutesService { throw new BadRequestException('Origin and destination yards must be different'); } + const originYardId = normalized[0].yardId; + const destinationYardId = normalized[normalized.length - 1].yardId; + const yardById = new Map(yards.map((yard) => [yard.id, yard])); + const direction = deriveTradeDirection( + yardById.get(originYardId) ?? { country: null }, + yardById.get(destinationYardId) ?? { country: null }, + ); + return { - originYardId: normalized[0].yardId, - destinationYardId: normalized[normalized.length - 1].yardId, + originYardId, + destinationYardId, + direction, milestones: normalized, }; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 38f2bc58b..53583e3e8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -1,5 +1,6 @@ +import { YardCountry } from '@edr/types'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator'; export class CreateYardDto { @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) @@ -7,10 +8,9 @@ export class CreateYardDto { @MaxLength(100) label!: string; - @ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 }) - @IsString() - @MaxLength(50) - country!: string; + @ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' }) + @IsEnum(YardCountry) + country!: YardCountry; @ApiPropertyOptional({ default: true }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 249aa1847..3f7f1ae97 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { YardCountry } from '@edr/types'; import { Column, Entity, Index } from 'typeorm'; @Entity({ schema: 'freight', name: 'yards' }) @@ -12,8 +13,11 @@ export class Yard extends BaseEntity { @Column({ name: 'label', type: 'varchar', length: 100 }) label!: string; + // Constrained to YardCountry by DTO validation + a DB CHECK constraint; + // route/schedule trade direction is derived from this value. Typed as the + // enum's literal values so plain strings from seeds/queries still fit. @Column({ name: 'country', type: 'varchar', length: 50 }) - country!: string; + country!: `${YardCountry}`; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index dfbcfcb6d..0fdacc572 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -282,15 +282,33 @@ export function computeImportWindowTimes( return { windowOpensAt: opensAt, windowClosesAt: closesAt }; } -/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ +/** + * Export booking window: a single FCFS window from `exportBookingLeadHours` + * before departure until departure. The open honours the daily desk hours — + * when the raw lead instant lands while the desk is shut, the window opens at + * the next desk opening instead (capped at departure, so a config whose desk + * never opens before the train leaves yields a zero-length window rather than + * one that outlives the train). + */ export function computeExportWindowTimes( departure: Date, - cfg: { exportBookingLeadHours: number }, + cfg: { + exportBookingLeadHours: number; + windowOpenHour: number; + windowCloseHour: number; + }, ): InitialWindowTimes { - return { - windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), - windowClosesAt: departure, - }; + const rawOpen = new Date( + departure.getTime() - cfg.exportBookingLeadHours * 3_600_000, + ); + let opensAt = officeHoursOpen(rawOpen, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); + if (opensAt.getTime() > departure.getTime()) { + opensAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: departure }; } /** @@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow { * after each close, on the same booking day, until departure. This mirrors * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the * exact windows the engine runs. - * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure. + * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure, + * with the open shifted to the next desk opening when it lands outside office hours + * (same math as `computeExportWindowTimes`). * * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored * `windowOpensAt` instead of recomputing it from config. Pass it so the board @@ -436,8 +456,7 @@ export function listConfigBookingWindows( ): BoardWindow[] { if (direction === 'EXPORT') { const start = - anchorOpensAt ?? - new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); + anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt; return [boardWindowFromInterval(start, departure)]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index e6af6c4a0..2d243c819 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -43,7 +43,7 @@ import { BookingSplitService } from './booking-split.service'; import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; /** A train's remaining capacity along the three physical limits the batch enforces. */ -interface Capacity { +export interface Capacity { wagons: number; weightTons: number; lengthMeters: number; @@ -1444,6 +1444,48 @@ export class BookingBatchService implements OnModuleInit { await this.fillSchedule(booking.trainScheduleId); } + // ---- intercity ride-along API --------------------------------------------- + + /** + * Remaining capacity budget (wagons / weight / length) for a schedule, and + * the per-booking need calculator — exposed for the intercity accept flow, + * which reserves ride-along bookings onto import/export trains outside the + * batch engine. + */ + async intercityCapacity(scheduleId: string): Promise<{ + budget: Capacity; + needFor: (booking: Booking) => Capacity; + } | null> { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) return null; + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const limits = await this.capacityLimits(locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; + } + + /** + * Accept an intercity booking onto the given train. Commercial bookings get + * the same pay-window lifecycle as a batch reservation (deadline, invoice + * due-date sync, pay-now notify, settle on the window tick), so payment → + * allocation needs no special path. Government bookings allocate directly. + */ + async acceptIntercity(booking: Booking, scheduleId: string): Promise { + if (booking.isGovernment) { + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: scheduleId }); + booking.trainScheduleId = scheduleId; + await this.allocate(scheduleId, booking, 'gov'); + return; + } + await this.reserve(booking, scheduleId); + this.armSettle(scheduleId); + } + // ---- mutations ------------------------------------------------------------ /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts new file mode 100644 index 000000000..d1c1d34a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -0,0 +1,77 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from '@edr/types'; +import { Logger } from '@nestjs/common'; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Server → client push for booking-window state changes. Same handshake model + * as the notifications gateway: clients only listen, the token is verified on + * connect. Events are broadcast namespace-wide — window state is route-scoped + * public information for signed-in users, and clients filter/invalidate their + * own queries. + */ +@WebSocketGateway({ + namespace: BOOKING_WINDOW_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class BookingWindowGateway implements OnGatewayConnection { + private readonly logger = new Logger(BookingWindowGateway.name); + + @WebSocketServer() + private readonly server!: Server; + + constructor(private readonly wsAuth: WsAuthService) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + if (!userId) { + this.logger.debug(`Rejected booking-window handshake ${socket.id}`); + socket.disconnect(true); + return; + } + socket.data.userId = userId; + } + + /** Push a schedule's current window state to every connected client. */ + emitPhase(schedule: TrainSchedule): void { + const payload: BookingWindowPhaseEvent = { + scheduleId: schedule.id, + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + direction: schedule.direction ?? null, + phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'], + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + bookingCycleNo: schedule.bookingCycleNo, + windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null, + windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null, + docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + }; + this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === 'string') return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith('Bearer ')) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 449c67219..12a78047c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -9,6 +9,7 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { BookingBatchService } from './booking-batch.service'; +import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; @@ -40,6 +41,7 @@ export class BookingWindowService implements OnModuleInit { private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, private readonly notifications: NotificationsService, + private readonly gateway: BookingWindowGateway, ) {} async onModuleInit(): Promise { @@ -48,7 +50,10 @@ export class BookingWindowService implements OnModuleInit { ); } - @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + // 10-second cadence: every transition is derived from persisted timestamps + // and applied idempotently, so a finer tick only shrinks the lag between a + // deadline passing and the phase actually moving (was a full minute). + @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) async tick(): Promise { if (this.ticking) return; this.ticking = true; @@ -89,9 +94,10 @@ export class BookingWindowService implements OnModuleInit { await this.settleOverdueReservations(); - // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. + // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes + // (30 ticks at the 10-second cadence). this.tickCount += 1; - if (this.tickCount % 5 === 0) { + if (this.tickCount % 30 === 0) { await this.bookingBatchService.runBatchFill(); } } finally { @@ -151,6 +157,9 @@ export class BookingWindowService implements OnModuleInit { ? await this.advanceExport(schedule, now) : await this.advanceImport(schedule, cfg, now); if (!advanced) return; + // Push the new window state to portal home / backoffice GL sections so + // they refresh instantly instead of waiting out their poll interval. + this.gateway.emitPhase(schedule); } } @@ -169,7 +178,9 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } - await this.notifyWindowOpened(schedule); + // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop + // (the `ticking` guard would otherwise delay every schedule's transition). + void this.notifyWindowOpened(schedule); this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } @@ -216,7 +227,8 @@ export class BookingWindowService implements OnModuleInit { schedule.bookingWindowStatus = 'OPEN'; } // Only announce the first opening of the day; reopen cycles don't re-notify. - if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule); + // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. + if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); this.logger.log( `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts new file mode 100644 index 000000000..bf1cec26d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class AcceptIntercityBookingsDto { + @ApiProperty({ + type: [String], + description: + 'Waiting intercity booking ids to accept onto this train, in priority order', + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + bookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts index 9232de29e..a0371178f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts @@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto { @IsInt() @Min(0) importWindowLeadDays?: number; + + @ApiPropertyOptional({ + example: 24, + description: + 'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts new file mode 100644 index 000000000..659eea456 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -0,0 +1,367 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { RouteMilestone } from '../routes/entities/route-milestone.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { BookingBatchService, type Capacity } from './booking-batch.service'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +/** + * Intercity (DOMESTIC) ride-along: intercity bookings never get their own + * train — they ride a passing import/export schedule whose route milestones + * contain the booking's origin strictly before its destination. + * + * Flow: the customer books a corridor with no date; at finalize time staff see + * every waiting intercity booking whose corridor lies on the schedule's route, + * with its wagon/weight/length need against the train's remaining capacity; + * accepting reserves it (pay window → payment → allocation, same lifecycle as + * a batch reservation). Cargo is loaded manually when the train reaches the + * booking's origin yard and unloaded at its destination yard. + */ +@Injectable() +export class IntercityService { + private readonly logger = new Logger(IntercityService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly bookingBatchService: BookingBatchService, + ) {} + + /** + * Waiting intercity bookings this schedule could carry, with the train's + * remaining capacity along all three axes (wagons, weight, length) and each + * booking's need, so staff can pick what fits. + */ + async listCandidates(scheduleId: string) { + const schedule = await this.getSchedule(scheduleId); + const milestoneSeq = await this.routeMilestoneSequence(schedule); + const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); + + const waiting = milestoneSeq + ? await this.findWaitingIntercityBookings(milestoneSeq) + : []; + const accepted = await this.findAcceptedIntercityBookings(scheduleId); + + return { + scheduleId, + routeId: schedule.routeId ?? null, + remaining: capacity?.budget ?? null, + candidates: waiting.map((booking) => { + const need = capacity?.needFor(booking) ?? null; + return { + ...this.mapBooking(booking), + need, + fits: need && capacity ? fits(need, capacity.budget) : false, + }; + }), + accepted: accepted.map((booking) => ({ + ...this.mapBooking(booking), + need: capacity?.needFor(booking) ?? null, + })), + }; + } + + /** + * Accept selected waiting intercity bookings onto this train, in the given + * order, each re-checked against the shrinking capacity budget. Commercial + * bookings open a pay window (payment → allocation runs on the existing + * settle lifecycle); government bookings allocate immediately. + */ + async acceptBookings(scheduleId: string, bookingIds: string[]) { + if (bookingIds.length === 0) { + throw new BadRequestException('Select at least one intercity booking'); + } + const schedule = await this.getSchedule(scheduleId); + const milestoneSeq = await this.routeMilestoneSequence(schedule); + if (!milestoneSeq) { + throw new BadRequestException( + 'Schedule has no route milestones — cannot serve intercity corridors', + ); + } + const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); + if (!capacity) { + throw new BadRequestException( + 'Schedule has no locomotive/train set — capacity unknown', + ); + } + + const accepted: string[] = []; + const rejected: Array<{ bookingId: string; reason: string }> = []; + let budget = capacity.budget; + + for (const bookingId of bookingIds) { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId }, relations: { bookingContainers: true } }); + if (!booking) { + rejected.push({ bookingId, reason: 'Booking not found' }); + continue; + } + const notWaiting = this.whyNotWaiting(booking, milestoneSeq); + if (notWaiting) { + rejected.push({ bookingId, reason: notWaiting }); + continue; + } + const need = capacity.needFor(booking); + if (!fits(need, budget)) { + rejected.push({ + bookingId, + reason: 'Does not fit the remaining wagon/weight/length capacity', + }); + continue; + } + await this.bookingBatchService.acceptIntercity(booking, scheduleId); + budget = subtract(budget, need); + accepted.push(bookingId); + this.logger.log( + `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`, + ); + } + + return { accepted, rejected, remaining: budget }; + } + + /** + * Mark an accepted intercity booking's cargo as loaded. Only allowed while + * the train is physically at the booking's origin yard: either it has not + * departed yet and the booking boards at the train's own origin, or the + * latest recorded checkpoint is at the booking's origin yard. + */ + async loadBooking(scheduleId: string, bookingId: string) { + const { schedule, booking } = await this.getAcceptedBooking( + scheduleId, + bookingId, + ); + if (booking.status !== 'PAID') { + throw new BadRequestException( + `Booking must be paid before loading (currently ${booking.status})`, + ); + } + await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + await this.dataSource + .getRepository(Booking) + .update(bookingId, { status: 'IN_TRANSIT' }); + return { bookingId, status: 'IN_TRANSIT' as const }; + } + + /** + * Mark an intercity booking's cargo as unloaded at its destination yard — + * requires the latest checkpoint to be at that yard. Completes the booking. + */ + async unloadBooking(scheduleId: string, bookingId: string) { + const { schedule, booking } = await this.getAcceptedBooking( + scheduleId, + bookingId, + ); + if (booking.status !== 'IN_TRANSIT') { + throw new BadRequestException( + `Booking must be loaded/in transit before unloading (currently ${booking.status})`, + ); + } + await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + await this.dataSource + .getRepository(Booking) + .update(bookingId, { status: 'COMPLETED' }); + return { bookingId, status: 'COMPLETED' as const }; + } + + // ---- helpers --------------------------------------------------------------- + + private async getSchedule(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + return schedule; + } + + /** + * yardId → sequenceNo for the schedule's route. Falls back to a two-stop + * origin/destination pseudo-route for legacy schedules without a routeId, + * so an intercity booking exactly matching the train's own corridor still + * qualifies. + */ + private async routeMilestoneSequence( + schedule: TrainSchedule, + ): Promise | null> { + if (schedule.routeId) { + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + if (milestones.length >= 2) { + return new Map(milestones.map((m) => [m.yardId, m.sequenceNo])); + } + } + if (schedule.originStationId && schedule.destinationStationId) { + return new Map([ + [schedule.originStationId, 1], + [schedule.destinationStationId, 2], + ]); + } + return null; + } + + /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */ + private async findWaitingIntercityBookings( + milestoneSeq: Map, + ): Promise { + const pool = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .where(`booking.trade_direction = 'DOMESTIC'`) + .andWhere('booking.train_schedule_id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status = 'APPROVED'))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + + return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq)); + } + + /** Intercity bookings already reserved/allocated on this schedule. */ + private async findAcceptedIntercityBookings( + scheduleId: string, + ): Promise { + return this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .where(`booking.trade_direction = 'DOMESTIC'`) + .andWhere('booking.train_schedule_id = :scheduleId', { scheduleId }) + .orderBy('booking.created_at', 'ASC') + .getMany(); + } + + private corridorOnRoute( + booking: Booking, + milestoneSeq: Map, + ): boolean { + const originSeq = milestoneSeq.get(booking.originYardId); + const destinationSeq = milestoneSeq.get(booking.destinationYardId); + return ( + originSeq != null && destinationSeq != null && originSeq < destinationSeq + ); + } + + private whyNotWaiting( + booking: Booking, + milestoneSeq: Map, + ): string | null { + if (booking.tradeDirection !== 'DOMESTIC') { + return 'Not an intercity booking'; + } + if (booking.trainScheduleId) { + return 'Already assigned to a train'; + } + const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; + if (booking.status !== readyStatus) { + return `Not ready to board (status ${booking.status})`; + } + if (!this.corridorOnRoute(booking, milestoneSeq)) { + return "Corridor is not on this schedule's route"; + } + return null; + } + + private async getAcceptedBooking(scheduleId: string, bookingId: string) { + const schedule = await this.getSchedule(scheduleId); + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (booking.trainScheduleId !== scheduleId) { + throw new BadRequestException('Booking is not assigned to this schedule'); + } + if (booking.tradeDirection !== 'DOMESTIC') { + throw new BadRequestException('Not an intercity booking'); + } + return { schedule, booking }; + } + + /** + * The train is "at" a yard when the latest recorded checkpoint is that yard, + * or — for a booking boarding at the train's own origin — when the train has + * not recorded any checkpoint yet (still sitting at its origin). + */ + private async assertTrainAtYard( + schedule: TrainSchedule, + yardId: string, + side: 'origin' | 'destination', + ): Promise { + const latest = await this.dataSource + .getRepository(TrainCheckpointEvent) + .findOne({ + where: { trainScheduleId: schedule.id }, + order: { occurredAt: 'DESC', createdAt: 'DESC' }, + }); + + if (!latest) { + if (side === 'origin' && schedule.originStationId === yardId) return; + throw new BadRequestException( + 'Train has not reached this yard yet — record its checkpoint first', + ); + } + if (latest.yardId !== yardId) { + throw new BadRequestException( + `Train's last recorded position is not at the booking's ${side} yard`, + ); + } + } + + private mapBooking(booking: Booking) { + return { + id: booking.id, + reference: booking.reference, + status: booking.status, + freightType: booking.freightType, + isGovernment: booking.isGovernment, + customer: booking.company?.name ?? 'Unknown customer', + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + origin: + booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', + destination: + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + 'Unknown destination', + weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, + }; + } +} + +function fits(need: Capacity, budget: Capacity): boolean { + return ( + need.wagons <= budget.wagons && + need.weightTons <= budget.weightTons && + need.lengthMeters <= budget.lengthMeters + ); +} + +function subtract(budget: Capacity, need: Capacity): Capacity { + return { + wagons: budget.wagons - need.wagons, + weightTons: budget.weightTons - need.weightTons, + lengthMeters: budget.lengthMeters - need.lengthMeters, + }; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index fb5ab66d5..1648bd957 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -20,6 +20,7 @@ import { TrainSchedulingManage, TrainSchedulingView, } from "../../common/booking-guards"; +import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto"; import { AssignBookingsDto } from "./dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; @@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingWindowService } from "./booking-window.service"; +import { IntercityService } from "./intercity.service"; import { BillingService } from "../billing/billing.service"; @ApiTags("train-scheduling") @@ -57,6 +59,7 @@ export class TrainSchedulingController { private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, private readonly bookingWindowService: BookingWindowService, + private readonly intercityService: IntercityService, private readonly billingService: BillingService, ) { } @@ -406,6 +409,54 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("schedules/:id/intercity-candidates") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity", + }) + getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) { + return this.intercityService.listCandidates(id); + } + + @Post("schedules/:id/intercity/accept") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", + }) + acceptIntercityBookings( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AcceptIntercityBookingsDto, + ) { + return this.intercityService.acceptBookings(id, dto.bookingIds); + } + + @Post("schedules/:id/intercity/:bookingId/load") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)", + }) + loadIntercityBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.intercityService.loadBooking(id, bookingId); + } + + @Post("schedules/:id/intercity/:bookingId/unload") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", + }) + unloadIntercityBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.intercityService.unloadBooking(id, bookingId); + } + @Get("schedules/:id/import-djibouti") @TrainSchedulingView() @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 7e281a72f..e01e0d629 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; @@ -25,7 +26,10 @@ import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingWindowGateway } from './booking-window.gateway'; import { BookingWindowService } from './booking-window.service'; +import { IntercityService } from './intercity.service'; +import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingSplitService } from './booking-split.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; @@ -46,6 +50,8 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEvent, ImportDjiboutiOperation, BookingBatchOffer, + // WsAuthService (booking-window gateway handshake) verifies IAM sessions. + Session, ]), forwardRef(() => BookingsModule), BillingModule, @@ -64,8 +70,11 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEventsRepository, BookingBatchService, BookingNotifierService, + BookingWindowGateway, + WsAuthService, BookingWindowService, BookingSplitService, + IntercityService, ], exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], }) 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 22dec2a37..fd0bec774 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 @@ -408,7 +408,9 @@ export class TrainSchedulingService { schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays, exportBookingLeadHours: - schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours, + dto.exportBookingLeadHours ?? + schedule.ruleExportBookingLeadHours ?? + liveCfg.exportBookingLeadHours, windowOpenHour: dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, windowCloseHour: @@ -432,6 +434,12 @@ export class TrainSchedulingService { schedule.direction === 'EXPORT' ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged) : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now); + if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) { + throw new BadRequestException( + 'These settings leave no booking window before departure — with the ' + + 'desk hours applied, the window would only open once the train has left.', + ); + } await this.dataSource.getRepository(TrainSchedule).update(id, { windowOpensAt: times.windowOpensAt, @@ -686,10 +694,9 @@ export class TrainSchedulingService { lockedLocomotives.push(locked); } - const direction = deriveScheduleDirection( - route.originYard ?? { country: null }, - route.destinationYard ?? { country: null }, - ); + // Frozen on the route at create/update from the yard-country enum; + // getSchedulableRoute already rejected DOMESTIC (intercity). + const direction = this.resolveRouteDirection(route); const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // Effective capacity is capped by the weakest locomotive in the set. @@ -3447,9 +3454,27 @@ export class TrainSchedulingService { `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`, ); } + // Intercity (same-country) service is not offered yet — only import/export + // trains can be scheduled. + if (this.resolveRouteDirection(route) === 'DOMESTIC') { + throw new BadRequestException( + `Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`, + ); + } return route; } + /** Stored route direction, deriving from yard countries for pre-migration rows. */ + private resolveRouteDirection(route: Route) { + return ( + route.direction ?? + deriveScheduleDirection( + route.originYard ?? { country: null }, + route.destinationYard ?? { country: null }, + ) + ); + } + private mapEligibleBooking(booking: Booking) { return { id: booking.id, @@ -4054,6 +4079,7 @@ export class TrainSchedulingService { : null, reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, + exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, paymentWindowMinutes: windowCfg.paymentWindowMinutes, }, diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts new file mode 100644 index 000000000..f5a730ea8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const; +export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number]; + +/** + * One import handover. A booking has a single handover when one truck takes the + * whole booking (`truckAssignmentId` null = per-booking), or one per truck when + * multiple trucks are used. Self-haul handovers are generated on truck arrival + * and signed before the truck leaves; EDR last-mile handovers are generated at + * delivery (after exit). + */ +@Entity({ schema: 'freight', name: 'booking_handovers' }) +@Index(['bookingId']) +export class BookingHandover extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + /** Customer self-haul truck this handover belongs to; null = per-booking. */ + @Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true }) + truckAssignmentId?: string | null; + + /** Denormalised plate for display / EDR trucks (which aren't customer trucks). */ + @Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true }) + truckPlate?: string | null; + + @Column({ name: 'mile_type', type: 'varchar', length: 20 }) + mileType!: HandoverMileType; + + @Column({ name: 'reference', type: 'varchar', length: 100 }) + reference!: string; + + @Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' }) + generatedAt!: Date; + + @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) + signedAt?: Date | null; + + @Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true }) + signedByUserId?: string | null; + + /** EDR last-mile: when the goods were delivered to the customer. */ + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts new file mode 100644 index 000000000..52bd318e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, EntityManager, IsNull } from 'typeorm'; + +import { BookingHandover } from './entities/booking-handover.entity'; + +/** + * Import handover records. A booking has one handover per truck (single truck ⇒ + * one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type: + * - SELF_HAUL: generated when the customer truck arrives, signed before it leaves. + * - EDR_LAST_MILE: generated at delivery (after exit). + */ +@Injectable() +export class HandoverService { + private readonly logger = new Logger(HandoverService.name); + + constructor(private readonly dataSource: DataSource) {} + + list(bookingId: string): Promise { + return this.dataSource.getRepository(BookingHandover).find({ + where: { bookingId }, + order: { generatedAt: 'ASC' }, + }); + } + + /** + * Self-haul: ensure a handover exists for a customer truck that just arrived. + * Idempotent — one per (booking, truck). Runs inside the caller's transaction + * when a manager is supplied. + */ + async ensureForArrivedTruck( + bookingId: string, + opts: { truckAssignmentId?: string | null; truckPlate?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await repo.findOne({ + where: { + bookingId, + truckAssignmentId: opts.truckAssignmentId ?? IsNull(), + }, + }); + if (existing) return existing; + + const reference = await this.generateReference(bookingId, m); + const saved = await repo.save( + repo.create({ + bookingId, + truckAssignmentId: opts.truckAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'SELF_HAUL', + reference, + generatedAt: new Date(), + }), + ); + this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`); + return saved; + } + + /** + * EDR last-mile: generate a handover at delivery (after exit). One per EDR + * truck (by plate) or per booking. Idempotent by (booking, plate). + */ + async ensureAtDelivery( + bookingId: string, + opts: { truckPlate?: string | null; truckAssignmentId?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await repo.findOne({ + where: { + bookingId, + truckPlate: opts.truckPlate ?? IsNull(), + truckAssignmentId: opts.truckAssignmentId ?? IsNull(), + }, + }); + if (existing) return existing; + + const reference = await this.generateReference(bookingId, m); + return repo.save( + repo.create({ + bookingId, + truckAssignmentId: opts.truckAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'EDR_LAST_MILE', + reference, + generatedAt: new Date(), + deliveredAt: new Date(), + }), + ); + } + + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ + async signForBooking(bookingId: string, userId?: string | null): Promise { + await this.dataSource + .getRepository(BookingHandover) + .update( + { bookingId, signedAt: IsNull() }, + { signedAt: new Date(), signedByUserId: userId ?? null }, + ); + } + + /** True when every handover on the booking is signed (and at least one exists). */ + async isFullySigned(bookingId: string): Promise { + const repo = this.dataSource.getRepository(BookingHandover); + const [total, unsigned] = await Promise.all([ + repo.count({ where: { bookingId } }), + repo.count({ where: { bookingId, signedAt: IsNull() } }), + ]); + return total > 0 && unsigned === 0; + } + + private async generateReference(bookingId: string, manager: EntityManager): Promise { + const [booking] = await manager.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, ''); + const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } }); + return `HND-${ref}-${String(count + 1).padStart(2, '0')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6b30dad1e..cd1aa432c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { HandoverService } from './handover.service'; @ApiTags('warehouse-inventory') @ApiBearerAuth() @@ -23,6 +24,7 @@ export class WarehouseInventoryController { constructor( private readonly inventoryService: WarehouseInventoryService, private readonly scheduling: SchedulingReadFacade, + private readonly handoverService: HandoverService, ) {} @Get() @@ -283,6 +285,19 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get('customer-truck-exit-paper/:assignmentId') + @ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' }) + async truckExitPaper( + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.inventoryService.truckExitPaper(assignmentId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get(':id/grn-document') @ApiOperation({ summary: 'View goods received note PDF' }) async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { @@ -312,6 +327,12 @@ export class WarehouseInventoryController { return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); } + @Get('bookings/:bookingId/handovers') + @ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' }) + bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.handoverService.list(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { 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 c9844051b..8e9457831 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 @@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; +import { HandoverService } from './handover.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -342,6 +343,7 @@ export class WarehouseInventoryService { private readonly lastMileService: LastMileService, private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, + private readonly handover: HandoverService, ) {} /** @@ -928,7 +930,7 @@ export class WarehouseInventoryService { SET received_to_port = true, received_at = COALESCE(bcu.received_at, NOW()), updated_at = NOW() - FROM freight.booking_containers bc + FROM freight.booking_container bc WHERE bc.id = bcu.booking_container_id AND bc.booking_id = $1 AND bc.deleted_at IS NULL @@ -1799,7 +1801,7 @@ export class WarehouseInventoryService { SET received_to_port = true, received_at = COALESCE(bcu.received_at, NOW()), updated_at = NOW() - FROM freight.booking_containers bc, freight.containers cont + FROM freight.booking_container bc, freight.containers cont WHERE bc.id = bcu.booking_container_id AND bc.booking_id = $1 AND bc.deleted_at IS NULL @@ -2080,9 +2082,14 @@ export class WarehouseInventoryService { [item.bookingId], ); const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); - if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + // Self-haul: the handover must be signed before the exit paper is issued. + // Prefer the structured handover record; fall back to the legacy note. + const handoverSigned = + (await this.handover.isFullySigned(item.bookingId)) || + Boolean(this.extractCustomerDeliveryApproval(item.notes)); + if (usesCustomerTruck && !handoverSigned) { throw new BadRequestException( - 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + 'Customer must sign the handover before the exit paper can be generated', ); } } @@ -2137,6 +2144,16 @@ export class WarehouseInventoryService { AND deleted_at IS NULL`, [item.bookingId], ); + // Self-haul: generate the per-booking handover on first truck arrival + // (idempotent). It must be signed before the truck leaves. + const [selfHaul]: Array<{ ok: number }> = await manager.query( + `SELECT 1 AS ok FROM freight.bookings + WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`, + [item.bookingId], + ); + if (selfHaul) { + await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); + } } await this.activityLog.record( { @@ -2231,7 +2248,7 @@ export class WarehouseInventoryService { FROM freight.customer_truck_containers cc JOIN freight.booking_container_units bcu ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL - JOIN freight.booking_containers bc + JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL AND bc.booking_id = c.booking_id WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL @@ -2291,6 +2308,115 @@ export class WarehouseInventoryService { }; } + /** + * Per-truck exit paper: one paper covering the containers loaded on a specific + * customer truck (used when multiple trucks leave separately). Gated on the + * handover being signed and warehouse fees paid. + */ + async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> { + const [truck] = await this.dataSource.query( + `SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber", + a.driver_name AS "driverName", a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt", + b.reference AS "bookingReference", company.name AS "customerName" + FROM freight.customer_truck_assignments a + JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE a.id = $1 AND a.deleted_at IS NULL`, + [assignmentId], + ); + if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`); + + if (!(await this.handover.isFullySigned(truck.bookingId))) { + throw new BadRequestException('Handover must be signed before the exit paper can be generated'); + } + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`, + [truck.bookingId], + ); + if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); + + const containers: Array<{ containerNumber: string; goods: string | null }> = + await this.dataSource.query( + `SELECT c.container_number AS "containerNumber", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + FROM freight.customer_truck_containers c + JOIN freight.bookings b ON b.id = c.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + WHERE c.assignment_id = $1 AND c.deleted_at IS NULL + ORDER BY c.container_number`, + [assignmentId], + ); + + const html = this.buildTruckExitPaperHtml({ + reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, + bookingReference: truck.bookingReference, + customerName: truck.customerName, + plateNumber: truck.plateNumber, + driverName: truck.driverName, + truckType: truck.truckType, + grossWeightKg: Number(truck.grossWeightKg ?? 0), + gateOut: truck.departedAt, + containers, + }); + return { + filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'), + }; + } + + private buildTruckExitPaperHtml(data: { + reference: string; + bookingReference: string; + customerName: string | null; + plateNumber: string; + driverName: string; + truckType: string; + grossWeightKg: number; + gateOut: string | Date | null; + containers: Array<{ containerNumber: string; goods: string | null }>; + }): string { + const esc = (v: unknown) => + String(v ?? '-').replace(/&/g, '&').replace(//g, '>'); + const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-'; + const rows: Array<[string, string]> = [ + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName ?? '-'], + ['Pickup Truck Plate', data.plateNumber], + ['Driver', data.driverName], + ['Truck Type', data.truckType], + ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`], + ['Gate-Out Time', gateOut], + ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'], + ]; + const containerRows = data.containers.length + ? data.containers + .map((c) => `${esc(c.containerNumber)}${esc(c.goods)}`) + .join('') + : 'No containers loaded on this truck.'; + return `Warehouse Exit Paper + + +
Ethio-Djibouti Railway S.C.
+

Warehouse Release / Exit Paper

+
Document / Release No. ${esc(data.reference)}
+
Release Particulars
+ ${rows.map(([l, v]) => ``).join('')}
${esc(l)}${esc(v)}
+
Containers Leaving on This Truck
+ + ${containerRows}
Container NumberGoods
+ `; + } + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( @@ -2388,7 +2514,17 @@ export class WarehouseInventoryService { return { filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + // Styled fallback titled as a GRN (not a release order) for Chromium-less render. + buffer: await this.releaseDocuments.renderStyledDocument( + html, + { + titleLines: ['GOODS RECEIVED', 'NOTE'], + subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE', + sectionTitle: 'RECEIVED PARTICULARS', + refLabel: 'GRN No.', + }, + 'Goods Received Note', + ), }; } @@ -2462,6 +2598,10 @@ export class WarehouseInventoryService { ); }); + // Sign the structured handover record(s) for this booking (self-haul: before + // the truck leaves). Kept alongside the legacy approval note. + await this.handover.signForBooking(bookingId, userId); + return { bookingId, inventoryId: item.id, @@ -2589,7 +2729,17 @@ export class WarehouseInventoryService { return { filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + // Styled fallback titled as a handover (not a release order) for Chromium-less render. + buffer: await this.releaseDocuments.renderStyledDocument( + html, + { + titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'], + subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER', + sectionTitle: 'HANDOVER PARTICULARS', + refLabel: 'Document / Handover No.', + }, + 'Import Goods Handover', + ), }; } @@ -2601,6 +2751,29 @@ export class WarehouseInventoryService { throw new BadRequestException('A release order must be issued before the goods can be delivered'); } + // Self-haul: the customer's own truck delivers — deliver only after the + // handover is signed AND the truck has left the warehouse holding the goods. + if (item.bookingId) { + const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "assignedAt" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + if (sh?.assignedAt) { + if (!(await this.handover.isFullySigned(item.bookingId))) { + throw new BadRequestException('Handover must be signed before delivery'); + } + const [left]: Array<{ n: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS n FROM freight.customer_truck_assignments + WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`, + [item.bookingId], + ); + if (Number(left?.n ?? 0) === 0) { + throw new BadRequestException('Deliver is available only after the customer truck has left'); + } + } + } + const receiverName = dto.receiverName.trim(); const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); const weight = Number(item.weight) || 0; @@ -2645,6 +2818,27 @@ export class WarehouseInventoryService { }, manager, ); + + // Handover on delivery. EDR last-mile generates its handover HERE (after + // exit, on delivery). Self-haul handovers were generated on arrival — + // stamp them delivered. + if (item.bookingId) { + const [b]: Array<{ selfHaul: string | null }> = await manager.query( + `SELECT customer_truck_assigned_at AS "selfHaul" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + if (b?.selfHaul) { + await manager.query( + `UPDATE freight.booking_handovers + SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW() + WHERE booking_id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + } else { + await this.handover.ensureAtDelivery(item.bookingId, {}, manager); + } + } }); return this.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 68e630e0b..1d8e3d9ea 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -32,16 +32,38 @@ export class WarehouseReleaseDocumentService { return this.pdf.htmlToPdfBuffer(html, { label }); } - private htmlToBasicPdfBuffer(html: string): Buffer { + /** + * Render document HTML with a STYLED hand-built fallback (the release layout, + * but with a custom title + section heading) for when Chromium is unavailable. + * Handover / GRN use this so their fallback looks like a proper document — + * not a plain-text dump, and not mislabelled as a release order. + */ + renderStyledDocument( + html: string, + fallbackOpts: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string }, + label = 'Document', + ): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label, + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts), + }); + } + + private htmlToBasicPdfBuffer( + html: string, + opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string }, + ): Buffer { const doc = this.extractReleaseDocument(html); + const titleLines = (opts?.titleLines ?? ['WAREHOUSE GATE', 'CLEARANCE / RELEASE', 'ORDER']).slice(0, 3); + const subtitle = opts?.subtitle ?? 'OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION'; + const sectionTitle = opts?.sectionTitle ?? 'RELEASE PARTICULARS'; + const refLabel = opts?.refLabel ?? 'Document / Release No.'; const body: string[] = [ this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), - this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'), - this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), + ...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 24, 'F2', '0.02 0.08 0.16')), + this.textOp(subtitle, 36, 764 - titleLines.length * 22 + 2, 8.5, 'F1', '0.25 0.34 0.45'), + this.textOp(refLabel, 424, 781, 8.5, 'F1', '0.15 0.22 0.32'), this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), @@ -50,7 +72,7 @@ export class WarehouseReleaseDocumentService { ...this.wrapLines(doc.notice, 68) .slice(0, 4) .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), - this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), + this.textOp(sectionTitle, 36, 604, 10, 'F2', '0.08 0.32 0.18'), ]; let y = 586; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index b871d2a36..5f0ce5749 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { BookingHandover } from './entities/booking-handover.entity'; +import { HandoverService } from './handover.service'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, + BookingHandover, ]), BillingModule, DocumentsModule, @@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseSchedulingAdapterService, WarehouseReleaseDocumentService, SchedulingReadFacade, + HandoverService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 6b0465112..b67f1f572 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001'; const COMPANY_EMAIL = 'first-last-mile-demo@edr.local'; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 }, ]; const CONTAINER_TYPES = [ diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index a619c7c05..be5e1c87d 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local"; const COMPANY_TIN = "1234567890"; const YARDS = [ - { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti" as const, + displayOrder: 1, + }, { code: "ADDIS_ABABA", label: "Addis Ababa", - country: "Ethiopia", + country: "Ethiopia" as const, displayOrder: 2, }, { code: "DIRE_DAWA", label: "Dire Dawa", - country: "Ethiopia", + country: "Ethiopia" as const, displayOrder: 3, }, ]; diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 708afb86b..732653a9d 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -16,8 +16,8 @@ const COMPANY_TIN = 'PAIDMILE001'; const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 }, ]; const CONTAINER_TYPES = [ diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index dc95729c1..6f21a50a2 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react"; import { CountdownTimer } from "@edr/ui-common"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; import type { StaffBookingWindow } from "@/types/trainScheduling"; @@ -224,6 +225,9 @@ function WindowCard({ w }: { w: StaffBookingWindow }) { * Hidden when nothing is pending. */ export function GlUpcomingWindowsSection() { + // Live pushes flip cards the moment the window engine transitions a phase; + // the 60s poll below stays only as a fallback. + useBookingWindowSocket(); const { data, isLoading } = useQuery( api.trainScheduling.allBookingWindows.queryOptions({ refetchInterval: 60_000, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index 2e32dcf45..ec3774ce6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -32,6 +32,7 @@ const DEFAULTS = { docReviewMinutes: 30, paymentWindowMinutes: 60, importWindowLeadDays: 3, + exportBookingLeadHours: 24, }; /** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ @@ -53,6 +54,7 @@ interface FormState { docReviewMinutes: number | ""; paymentWindowMinutes: number | ""; importWindowLeadDays: number | ""; + exportBookingLeadHours: number | ""; } function parseError(error: unknown, fallback: string): string { @@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({ r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes, importWindowLeadDays: r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays, + exportBookingLeadHours: + r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours, }); }, [opened, schedule]); @@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({ const doc = Number(form.docReviewMinutes); const pay = Number(form.paymentWindowMinutes); const lead = Number(form.importWindowLeadDays); + const exportLead = Number(form.exportBookingLeadHours); + const leadInvalid = isExport + ? form.exportBookingLeadHours === "" || + !Number.isFinite(exportLead) || + exportLead < 1 + : form.importWindowLeadDays === "" || !Number.isFinite(lead); if ( form.windowDurationHours === "" || form.docReviewMinutes === "" || form.paymentWindowMinutes === "" || - form.importWindowLeadDays === "" || !Number.isFinite(duration) || !Number.isFinite(doc) || !Number.isFinite(pay) || - !Number.isFinite(lead) + leadInvalid ) { toast({ title: "Fill every field before saving", @@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({ windowDurationHours: duration, docReviewMinutes: doc, paymentWindowMinutes: pay, - importWindowLeadDays: lead, + ...(isExport + ? { exportBookingLeadHours: exportLead } + : { importWindowLeadDays: lead }), }; try { @@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({ {isExport ? ( }> - Export schedules use a single FCFS lead window — the daily desk - hours below don't apply, only the lead time does. + Export schedules use a single first-come-first-served window: it + opens the export lead time before departure — shifted to the next + desk opening if that lands outside desk hours — and stays open + until departure. Cycle timing below doesn't apply. ) : null} @@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({ } allowDeselect={false} comboboxProps={{ withinPortal: true }} - disabled={isExport} /> truck.value === truckPlateNumber) ? truckPlateNumber : null} + value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null} onChange={(value) => { - const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value); + const truck = truckSelectOptions.find((row) => row.value === value); setTruckPlateNumber(truck?.value ?? ''); setTrailerPlateNumber(truck?.trailerPlate ?? ''); + if (truck?.driverName) setDriverName(truck.driverName); + if (truck?.driverPhone) setDriverPhone(truck.driverPhone); + if (truck?.truckType) setTruckType(truck.truckType); }} /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx new file mode 100644 index 000000000..73e37c1ec --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx @@ -0,0 +1,147 @@ +import { Alert, Badge, Button, Group, Loader, Modal, MultiSelect, Stack, Text } from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { FileText, Truck } from 'lucide-react'; +import { useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; +import { extractErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; + +interface TruckDispatchModalProps { + opened: boolean; + onClose: () => void; + bookingId: string | null; + bookingReference?: string | null; +} + +/** + * Truck_dispatch: after a self-haul truck arrives, staff select which of the + * booking's containers ride each truck. The loaded set drives the truck's gross + * weight; the truck is weighed for real on departure. + */ +export function TruckDispatchModal({ opened, onClose, bookingId, bookingReference }: TruckDispatchModalProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [selectedByTruck, setSelectedByTruck] = useState>({}); + + const trucksKey = ['td-customer-trucks', bookingId]; + const loadableKey = ['td-loadable', bookingId]; + + const { data: trucks = [], isLoading: trucksLoading } = useQuery({ + queryKey: trucksKey, + queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + const { data: loadable = [], isLoading: loadableLoading } = useQuery({ + queryKey: loadableKey, + queryFn: () => warehouseService.getLoadableContainers(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + + const loadMutation = useMutation({ + mutationFn: ({ assignmentId, containerNumbers }: { assignmentId: string; containerNumbers: string[] }) => + warehouseService.loadTruck(bookingId as string, assignmentId, containerNumbers), + onSuccess: (_res, vars) => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + queryClient.invalidateQueries({ queryKey: loadableKey }); + setSelectedByTruck((s) => ({ ...s, [vars.assignmentId]: [] })); + toast({ title: 'Truck loaded' }); + }, + onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), + }); + + const openTruckExitPaper = async (assignmentId: string, plate: string) => { + try { + const res = await warehouseService.downloadTruckExitPaper(assignmentId); + openPdfBlob(res.data, `exit-${plate}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) }); + } + }; + + return ( + + + Truck_dispatch — load containers {bookingReference ? `· ${bookingReference}` : ''} + + } + > + {trucksLoading || loadableLoading ? ( + + + + ) : trucks.length === 0 ? ( + + No customer truck is assigned to this booking yet. + + ) : ( + + {trucks.map((t) => { + const alreadyLoaded = (t.containers ?? []).map((c) => c.containerNumber); + // Options = still-loadable + this truck's own already-loaded (so they stay visible). + const options = Array.from(new Set([...loadable, ...alreadyLoaded])); + const selected = selectedByTruck[t.id] ?? alreadyLoaded; + const departed = Boolean(t.arrivedAt) && Boolean((t as { departedAt?: string }).departedAt); + return ( + + + {t.plateNumber} + + {t.driverName} · {t.truckType} + {t.arrivedAt ? Arrived : Not arrived} + + + setSelectedByTruck((s) => ({ ...s, [t.id]: v }))} + searchable + disabled={departed || !t.arrivedAt} + nothingFoundMessage="No loadable containers" + /> + + + + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fbfd2f998..fce620235 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -324,6 +324,14 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + INTERCITY_CANDIDATES: (id: string) => + `/train-scheduling/schedules/${id}/intercity-candidates`, + INTERCITY_ACCEPT: (id: string) => + `/train-scheduling/schedules/${id}/intercity/accept`, + INTERCITY_LOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/intercity/${bookingId}/load`, + INTERCITY_UNLOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`, IMPORT_LOADING_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/import-loading-bookings`, IMPORT_LOADING_STATUS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts new file mode 100644 index 000000000..375c12f19 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -0,0 +1,55 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; +import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live booking-window pushes for staff. Every phase transition + * the window engine applies invalidates the GL windows carousel and the batch + * board, so both flip the moment the backend does — polling stays only as a + * fallback. + */ +export function useBookingWindowSocket(enabled: boolean = true) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getCookie(AUTH_TOKEN_COOKIE); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on( + BOOKING_WINDOW_WS_EVENTS.PHASE, + (_event: BookingWindowPhaseEvent) => { + qc.invalidateQueries({ + queryKey: ["train-scheduling", "all-booking-windows"], + }); + qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + }); + }, + ); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 249130e61..7ee0f7896 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -48,6 +48,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index bc5b7dcb1..f36ce8394 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() { const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); - const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]); + // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the + // API rejects them, so keep them out of the picker entirely. + const activeRoutes = useMemo( + () => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"), + [routesQuery.data], + ); const selectedRoute = activeRoutes.find((r) => r.id === routeId); @@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() { } try { const created = await create.mutateAsync({ - payload: { routeId, scheduleDate, locomotiveIds }, + payload: { + routeId, + scheduleDate: new Date(scheduleDate).toISOString(), + locomotiveIds, + }, }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); @@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() { { - const raw = e.currentTarget.value; - setScheduleDate(raw ? new Date(raw).toISOString() : ""); - }} + value={scheduleDate} + onChange={(e) => setScheduleDate(e.currentTarget.value)} /> TRAIN_SCHEDULING_INVALIDATIONS, ), + intercityCandidates: endpoint< + { scheduleId: string }, + import("@/types/trainScheduling").IntercityCandidatesResult + >( + "train-scheduling", + "intercity-candidates", + ({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId), + ({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId], + ), + + acceptIntercityBookings: endpoint< + { scheduleId: string; bookingIds: string[] }, + import("@/types/trainScheduling").IntercityAcceptResult + >( + "train-scheduling", + "intercity-accept", + ({ scheduleId, bookingIds }) => + trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + loadIntercityBooking: endpoint< + { scheduleId: string; bookingId: string }, + void + >( + "train-scheduling", + "intercity-load", + ({ scheduleId, bookingId }) => + trainSchedulingService.loadIntercityBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unloadIntercityBooking: endpoint< + { scheduleId: string; bookingId: string }, + void + >( + "train-scheduling", + "intercity-unload", + ({ scheduleId, bookingId }) => + trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + cancelSchedule: endpoint< { id: string; freightType?: FreightType }, TrainScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts index 26c340e83..65de13760 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS'; export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; +/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */ +export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC'; + export interface YardRef { id: string; code: string; @@ -23,6 +26,7 @@ export interface RouteMilestone { export interface RouteRecord { id: string; status: RouteStatus; + direction?: RouteDirection; originYardId: string; destinationYardId: string; originYard?: YardRef | null; diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 8c969b06d..3f4c6822f 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -17,6 +17,8 @@ import type { ImportDjiboutiLoadList, ImportDjiboutiOperation, ImportLoadingBookingsResponse, + IntercityAcceptResult, + IntercityCandidatesResult, LoadingStatus, LocomotiveRecord, PinWagonsPayload, @@ -328,6 +330,46 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getIntercityCandidates: async ( + scheduleId: string, + ): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_CANDIDATES(scheduleId), + ); + return unwrap(response.data); + }, + + acceptIntercityBookings: async ( + scheduleId: string, + bookingIds: string[], + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_ACCEPT(scheduleId), + { bookingIds }, + ); + return unwrap(response.data); + }, + + loadIntercityBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_LOAD(scheduleId, bookingId), + {}, + ); + }, + + unloadIntercityBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_UNLOAD(scheduleId, bookingId), + {}, + ); + }, + dispatchSchedule: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index e8e2fac88..4d7a834ba 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -1,3 +1,5 @@ +import type { Freight } from '@edr/types'; + import { api as apiClient } from '../auth/http'; import { URL_CONSTANTS } from '@/constants/URLS'; @@ -67,6 +69,33 @@ const cleanParams = (params: object) => ); export const warehouseService = { + /** Customer self-haul trucks assigned to a booking (portal multi-truck). */ + getCustomerTrucks: async (bookingId: string): Promise => { + const { data } = await apiClient.get(`/bookings/${bookingId}/customer-trucks`); + return data?.data ?? data ?? []; + }, + + /** Booking container numbers not yet loaded onto any truck. */ + getLoadableContainers: async (bookingId: string): Promise => { + const { data } = await apiClient.get( + `/bookings/${bookingId}/customer-trucks/loadable-containers`, + ); + return data?.data ?? data ?? []; + }, + + /** Truck_dispatch: load selected containers onto a truck (after arrival). */ + loadTruck: async ( + bookingId: string, + assignmentId: string, + containerNumbers: string[], + ): Promise => { + const { data } = await apiClient.post( + `/bookings/${bookingId}/customer-trucks/${assignmentId}/load`, + { containerNumbers }, + ); + return data?.data ?? data ?? []; + }, + // ── Warehouses ────────────────────────────────────────────────────────── list: (filter?: WarehouseFilter) => apiClient.get(URL_CONSTANTS.WAREHOUSES.BASE, { @@ -147,6 +176,11 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), { responseType: 'blob', }), + /** Per-truck exit paper PDF (containers loaded on one customer truck). */ + downloadTruckExitPaper: (assignmentId: string) => + apiClient.get(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, { + responseType: 'blob', + }), deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 4807d00fb..68ad375d8 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -407,6 +407,7 @@ export interface ScheduleWindowRule { windowDurationHours: number | null; reopenDelayMinutes: number | null; importWindowLeadDays: number | null; + exportBookingLeadHours: number | null; /** Live global values (not snapshotted per schedule) — editor prefill baseline. */ docReviewMinutes: number; paymentWindowMinutes: number; @@ -420,6 +421,7 @@ export interface UpdateScheduleWindowRulePayload { docReviewMinutes?: number; paymentWindowMinutes?: number; importWindowLeadDays?: number; + exportBookingLeadHours?: number; } export interface TrainScheduleDetail { @@ -746,3 +748,49 @@ export interface CompositionRemovalEntry { removedAt: string; notes: string | null; } + +// ── Intercity ride-along ───────────────────────────────────────────────────── +// Intercity (DOMESTIC) bookings have no train of their own — they ride a +// passing import/export schedule whose route milestones contain the booking's +// origin before its destination. Staff accept them at finalize time against +// the train's remaining wagon/weight/length capacity. + +export interface IntercityCapacity { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +export interface IntercityBookingRow { + id: string; + reference: string | null; + status: string; + freightType: FreightType | null; + isGovernment: boolean; + customer: string; + originYardId: string; + destinationYardId: string; + origin: string; + destination: string; + weightTons: number; + paymentDeadline: string | null; + need: IntercityCapacity | null; +} + +export interface IntercityCandidateRow extends IntercityBookingRow { + fits: boolean; +} + +export interface IntercityCandidatesResult { + scheduleId: string; + routeId: string | null; + remaining: IntercityCapacity | null; + candidates: IntercityCandidateRow[]; + accepted: IntercityBookingRow[]; +} + +export interface IntercityAcceptResult { + accepted: string[]; + rejected: Array<{ bookingId: string; reason: string }>; + remaining: IntercityCapacity; +} diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts new file mode 100644 index 000000000..17f6174b3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -0,0 +1,60 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; + +function getAuthToken(): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith("auth-token=")) + ?.split("=")[1]; +} + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live booking-window pushes. Every phase transition the window + * engine applies (open, doc review, payment, reopen, done) invalidates the + * cached window lists, so the home-page "Booking Windows" card flips the + * moment the backend does — the 60s poll remains only as a fallback. + */ +export function useBookingWindowSocket(enabled: boolean) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getAuthToken(); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on( + BOOKING_WINDOW_WS_EVENTS.PHASE, + (_event: BookingWindowPhaseEvent) => { + qc.invalidateQueries({ + queryKey: ["train-scheduling", "myBookingWindows"], + }); + qc.invalidateQueries({ + queryKey: ["train-scheduling", "contractBookingWindows"], + }); + }, + ); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index dc3f6f7be..5e051cce9 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -1,12 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { const { user, customer, company } = useAuth(); + // Live booking-window pushes: a phase transition on any lane invalidates the + // windows queries below the moment it happens (poll below is only a fallback). + useBookingWindowSocket(Boolean(user)); + const invoicesQuery = useQuery(api.invoices.listMy.queryOptions()); const myInvoices = invoicesQuery.data ?? []; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 268e8627a..16225cd02 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -211,7 +211,10 @@ export default function ContractDetailPage() { }), enabled: !!id, }); - const bookingWindowOpen = hasOpenWindow(bookingWindows); + // Intercity contracts are never window-gated: the shipment rides a passing + // import/export train that staff assign later, so booking is always open. + const bookingWindowOpen = + contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows); // Draw-down capacity per cargo line (GENERAL contracts only). The backend // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index a2bc985c0..d064197d5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -128,8 +128,10 @@ export default function NewShipmentPage() { // Coarse gate: if the customer deep-links here while no booking window is // open, show the same closed-state notice as the contract page instead of the - // form. Still allowed the moment any window isOpenNow. - if (!hasOpenWindow(bookingWindows)) { + // form. Still allowed the moment any window isOpenNow. Intercity contracts + // are never window-gated — the shipment rides a passing train that staff + // pick at finalize time, so booking is always open. + if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) { return ( + } + title="Schedule" + description="Intercity shipments have no fixed day." + /> + }> + Your shipment rides the next import/export train passing through your + corridor. Operations assign it to a train with free capacity — you + will be notified when it is accepted and payment is due. + + + ); + } + return ( = [ { value: "import", @@ -46,7 +48,8 @@ export const OPERATION_TYPE_OPTIONS: Array<{ { value: "intercity", label: "Intercity", - description: "Domestic movement between Ethiopian yards.", + description: + "Domestic movement between Ethiopian yards — rides on passing import/export trains, no customs.", }, { value: "import_ff", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx index 49d04533a..6ce2aed5f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx @@ -30,7 +30,11 @@ export function Step0OperationType({ }) { const data = OPERATION_TYPE_OPTIONS.filter((opt) => allowedOperations.includes(opt.value), - ).map((opt) => ({ value: opt.value, label: opt.label })); + ).map((opt) => ({ + value: opt.value, + label: opt.label, + disabled: opt.disabled ?? false, + })); return (
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index a4ec5aeb8..e37daea6c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -49,7 +49,11 @@ export function Step1ContractType({ const operationData = OPERATION_TYPE_OPTIONS.filter((opt) => allowedOperations.includes(opt.value), - ).map((opt) => ({ value: opt.value, label: opt.label })); + ).map((opt) => ({ + value: opt.value, + label: opt.label, + disabled: opt.disabled ?? false, + })); const previousContractRef = form.watch("previousContractRef"); const [searchQuery, setSearchQuery] = useState(""); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index ac182113f..1403cf1b5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -19,6 +19,11 @@ export interface ShipmentValidationContext { isHazardous: boolean; isReefer: boolean; unitOfMeasure?: "PER_TON" | "PER_ITEM"; + /** + * Intercity (DOMESTIC) shipments ride a passing import/export train that + * staff pick later, so no shipment day is chosen. Defaults to true. + */ + requiresDate?: boolean; } const containerUnitSchema = z.object({ @@ -54,7 +59,7 @@ const shipmentFormBase = z.object({ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { return shipmentFormBase.superRefine((data, refineCtx) => { - if (!data.scheduledDate.trim()) { + if ((ctx.requiresDate ?? true) && !data.scheduledDate.trim()) { refineCtx.addIssue({ code: "custom", path: ["scheduledDate"], diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index b3d77d220..1501282fe 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -66,8 +66,8 @@ "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", "@types/express": "^4.17.21", - "@types/luxon": "^3.7.1", "@types/jest": "^29.5.11", + "@types/luxon": "^3.7.1", "@types/node": "^20.10.6", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", diff --git a/packages/types/src/freight/booking-window-ws.ts b/packages/types/src/freight/booking-window-ws.ts new file mode 100644 index 000000000..6c48f2c4c --- /dev/null +++ b/packages/types/src/freight/booking-window-ws.ts @@ -0,0 +1,41 @@ +/** + * Shared contracts for live booking-window pushes. + * + * The freight API emits one event whenever a schedule's booking-window state + * changes (phase transition, open/close, cycle reopen). Portal home and the + * backoffice GL windows section subscribe and refresh instantly instead of + * waiting for their poll interval. + */ + +/** Booking-window lifecycle phase persisted on a train schedule. */ +export type BookingWindowPhase = + | "PRE_WINDOW" + | "OPEN" + | "DOC_REVIEW" + | "PAYMENT" + | "CLOSED_FOR_DAY" + | "DONE"; + +/** Payload pushed on every booking-window state change. */ +export interface BookingWindowPhaseEvent { + scheduleId: string; + originYardId: string; + destinationYardId: string; + direction: string | null; + phase: BookingWindowPhase; + bookingWindowStatus: string | null; + bookingCycleNo: number; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + scheduledDepartureDate: string | null; +} + +/** Socket.io event names pushed server → client on the booking-windows namespace. */ +export const BOOKING_WINDOW_WS_EVENTS = { + PHASE: "booking-window:phase", +} as const; + +/** Socket.io namespace the booking-window gateway listens on. */ +export const BOOKING_WINDOW_WS_NAMESPACE = "booking-windows"; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 829ae3217..bf2778575 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -695,8 +695,8 @@ export interface CreateBulkLineDto { export interface CreateBookingUnderContractDto { /** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */ contractRouteId?: string; - /** Binding shipment day. */ - scheduledDate: string; + /** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */ + scheduledDate?: string; containers?: CreateBookingContainerLineDto[]; bulkLines?: CreateBulkLineDto[]; notes?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7c8a3c4cd..0b5752265 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -8,6 +8,7 @@ export * from "./etrade"; export * from "./contracts"; export * from "./clearance-files.catalog"; export * from "./notifications"; +export * from "./booking-window-ws"; export enum TradeDirection { IMPORT = "IMPORT", @@ -220,6 +221,24 @@ export enum WagonReadiness { export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; +/** + * The only two countries on the EDR line. Yard `country` is constrained to + * these values; route trade direction is derived from the origin/destination + * yard countries (ET→DJ = EXPORT, DJ→ET = IMPORT, same-country = DOMESTIC, + * shown as "Intercity" in UIs and currently disabled for scheduling/contracts). + */ +export enum YardCountry { + ETHIOPIA = "Ethiopia", + DJIBOUTI = "Djibouti", +} + +/** UI label for a schedule/route trade direction (DOMESTIC displays as Intercity). */ +export const TRADE_DIRECTION_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Intercity", +}; + export enum TrainCheckpointKind { Departed = "DEPARTED", Passed = "PASSED", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e278995a..fadeaca56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15461,9 +15461,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -15584,9 +15584,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)