diff --git a/apps/edr-freight-api/docs/priority-batch-window-flow.md b/apps/edr-freight-api/docs/priority-batch-window-flow.md new file mode 100644 index 000000000..9c8a52878 --- /dev/null +++ b/apps/edr-freight-api/docs/priority-batch-window-flow.md @@ -0,0 +1,74 @@ +# Priority & Batch Window Flow (Import, Freight) + +Export = no batch, no priority. Pure first-come-first-served (`booking-batch.service.ts:462-467, 625-628`). Everything below is import only. + +## Step by step + +**1. Booking submitted → priority score computed** +`booking-transition.service.ts:110-111,196-197` → `booking-pricing.service.ts:403-407` `computeSubmitPriorityScore()` → `rule-engine.service.ts:118`. +- Government booking: `+50,000` (`government-priority.constants.ts:2`, applied `rule-engine.service.ts:201`) +- Plus cargo/weight modifiers +- Stored on `booking.priorityScore` + +**2. Window opens (PRE_WINDOW → OPEN)** +Cron tick every 10s: `booking-window.service.ts:63` → `advanceImport` → `booking-window.service.ts:232-251`. +Times computed by `computeImportWindowTimes` (`batch-window.util.ts:248-283`). + +**3. Customers book during OPEN** +Booking lands as: +- Commercial → `FULLY_EXECUTED` +- Government → `APPROVED/PAID` (skips contract flow) + +**4. Window closes (OPEN → DOC_REVIEW)** +`booking-window.service.ts:254-268`. Staff review docs for `docReviewMinutes`. + +**5. Doc review ends** +Staff `completeDocReview()` (`booking-window.service.ts:124-159`) or timeout → `booking-window.service.ts:270-295`. +Before batch runs: `expireUnacceptedForRouteDay` (`booking-batch.service.ts:1853-1883`) kills never-accepted bookings so they can't compete. + +**6. Batch fill runs** +`processRouteDay` → `fillRouteDay` (`booking-batch.service.ts:1138-1319`), or single-schedule `fillSchedule` (`:1018-1128`). + +- Pool pulled pre-sorted: `findBatchPool`/`findBatchPoolByCorridorDay` (`bookings.repository.ts:991-1008, 1055-1083`) + `ORDER BY is_government DESC, priority_score DESC, fully_executed_at ASC, created_at ASC` +- Consolidated pairs grouped as one atomic unit: `groupConsolidatedPool` (`:1962-1987`) — never split. +- Greedy placement, earliest-departing fitting train first: loop at `:1218-1306`. +- No fit + government booking → `preemptForGovernment` (`:1891-1910`): bumps lowest-`priorityScore` commercial victim first, only if legs overlap (`:1920`). +- No fit + commercial import (GENERAL/ONE_TIME) → maybe partial "split" offer: `maybeOfferPartial`/`isSplitEligible` (`:1326-1370`). +- Still no fit → stays pooled, `notifier.unplaced` (`:1278-1280`). + +**7. Placed bookings get reserved/allocated** +- Commercial: `reserve()` (`:1673-1703`) → `SELECTED_FOR_BATCH`, payment deadline set, DOC_REVIEW→PAYMENT (`booking-window.service.ts:275-294`). +- Government: `allocate()` directly (`:1706-1746`), no payment step. + +**8. Payment phase ends** +`booking-window.service.ts:297-309` → `settleDueReservations` → `settleReserved` (`:1437-1491`): +- paid → allocated +- unpaid → expired, capacity freed + +Then `concludeCycle` (`:315-373`): +- Train full → `DONE` + auto-finalize (`:320-329`) +- Not full → reopen same/next day (`nextCycleOpensAt` / office hours, `:331-372`, `batch-window.util.ts:217-224`) or `DONE` if no cycle fits before departure. + +**9. Backstop** +`settleOverdueReservations` (`booking-window.service.ts:388-406`) catches any reservation whose deadline passed outside the normal tick. + +## Phase enum + +`PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → (reopen PRE_WINDOW | DONE)` +(`booking-window.config.ts:27-34`) + +## What decides priority + +1. `is_government` — always first, both in SQL sort and `compareSchedulingPriority` util (`compare-scheduling-priority.util.ts:9-23`) +2. `priority_score` DESC (rule engine: government bonus + cargo/weight modifiers) +3. `fully_executed_at` ASC (earlier wins) +4. `created_at` ASC + +## Edge cases + +- Government preemption only bumps if legs overlap; picks lowest-priority victim first. +- Consolidated pairs are both-or-neither, never split (`:1326-1334, 1793`). +- Only GENERAL/ONE_TIME import bookings are eligible for partial "split" offers. +- Per-unit try/catch around reserve — one failure can't cause silent trickle/stagger allocation (comment at `:1283-1288`). +- Each train freezes its own rule snapshot at window-open time, not live config (`booking-window.service.ts:85-93`). diff --git a/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts b/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts new file mode 100644 index 000000000..4bda8dde1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds locomotives.overage_tolerance_tons / overage_tolerance_meters: an + * optional per-locomotive deviation allowance above max_pull_weight_tons / + * max_train_length_meters. Nullable, defaults to no tolerance so existing + * strict-cap behavior is unchanged until staff sets a value. + */ +export class AddLocomotiveOverageTolerance2040000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS overage_tolerance_tons NUMERIC(10, 3), + ADD COLUMN IF NOT EXISTS overage_tolerance_meters NUMERIC(10, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS overage_tolerance_tons, + DROP COLUMN IF EXISTS overage_tolerance_meters; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts new file mode 100644 index 000000000..332df2423 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds customer_truck_containers.loaded_at so an assignment (customer planning + * which containers ride which truck) is distinct from the container actually + * being loaded. Stage LOADED now requires loaded_at; customer assignment alone + * keeps the container at its prior stage (RECEIVED/GRN) with its planned truck + * shown. Backfills containers on already-departed trucks (they left loaded). + */ +export class AddCustomerTruckContainerLoadedAt2050000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers + ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.customer_truck_containers ctc + SET loaded_at = a.departed_at + FROM freight.customer_truck_assignments a + WHERE a.id = ctc.assignment_id + AND a.departed_at IS NOT NULL + AND ctc.deleted_at IS NULL + AND ctc.loaded_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts b/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts new file mode 100644 index 000000000..927504ee6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drops wagon_types.max_wagons_per_train. Train wagon-count caps are already + * derived from locomotive + wagon length/weight (train-capacity.util.ts) and + * the global train_scheduling_global_rules row — this per-wagon-type override + * was unused by that derivation and only added a confusing "Max / train" + * field to the wagon type form. + */ +export class DropWagonTypeMaxWagonsPerTrain2050000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_types + DROP COLUMN IF EXISTS max_wagons_per_train; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_types + ADD COLUMN IF NOT EXISTS max_wagons_per_train INT; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts b/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts new file mode 100644 index 000000000..ed9e8fae2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Upserts the 10 real EDR wagon types (code, name, capacity, length, tare + * weight) by code. Overwrites any existing row with the same code so + * previously-seeded demo values (e.g. NW5/PW2/CW3 from demo-bookings.seeder) + * are replaced with the real spec. + */ +export class SeedRailWagonTypes2060000000000 implements MigrationInterface { + private readonly wagonTypes = [ + { code: 'NW7', name: 'Double deck sedan wagon', capacityTons: 22, lengthMeters: 26.066, tareWeightTons: 37.1 }, + { code: 'NW5', name: 'Flat wagon', capacityTons: 70, lengthMeters: 13.966, tareWeightTons: 22.4 }, + { code: 'PW2', name: 'Box wagon', capacityTons: 70, lengthMeters: 17.066, tareWeightTons: 25.2 }, + { code: 'GW2', name: 'Tank wagon', capacityTons: 70, lengthMeters: 12.228, tareWeightTons: 23 }, + { code: 'CW4', name: 'Gondola covered wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 24.8 }, + { code: 'CW3', name: 'Gondola open wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 23.4 }, + { code: 'KW2', name: 'Hopper covered wagon', capacityTons: 69, lengthMeters: 16.466, tareWeightTons: 25.2 }, + { code: 'KW3', name: 'Hopper wagon open', capacityTons: 70, lengthMeters: 14.4, tareWeightTons: 24 }, + { code: 'NW6', name: 'Flat wagon (long)', capacityTons: 70, lengthMeters: 18.56, tareWeightTons: 25.3 }, + { code: 'BW1', name: 'Refrigerated wagon', capacityTons: 38, lengthMeters: 21.996, tareWeightTons: 32.1 }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + for (const wt of this.wagonTypes) { + await queryRunner.query( + ` + INSERT INTO freight.wagon_types (code, name, capacity_tons, length_meters, tare_weight_tons, is_active) + VALUES ($1, $2, $3, $4, $5, true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + tare_weight_tons = EXCLUDED.tare_weight_tons; + `, + [wt.code, wt.name, wt.capacityTons, wt.lengthMeters, wt.tareWeightTons], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.wagon_types WHERE code = ANY($1);`, + [this.wagonTypes.map((wt) => wt.code)], + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts new file mode 100644 index 000000000..ff85f3f24 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Tare weight becomes mandatory on a wagon type. + * + * The locomotive's pull limit is a GROSS limit — it drags the wagon as well as + * the cargo — so capacity math cannot run without a tare. A NULL tare silently + * read as zero and let trains overbook by the tare fraction (~27% on a PW2 + * consist), so the column is now NOT NULL. + * + * Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which + * upserts the ten real EDR types). Backfill those by code first, and give any + * remaining custom/demo type the NW5 flat-wagon tare rather than fail the + * migration — a wrong-but-plausible tare is recoverable in the admin UI; a + * blocked deploy is not. + */ +export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface { + private readonly tareByCode: Array<[string, number]> = [ + ['NW7', 37.1], + ['NW5', 22.4], + ['PW2', 25.2], + ['GW2', 23], + ['CW4', 24.8], + ['CW3', 23.4], + ['KW2', 25.2], + ['KW3', 24], + ['NW6', 25.3], + ['BW1', 32.1], + ]; + + /** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */ + private readonly fallbackTareTons = 22.4; + + public async up(queryRunner: QueryRunner): Promise { + for (const [code, tareWeightTons] of this.tareByCode) { + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $2 + WHERE code = $1 AND tare_weight_tons IS NULL;`, + [code, tareWeightTons], + ); + } + + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $1 + WHERE tare_weight_tons IS NULL;`, + [this.fallbackTareTons], + ); + + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons SET NOT NULL;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons DROP NOT NULL;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts new file mode 100644 index 000000000..0fe3569f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon spec belongs to the wagon TYPE, not to each physical wagon. + * + * `wagons.tare_weight` and `wagons.max_payload_weight` duplicated + * `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows, + * with nothing keeping them in step. They had drifted completely: every wagon + * disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a + * third disagreed on payload (NW5 wagons claiming 22T–70T against a flat 70T). + * None of those numbers came from the railway. + * + * Nothing reads them for capacity — that math resolves tare and capacity through + * `wagon_type_id` — so dropping them removes a source of fiction rather than a + * source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is + * always reachable. + * + * A wagon re-tared after repair would need a nullable override column on + * `wagons` falling back to the type; deliberately not added, since no such + * per-wagon value exists today. + */ +export class DropWagonSpecColumns2080000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS tare_weight, + DROP COLUMN IF EXISTS max_payload_weight; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-add nullable, backfill from the owning type, then restore NOT NULL. + // The pre-drop values were drifted seed data and are not recoverable — the + // type's spec is what they should always have held. + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS tare_weight NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS max_payload_weight NUMERIC(10, 2); + `); + await queryRunner.query(` + UPDATE freight.wagons w + SET tare_weight = t.tare_weight_tons, + max_payload_weight = t.capacity_tons + FROM freight.wagon_types t + WHERE t.id = w.wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + ALTER COLUMN tare_weight SET NOT NULL, + ALTER COLUMN max_payload_weight SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts new file mode 100644 index 000000000..2bf9f82fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + NotFoundException, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto"; +import { CustomerResetService } from "./customer-reset.service"; + +/** + * Staff-triggered password reset. The customer receives the code and sets their + * own password — staff never see or handle a credential. + */ +@ApiTags("backoffice") +@Controller("backoffice/customers") +@ApiBearerAuth() +export class CustomerResetController { + constructor(private readonly customerResetService: CustomerResetService) {} + + @Post(":companyId/reset-password") + @BookingStaff(FREIGHT_PERMS.customers.resetPassword) + @ApiOperation({ + summary: "Send a password-reset code to a customer's primary contact", + }) + async resetPassword( + @Param("companyId", ParseUUIDPipe) companyId: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + const maskedTarget = await this.customerResetService.sendResetToCustomer( + companyId, + dto.channel, + ); + + if (!maskedTarget) { + throw new NotFoundException( + `No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`, + ); + } + + return { channel: dto.channel, maskedTarget }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts new file mode 100644 index 000000000..4c8f67599 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ExternalProfile } from "../companies/entities/external-profile.entity"; +import { ResetChannel } from "./dto/forgot-password.dto"; +import { ForgotPasswordService } from "./forgot-password.service"; + +@Injectable() +export class CustomerResetService { + private readonly logger = new Logger(CustomerResetService.name); + + constructor( + @InjectRepository(ExternalProfile) + private readonly externalProfileRepository: Repository, + private readonly forgotPasswordService: ForgotPasswordService, + ) {} + + /** + * Send a reset code to the company's primary contact. Returns the masked + * destination, or null when there is no eligible account for that channel. + * + * Unlike the public flow this reports failure honestly — the caller is an + * authenticated staff member, so there is nothing to enumerate. + */ + async sendResetToCustomer( + companyId: string, + channel: ResetChannel, + ): Promise { + const profile = await this.externalProfileRepository.findOne({ + where: { companyId, isPrimaryContact: true }, + }); + + if (!profile) { + this.logger.warn(`Company ${companyId} has no primary contact profile`); + return null; + } + + // Resolve through the same active-account gate the public flow uses, so a + // suspended customer cannot be reactivated by a staff-triggered reset. + const user = await this.forgotPasswordService.resolveActiveUserById( + profile.userId, + ); + if (!user) { + this.logger.warn( + `Primary contact ${profile.userId} of company ${companyId} is not an active account`, + ); + return null; + } + + const target = await this.forgotPasswordService.requestReset(user, channel); + if (!target) return null; + + this.logger.log( + `Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`, + ); + return this.forgotPasswordService.maskTarget(target); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts new file mode 100644 index 000000000..be2f9bdac --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; + +/** The channel the reset code is delivered over. */ +export enum ResetChannel { + Email = "email", + Phone = "phone", +} + +export class ForgotPasswordRequestDto { + @ApiProperty({ + description: "Email, username, or phone number of the account to reset", + example: "name@company.com", + }) + @IsString() + @IsNotEmpty() + identifier!: string; + + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} + +export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto { + @ApiProperty({ description: "The 6-digit code sent to the chosen channel" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class BackofficeResetPasswordDto { + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts new file mode 100644 index 000000000..da49982d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Logger, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { + ForgotPasswordRequestDto, + ForgotPasswordVerifyDto, +} from "./dto/forgot-password.dto"; +import { ForgotPasswordService, ResetTicket } from "./forgot-password.service"; + +/** + * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only + * ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which + * this API does not set). These routes drive freight's own email-or-phone OTP + * service instead, then hand back a ticket for IAM's public `set-password`. + */ +@ApiTags("auth") +@Controller("auth") +@Public() +export class ForgotPasswordController { + private readonly logger = new Logger(ForgotPasswordController.name); + + constructor(private readonly forgotPasswordService: ForgotPasswordService) {} + + @Post("forgot-password/request") + @ApiOperation({ + summary: "Send a password-reset code over email or SMS", + description: + "Always reports success. An unknown, inactive, or channel-less account is " + + "indistinguishable from a real one, so this cannot be used to enumerate accounts.", + }) + async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> { + const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier); + + if (user) { + try { + await this.forgotPasswordService.requestReset(user, dto.channel); + } catch (error) { + // A delivery failure must not change the response shape either — log it + // and let the caller sit on the OTP screen. + this.logger.error( + `Reset code delivery failed for user ${user.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } + } else { + this.logger.log("Reset requested for an unknown or inactive account"); + } + + return { success: true }; + } + + @Post("forgot-password/verify") + @ApiOperation({ + summary: "Exchange a valid reset code for a single-use set-password ticket", + description: + "The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " + + "alongside the same identifier and the new password.", + }) + verify(@Body() dto: ForgotPasswordVerifyDto): Promise { + return this.forgotPasswordService.verifyAndMintTicket( + dto.identifier, + dto.channel, + dto.otp, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts new file mode 100644 index 000000000..42dc723d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -0,0 +1,169 @@ +import { randomBytes } from "node:crypto"; + +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, Repository } from "typeorm"; + +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity"; + +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { ResetChannel } from "./dto/forgot-password.dto"; + +/** + * How long the reset ticket minted for `PATCH /api/auth/set-password` stays + * valid. The IAM `setPassword` handler enforces this via `expiresAt`. + */ +const RESET_TICKET_TTL_MS = 10 * 60 * 1000; + +/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */ +const RESET_OTP_TTL_MS = 10 * 60 * 1000; + +export interface ResetTicket { + userId: string; + verificationCode: string; +} + +@Injectable() +export class ForgotPasswordService { + private readonly logger = new Logger(ForgotPasswordService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Resolve an account that is actually eligible for a password reset. + * + * IAM's `set-password` handler flips `isActive: true` on the user as a side + * effect, so a reset on a deactivated account would silently resurrect it. + * Gating here — rather than at the set-password call — is what keeps that + * from being reachable. Mirrors IAM's own login lookup: match on any of + * email / username / phone, and require an active credential row. + */ + async resolveActiveUser(identifier: string): Promise { + const id = identifier.trim(); + if (!id) return null; + + return await this.activeUserQuery() + .andWhere( + "(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)", + { id }, + ) + .getOne(); + } + + /** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */ + async resolveActiveUserById(userId: string): Promise { + if (!userId) return null; + return await this.activeUserQuery() + .andWhere("u.id = :userId", { userId }) + .getOne(); + } + + /** + * Base query for accounts eligible to reset. `.where()` is claimed here so + * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause, + * which would silently drop the `isActive` gate. + */ + private activeUserQuery() { + return this.userRepository + .createQueryBuilder("u") + .innerJoin("u.userCredentials", "uc", "uc.isActive = true") + .where("u.isActive = true") + .orderBy("u.createdAt", "DESC"); + } + + /** The address the code goes to, taken from the account — never from input. */ + private targetFor(user: User, channel: ResetChannel): OtpTarget | null { + if (channel === ResetChannel.Email) { + return user.email ? { email: user.email } : null; + } + return user.phoneNumber ? { phone: user.phoneNumber } : null; + } + + /** + * Send a reset code to the account's own email/phone. Returns the target so + * authenticated (backoffice) callers can echo a masked version; unauthenticated + * callers must discard it. + * + * Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp` + * upserts. A reset request therefore overwrites any pending signup code for + * the same address — last code sent wins. That is the pre-existing behaviour + * between any two flows sharing this table. + */ + async requestReset( + user: User, + channel: ResetChannel, + ): Promise { + const target = this.targetFor(user, channel); + if (!target) return null; + + await this.otpService.sendOtp(target); + return target; + } + + /** + * Prove possession of the OTP, then mint an IAM reset ticket the caller can + * spend on the public `PATCH /api/auth/set-password`. + * + * Minting a `UserVerification` row rather than writing `UserCredential` + * ourselves keeps IAM as the single owner of the password write path (old + * credential deactivation, argon hashing, changed-at bookkeeping). + */ + async verifyAndMintTicket( + identifier: string, + channel: ResetChannel, + otp: string, + ): Promise { + const user = await this.resolveActiveUser(identifier); + const target = user && this.targetFor(user, channel); + + if (!user?.id || !target) { + // Same shape as a wrong code: a caller probing for accounts learns nothing + // beyond what the request step already (deliberately) refuses to tell them. + throw new BadRequestException("Invalid verification code"); + } + + await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS); + + const code = randomBytes(24).toString("base64url"); + const verificationCode = await hashPassword(code); + const userId = user.id; + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(UserVerification); + // Retire any outstanding codes so only the ticket we just minted can be + // spent — `findVerificationForPrimaryReset` reads the newest row. + await repo.update({ userId }, { isUsed: true }); + await repo.insert({ + userId, + otpType: EOtpType.RESET_PASSWORD, + verificationCode, + expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS), + isUsed: false, + attemptCount: 0, + }); + }); + + this.logger.log(`Reset ticket minted for user ${userId}`); + return { userId, verificationCode: code }; + } + + /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ + maskTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 16fbeffda..6415cf4c1 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -2,15 +2,35 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; +import { ExternalProfile } from '../companies/entities/external-profile.entity'; +import { OtpModule } from '../otp/otp.module'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; +import { CustomerResetController } from './customer-reset.controller'; +import { CustomerResetService } from './customer-reset.service'; +import { ForgotPasswordController } from './forgot-password.controller'; +import { ForgotPasswordService } from './forgot-password.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], - controllers: [FreightMeController, CheckAvailabilityController], - providers: [FreightMeService, CheckAvailabilityService], + imports: [ + TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + OtpModule, + ], + controllers: [ + FreightMeController, + CheckAvailabilityController, + ForgotPasswordController, + CustomerResetController, + ], + providers: [ + FreightMeService, + CheckAvailabilityService, + ForgotPasswordService, + CustomerResetService, + ], }) export class FreightAuthModule {} 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 79d94f3de..f556751fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -68,6 +68,8 @@ 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 { FirstMileService } from '../first-mile/first-mile.service'; +import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -81,6 +83,60 @@ import { hasFreightPermission, } from "../../common/freight-permission.util"; +interface MileVehicleSummary { + plate: string | null; + code: string | null; + driverName: string | null; + containerNumber: string | null; + distanceKm: number | null; +} + +interface MileLegSummary { + status: string; + exactKm: number | null; + remainingPayment: number | null; + currency: string; + invoiced: boolean; + vehicles: MileVehicleSummary[]; +} + +/** Trim a first/last-mile record down to a customer-safe operational summary. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function summarizeMileLeg(rec?: Record): MileLegSummary | null { + if (!rec) return null; + const num = (v: unknown) => (v == null ? null : Number(v)); + const assignments: Array> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any + const currency = + rec.vehicle?.currency ?? + assignments[0]?.vehicle?.currency ?? + rec.booking?.paymentCurrency ?? + 'ETB'; + const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ + plate: a.vehicle?.plateNumber ?? null, + code: a.vehicle?.code ?? null, + driverName: a.vehicle?.assignedDriverName ?? null, + containerNumber: a.containerNumber ?? null, + distanceKm: num(a.distanceKm), + })); + if (!vehicles.length && rec.vehicle) { + vehicles.push({ + plate: rec.vehicle.plateNumber ?? null, + code: rec.vehicle.code ?? null, + driverName: rec.vehicle.assignedDriverName ?? null, + containerNumber: null, + distanceKm: num(rec.exactKm), + }); + } + return { + status: rec.status ?? '', + exactKm: num(rec.exactKm), + remainingPayment: num(rec.remainingPayment), + currency, + invoiced: Boolean(rec.invoice), + vehicles, + }; +} + @ApiTags("bookings") @Controller("bookings") @ApiBearerAuth() @@ -94,6 +150,8 @@ export class BookingsController { private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, private readonly containerReceiptService: ContainerReceiptService, + private readonly firstMileService: FirstMileService, + private readonly lastMileService: LastMileService, ) {} @Post() @@ -290,6 +348,33 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/mile-summary') + @ApiOperation({ + summary: 'First/last-mile operational summary for a booking (customer-safe)', + }) + async mileSummary( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Customers may only see their own booking's mile summary. + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + + const [first, last] = await Promise.all([ + this.firstMileService.findAll({ bookingId: id, pageSize: 1 }), + this.lastMileService.findAll({ bookingId: id, pageSize: 1 }), + ]); + return { + firstMile: summarizeMileLeg(first.data[0]), + lastMile: summarizeMileLeg(last.data[0]), + }; + } + @Post(':id/customer-truck-assignment') @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) async assignCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 48c5b628b..4b3e3bcca 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentController } from './booking-payment.controller'; @@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; NotificationsModule, NotificationInboxModule, forwardRef(() => FirstMileModule), + forwardRef(() => LastMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), forwardRef(() => ContractsModule), diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index fa11fc66c..087786715 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository { if (!statuses.length) return []; return this.repository.find({ where: { status: In(statuses) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + }, order: { createdAt: 'DESC' }, }); } @@ -993,6 +998,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') @@ -1023,6 +1029,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id = :originYardId', { originYardId }) .andWhere('booking.destination_yard_id = :destinationYardId', { @@ -1061,6 +1068,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { @@ -1125,6 +1133,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') @@ -1138,6 +1147,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .getMany(); 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 578e73278..48a29988a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1424,12 +1424,14 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned handover means the customer must approve delivery. - // Surfaced so the portal shows "Approve delivery" as soon as the handover - // exists, independent of the truck-arrival flag. + // A generated-but-unsigned SELF_HAUL handover means the customer must approve + // delivery from the portal (booking-based, one per booking). EDR last-mile + // handovers are per delivering truck and signed by the receiver at the door, + // so they never surface the portal "Approve delivery" action. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); 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 14402f830..4e364a03a 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 @@ -312,6 +312,13 @@ export class CustomerTruckService { if (assignment.departedAt) { throw new ConflictException('This truck has already left — its load is locked'); } + // Containers can only be loaded after the truck has physically arrived at the + // warehouse (arrival weighing recorded). Assignment alone is just planning. + if (!assignment.arrivedAt) { + throw new BadRequestException( + 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck', + ); + } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!requested.length) { @@ -333,12 +340,16 @@ export class CustomerTruckService { const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + // Operator loading the truck: stamp loaded_at so these containers move to + // the LOADED stage (customer assignment alone leaves loaded_at null). + const loadedAt = new Date(); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId, bookingId, containerNumber, + loadedAt, }), ), ); diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts index 110e31671..8b6ecc8b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity { @Column({ name: 'container_number', type: 'varchar', length: 64 }) containerNumber!: string; + + /** + * When the container was actually loaded onto the truck by the operator. + * Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires + * this to be set, so customer assignment alone does not mark a container loaded. + */ + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts new file mode 100644 index 000000000..73271e0af --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -0,0 +1,88 @@ +import { Repository } from "typeorm"; + +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; + +type Row = Pick & { createdAt: Date }; + +const COMPANY_ID = "company-1"; + +/** + * Stands in for the TypeORM repository over a fixed set of rows, honouring the + * `where.status` filter and the `createdAt DESC` ordering findOne relies on. + */ +function mockRepositoryOver(rows: Row[]) { + return { + findOne: jest.fn( + ({ where }: { where: Partial & { companyId: string } }) => + Promise.resolve( + rows + .filter( + (row) => + where.companyId === COMPANY_ID && + (where.status === undefined || row.status === where.status), + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? + null, + ), + ), + } as unknown as Repository; +} + +function subject(rows: Row[]) { + return new CompanyChangeRequestRepository(mockRepositoryOver(rows)); +} + +describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => { + const rejected: Row = { + id: "rejected", + status: ChangeRequestStatus.Rejected, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + + it("returns the pending request when one is open", async () => { + const pending: Row = { + id: "pending", + status: ChangeRequestStatus.Pending, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([rejected, pending]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("pending"); + }); + + it("returns the latest rejected request when nothing is pending", async () => { + const result = await subject([rejected]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("rejected"); + }); + + it("returns null once a resubmit of a rejected request is approved", async () => { + const approved: Row = { + id: "approved", + status: ChangeRequestStatus.Approved, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([ + rejected, + approved, + ]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); + + it("returns null when the company has no requests", async () => { + const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index 24d988452..eb44d56cb 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository { const pending = await this.findPendingByCompanyId(companyId); if (pending) return pending; - return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Rejected }, + const latest = await this.repository.findOne({ + where: { companyId }, order: { createdAt: "DESC" }, }); + return latest?.status === ChangeRequestStatus.Rejected ? latest : null; } async findById(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index 15aec5ac6..9bf14cd61 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; @@ -20,6 +20,11 @@ const PREFIX_MAP: Record = { [ProfileType.transporter]: "TR", }; +const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** Numbers per series letter: A00001..A99999, then B00001. */ +const SERIES_SIZE = 99_999; + @Injectable() export class CompanyProfileRepository extends BaseRepository { constructor( @@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository { const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); - const nextId = result[0].next_id as number; + const nextId = Number(result[0].next_id); + const offset = nextId - 1; + const seriesIndex = Math.floor(offset / SERIES_SIZE); + + if (seriesIndex >= SERIES_LETTERS.length) { + throw new InternalServerErrorException( + `Company profile reference series exhausted for type "${type}"`, + ); + } + + const letter = SERIES_LETTERS[seriesIndex]; + const number = (offset % SERIES_SIZE) + 1; const prefix = PREFIX_MAP[type]; - return `${prefix}-${String(nextId).padStart(5, "0")}`; + return `${prefix}-${letter}${String(number).padStart(5, "0")}`; } async findByCompanyId(companyId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 72696766f..2266b0c65 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -60,7 +60,7 @@ export class CompanyProfile extends BaseEntity { type!: ProfileType; /** - * Official profile reference (e.g. "EX-00001"). Minted only when the profile + * Official profile reference (e.g. "EX-A00001"). Minted only when the profile * is approved (status → Active); pending/unapproved profiles carry NULL. * The unique index tolerates this because Postgres treats NULLs as distinct. * API responses surface it as "" when absent — see ResponseCompanyProfileDto. diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts new file mode 100644 index 000000000..eb77533ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts @@ -0,0 +1,68 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; + +type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED'; + +/** + * Risk assignment is gated on the T1 being closed (catalog order + * T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit. + */ +function makeService(t1Status: Status | 'MISSING') { + const rows = new Map(); + if (t1Status !== 'MISSING') { + rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone); + } + const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone; + rows.set('RISK_ASSIGNED', risk); + + const repo = { + findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) => + Promise.resolve(rows.get(where.milestoneCode) ?? null), + ), + save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)), + }; + const dataSource = { getRepository: () => repo } as unknown as DataSource; + return { service: new ClearanceMilestoneService(dataSource), repo, risk }; +} + +describe('ClearanceMilestoneService.assignRisk', () => { + it('rejects the assignment while the T1 is still open', async () => { + const { service, repo } = makeService('PENDING'); + + await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => { + const { service, repo } = makeService('MISSING'); + + await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('assigns the risk level once the T1 is closed', async () => { + const { service, risk } = makeService('COMPLETED'); + + const saved = await service.assignRisk('b-1', 'RED', 'user-1'); + + expect(saved.status).toBe('COMPLETED'); + expect(saved.metadata?.riskLevel).toBe('RED'); + expect(risk.triggeredByUserId).toBe('user-1'); + }); + + it('assigns the risk level when the T1 step was skipped', async () => { + const { service } = makeService('SKIPPED'); + + const saved = await service.assignRisk('b-1', 'YELLOW'); + + expect(saved.status).toBe('COMPLETED'); + expect(saved.metadata?.riskLevel).toBe('YELLOW'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 4a58e50be..81ed305e4 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { @@ -181,6 +181,10 @@ export class ClearanceMilestoneService { * Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED * milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the * milestone metadata so the timeline shows it. + * + * Customs cannot risk-rate cargo still moving under transit: the T1 must be + * closed (accepted by GL Ethiopia after the train arrives) first, which is the + * catalog order T1_CLOSED → RISK_ASSIGNED. */ async assignRisk( bookingId: string, @@ -188,9 +192,22 @@ export class ClearanceMilestoneService { userId?: string, note?: string, ): Promise { + await this.assertT1Closed(bookingId); return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note); } + /** Guard: the booking's T1 must be closed before customs risk can be assigned. */ + private async assertT1Closed(bookingId: string): Promise { + const t1 = await this.repo.findOne({ + where: { bookingId, milestoneCode: 'T1_CLOSED' }, + }); + if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') { + throw new BadRequestException( + 'The T1 must be closed before a customs risk level can be assigned.', + ); + } + } + /** * Advise duty & tax (amount + declaration serial) and complete the * DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the 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 55dd5c29f..46fb61db1 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 @@ -122,6 +122,15 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // GENERAL without customs (Path A) ALSO clears per booking: the customer + // uploads his own clearance proof on each booking and Operations reviews it + // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → + // requestOperation machine). DOMESTIC has no border, so no gate. + const generalSelfClear = + contract.contractKind === 'GENERAL' && + !contract.customsClearingEnabled && + contract.tradeDirection !== 'DOMESTIC'; + // 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. @@ -140,9 +149,10 @@ export class ContractBookingService { // 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 && !isIntercity) { + // export: within exportBookingLeadHours of departure. Bookings that enter the + // clearance gate first (Path B customs AND Path A per-booking self-clearance) + // are scheduled later, so they are not gated here. + if (!generalCustoms && !generalSelfClear && !isIntercity) { await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, @@ -174,7 +184,10 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING', + status: + generalCustoms || generalSelfClear + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -259,9 +272,10 @@ export class ContractBookingService { const withContainers = await this.bookingsRepository.findByIdWithFiles( booking.id, ); - const intendedStatus = generalCustoms - ? 'AWAITING_DOCUMENTS' - : 'OPERATION_REQUEST_PENDING'; + const intendedStatus = + generalCustoms || generalSelfClear + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING'; if ( withContainers && freightType === 'CONTAINER' && diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9f12b937d..5b0db20cb 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -582,7 +582,7 @@ export class ContractTransitionService { if (!dto.otpPhone || !dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); + await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', @@ -632,16 +632,15 @@ export class ContractTransitionService { contract.customsClearingEnabled ?? false, ); - // GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract - // level: there is no contract clearance cycle. The contract just becomes - // active; the customer then files shipment requests and GL books + clears - // each one. ONE_TIME customs and Path A self-clearance keep the contract - // cycle below. - const isGeneralCustoms = - contract.contractKind === 'GENERAL' && - Boolean(contract.customsClearingEnabled); + // GENERAL contracts run clearance PER BOOKING, not at the contract level — + // both paths. Customs (Path B): the customer files shipment requests, GL + // books each one and the booking carries its own clearance. Self-clearance + // (Path A): the customer books, then uploads the clearance docs on that + // booking for Operations to review. Only ONE_TIME contracts keep the + // contract-level cycle below. + const isGeneral = contract.contractKind === 'GENERAL'; - if (clearanceCode && !isGeneralCustoms) { + if (clearanceCode && !isGeneral) { // Open a clearance cycle, seed the pre-booking milestones, and route the // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the // distinction is enforced at the review/finalize endpoints, not here. @@ -652,8 +651,8 @@ export class ContractTransitionService { updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { - // No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which - // clears per booking). Ready for shipment requests / direct booking. + // No contract-level clearance gate — DOMESTIC, or any GENERAL contract + // (which clears per booking). Ready for shipment requests / direct booking. updates.status = contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; updates.clearanceStatus = 'NOT_APPLICABLE'; 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 e3130da95..f220cae0d 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 @@ -9,14 +9,19 @@ import { IsOptional, IsString, IsUUID, + Matches, Min, ValidateNested, } from 'class-validator'; /** One physical container under a booking line — entered at booking time. */ export class CreateContainerUnitDto { - @ApiProperty() + @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @IsString() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value)) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumber must match ISO container format, e.g. ABCD1234567', + }) containerNumber!: string; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/gps-tracking/README.md b/apps/edr-freight-api/src/modules/gps-tracking/README.md new file mode 100644 index 000000000..668b581b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/README.md @@ -0,0 +1,150 @@ +# GPS Tracking (GT06) — Operations & Device Configuration + +GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes +everything about how the service is deployed and how devices are pointed at it. + +--- + +## 1. Why GPS needs its own dedicated TCP port + +- **Not HTTP.** GT06 devices send binary frames + (`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`). + An HTTP server receiving these answers `400 Bad Request` and closes. +- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two + listeners on the same pair collide (`EADDRINUSE`). The REST API already owns + its port, so GPS traffic needs a separate one. +- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so + L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by + domain. Routing must happen at **Layer 4 (TCP)** by port. +- **DNS carries no port.** An A record maps a name to an IP only. The tracker + config must state the port explicitly (e.g. `gps.example.com:5023`). + +### Operational requirements + +| Item | Value | +| --- | --- | +| Protocol | Raw TCP (not HTTP, not TLS) | +| Default port | `5023` (configurable via `GT06_TCP_PORT`) | +| Listener bind | `0.0.0.0` inside the `freight-gps` container | +| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. | + +--- + +## 2. Port configuration + +`5023` is only this project's default — **not** a GT06 protocol requirement. The +listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured +with the same number. + +Host and container ports are decoupled in `docker-compose.yaml`: + +```yaml +freight-gps: + ports: + - "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed + environment: + GT06_TCP_PORT: "5023" # pinned inside the container +``` + +- The **container** always listens on `5023`. +- The **host/public** port is configurable (443, 5023, 9000, …) via the root + `.env`'s `GT06_TCP_PORT`. +- This split is required because the image runs as a **non-root** user + (`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the + host port and forwards to `5023` inside. +- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the + actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`. +- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that + expect a TLS handshake on 443 may drop the connection. + +--- + +## 3. Deployment topology + +The GT06 listener runs as its own process (`dist/main.gps.js`, module +`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight` +DB with the API; the DB is the seam (ingester writes `gps_devices` / +`gps_positions`, API reads them). + +``` +freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations) +freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket) +``` + +`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations. + +Horizontal scale: each tracker holds one long-lived TCP connection with +per-socket session state, so N `freight-gps` replicas can run behind an L4 LB — +each device sticks to one replica. `ensureDevice` is safe under concurrency +(unique IMEI). + +--- + +## 4. Device configuration (GT06 side) + +Config is done by **SMS to the tracker's SIM**. Commands below are the canonical +Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware. +Default command password is usually `123456`. + +Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN. + +``` +STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS +APN,# # 2. carrier data APN (add ,user,pass if needed) +SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT +GPRSON,1# # 4. enable data +GPSON,1# # enable GPS +TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#) +RESET# # 6. reboot so it reconnects (many cache DNS until reboot) +``` + +Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#` +Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#` + +### Verify from the server + +```bash +docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up" +nc -vz gps.example.com 5023 +curl -H "Authorization: Bearer " https://api.example.com/api/gps/positions/latest +``` + +First login packet **auto-registers** the IMEI (no manual step). `online:true` +only when `lastSeenAt` < 5 min (computed at read time). + +### Link a tracker to a vehicle (optional) + +Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`): + +``` +PATCH /api/gps/devices/:id { "vehicleId": "", "name": "Truck 03-ET" } +``` + +### Failure map + +| Symptom | Cause | +| --- | --- | +| No SMS reply | SIM PIN on / no signal / wrong number | +| Replies but never connects | APN wrong, or `SERVER` port ≠ `GT06_TCP_PORT` | +| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS | +| Registered but `online:false` | packets blocked by firewall — open inbound TCP | +| Wrong location / `positioned:false` | no GPS fix yet — open sky, cold start ~1–2 min | + +--- + +## 5. Security + +- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can + reach the port can inject fake positions. +- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security + group to the SIM provider's **APN / IP range**. +- Trackers must use the same host+port as the server: + `SERVER,1,gps.example.com,,0#`. + +--- + +## 6. Edge (L4) termination + +See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf) +for an Nginx `stream {}` example, and the AWS NLB notes in the same file. +Reminder: **L4 only** — an HTTP proxy cannot route GT06. diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index e380e541e..8bd4a31d8 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -11,14 +11,15 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { GpsTrackingService } from './gps-tracking.service'; import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@FleetView() +@BookingStaff(FREIGHT_PERMS.tracking.view) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} @@ -44,21 +45,21 @@ export class GpsTrackingController { } @Post('devices') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Register a GPS tracker' }) register(@Body() dto: RegisterDeviceDto) { return this.gps.registerDevice(dto); } @Patch('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { return this.gps.updateDevice(id, dto); } @Delete('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Delete a GPS tracker' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.gps.removeDevice(id); diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts index 66d0100d0..d46622ba4 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -49,6 +49,23 @@ export class CreateLocomotiveDto { @Min(0) maxTrainLengthMeters!: number; + // Allowed deviation above maxPullWeightTons before scheduling blocks the train + // (e.g. 90 lets a 3,500T-rated locomotive pull up to 3,590T). Omit/0 = strict cap. + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + overageToleranceTons?: number; + + // Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. + @ApiPropertyOptional({ example: 0 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + overageToleranceMeters?: number; + @ApiPropertyOptional({ example: 4200 }) @IsOptional() @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 6dcd9ad3e..d3214b6c4 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -39,6 +39,26 @@ export class Locomotive extends BaseEntity { @Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 }) maxTrainLengthMeters!: number; + /** Allowed deviation above maxPullWeightTons before a train is blocked (e.g. the 37th PW2 wagon in the fertilizer example runs 90T over 3,500T and is still accepted). Null/0 = no tolerance. */ + @Column({ + name: 'overage_tolerance_tons', + type: 'numeric', + precision: 10, + scale: 3, + nullable: true, + }) + overageToleranceTons?: number | null; + + /** Allowed deviation above maxTrainLengthMeters before a train is blocked. Null/0 = no tolerance. */ + @Column({ + name: 'overage_tolerance_meters', + type: 'numeric', + precision: 10, + scale: 3, + nullable: true, + }) + overageToleranceMeters?: number | null; + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) status!: LocomotiveStatus; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index ae9a41608..65700fd93 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -64,6 +64,8 @@ export class LocomotivesService { maxPullWeightTons: dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS, maxTrainLengthMeters: dto.maxTrainLengthMeters, + overageToleranceTons: dto.overageToleranceTons ?? null, + overageToleranceMeters: dto.overageToleranceMeters ?? null, powerKw: dto.powerKw ?? null, tractionForceKn: dto.tractionForceKn ?? null, maxSpeedKmh: dto.maxSpeedKmh ?? null, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts index 1d7fd27fc..80fecf1bf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -13,8 +13,10 @@ import { Patch, Post, Query, + UseGuards, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; import { AuthUserPayload, @@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; import { NotificationInboxService } from "./notification-inbox.service"; @ApiTags("notifications") +@ApiBearerAuth() +@UseGuards(JwtGuard) @Controller("notifications") export class NotificationInboxController { constructor(private readonly service: NotificationInboxService) {} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index c14dcbaa5..0c4704170 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service"; /** * Server → client push for in-app notifications. Clients only *listen* (no - * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here; - * the handshake is authenticated in `handleConnection` and each socket joins a - * private `user:` room the service targets. + * `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST + * controller does not cover WebSockets; the handshake is authenticated in + * `handleConnection` and each socket joins a private `user:` room the + * service targets. */ @WebSocketGateway({ namespace: NOTIFICATION_WS_NAMESPACE, diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index d8404e84c..cddc67b2d 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy { this.logger.debug(`Sending SMS to ${recipient} via ${url}`); + // axios defaults to no timeout — a hanging gateway would block the caller + // (and any transaction it sits in) indefinitely. Always bound the wait. + const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); + try { const response = await axios.post( url, @@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy { callbackUrl: "", }, { + timeout, headers: { accept: "*/*", "Content-Type": "application/json", diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index e26ed35c9..b98b41d8d 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -50,6 +50,9 @@ export class OtpService { await this.otpRepository.createOtp(target, otp); } + // A freshly issued code gets a fresh guess budget. + this.actionAttempts.delete(this.targetKey(target)); + if (target.email) { // send email (queued to RabbitMQ via the shared Email service) await this.emailClient.sendEmail({ @@ -121,24 +124,44 @@ export class OtpService { // --------------------------------------------------------------------------- // Fresh, single-use challenge gating a sensitive action (e.g. applying a - // contract signature). Unlike verifyOtp above — which marks a phone verified - // and leaves the code in place — this enforces a short TTL and consumes the - // code on success so it can never be replayed. + // contract signature, resetting a forgotten password). Unlike verifyOtp above + // — which marks a target verified and leaves the code in place — this enforces + // a TTL and consumes the code on success so it can never be replayed. private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; - async verifyOtpForAction(phone: string, otp: string) { - const otpData = await this.otpRepository.findByPhone(phone); + // Without a cap, a 6-digit code guarding a password reset is brute-forceable + // within its own TTL. `otp_verifications` has no attempt column, so the + // counter lives here and the code is burned once the budget is spent. + // Per-process: it resets on restart and is not shared across replicas — a + // persisted counter needs a migration on OtpVerification. + private readonly MAX_ACTION_ATTEMPTS = 5; + private readonly actionAttempts = new Map(); + + private targetKey(target: OtpTarget): string { + return target.email ? `email:${target.email}` : `phone:${target.phone}`; + } + + async verifyOtpForAction( + target: OtpTarget, + otp: string, + ttlMs: number = this.ACTION_OTP_TTL_MS, + ) { + const otpData = await this.otpRepository.findByTarget(target); + const key = this.targetKey(target); if (!otpData) { throw new BadRequestException( - "No verification code was requested for this phone", + target.email + ? "No verification code was requested for this email" + : "No verification code was requested for this phone", ); } const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); - if (ageMs > this.ACTION_OTP_TTL_MS) { + if (ageMs > ttlMs) { await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); throw new BadRequestException( "Verification code has expired. Request a new one.", @@ -146,11 +169,24 @@ export class OtpService { } if (otpData.otp !== otp) { + const attempts = (this.actionAttempts.get(key) ?? 0) + 1; + + if (attempts >= this.MAX_ACTION_ATTEMPTS) { + await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); + + throw new BadRequestException( + "Too many incorrect attempts. Request a new code.", + ); + } + + this.actionAttempts.set(key, attempts); throw new BadRequestException("Invalid verification code"); } // single-use: consume on success await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); return { success: true }; } 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 a79a54503..23399bb4d 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 @@ -39,12 +39,17 @@ export class Route extends BaseEntity { milestones?: RouteMilestone[]; } +/** + * Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa", + * not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the + * machine identifier and is only a fallback for a yard missing one. + */ export function formatRouteLabel(route: { - originYard?: { code?: string; name?: string } | null; - destinationYard?: { code?: string; name?: string } | null; + originYard?: { code?: string; label?: string } | null; + destinationYard?: { code?: string; label?: string } | null; }): string { - const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin'; - const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination'; + const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin'; + const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination'; return `${origin} → ${dest}`; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 700a38983..a64faabbe 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -22,7 +22,9 @@ export class TrainSchedulesRepository extends BaseRepository { return this.repo(manager).findOne({ where: { id }, relations: { - route: true, + // Yards carry the route's display name; without them formatRouteLabel + // degrades to the literal "Origin → Destination". + route: { originYard: true, destinationYard: true }, trainSet: { locomotive: true, locomotives: { locomotive: true }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index d8dc6b116..62ed50c0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -24,3 +24,23 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14; /** Default CW3 covered wagon length for bulk bookings (m). */ export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14; + +/** + * Fallback tare weights (T) matching the length fallbacks above. The locomotive + * pull limit is a GROSS limit, so a booking's weight budget must include the + * empty weight of every wagon it occupies — not just its cargo. + */ +export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4; + +/** Default CW3 gondola tare for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4; + +/** + * Fallback rated payloads (T) matching the tare fallbacks above. A bulk booking's + * wagon count is its cargo divided by this, so a zero here would make the count + * infinite — callers must floor it at a positive number. + */ +export const DEFAULT_CONTAINER_WAGON_CAPACITY_TONS = 70; + +/** Default CW3 gondola rated payload for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_CAPACITY_TONS = 60; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 31d3c8855..a20ef600d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.Mock; }; let trainSchedulesRepository: { + findById: jest.Mock; findByIdWithFullGraph: jest.Mock; findAll: jest.Mock; }; @@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { + findById: jest.fn().mockResolvedValue({ + id: scheduleId, + bookingWindowStatus: 'OPEN', + windowPhase: null, + }), findByIdWithFullGraph: jest.fn().mockResolvedValue({ id: scheduleId, maxWagons: 10, @@ -163,7 +169,7 @@ describe('BookingBatchService — PAID reconcile', () => { }); it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { - const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined); + const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined); @@ -181,6 +187,77 @@ describe('BookingBatchService — PAID reconcile', () => { expect(reconcileOrder).toBeLessThan(wagonOrder); }); + describe('extendPaymentPhaseForTopUp', () => { + const schedRepo = () => dataSource.getRepository(); + + it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => { + const soon = new Date(Date.now() + 5_000); // phase almost over + const departure = new Date(Date.now() + 24 * 3_600_000); + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: soon, + scheduledDepartureDate: departure, + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + // paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon. + expect(schedRepo().update).toHaveBeenCalledWith( + scheduleId, + expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }), + ); + const [, patch] = schedRepo().update.mock.calls.at(-1)!; + expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan( + soon.getTime(), + ); + }); + + it('does not pull the deadline in when the current end is already later', async () => { + const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: far, + scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000), + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + expect(schedRepo().update).not.toHaveBeenCalled(); + }); + + it('is a no-op outside the PAYMENT phase', async () => { + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'OPEN', + paymentPhaseEndsAt: null, + scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000), + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + expect(schedRepo().update).not.toHaveBeenCalled(); + }); + + it('never extends past departure', async () => { + const departure = new Date(Date.now() + 60_000); // 1 min away + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date(Date.now() + 1_000), + scheduledDepartureDate: departure, + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + const [, patch] = schedRepo().update.mock.calls.at(-1)!; + expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual( + departure.getTime(), + ); + }); + }); + describe('fillRouteDay — day-level distribution', () => { const originYardId = 'yard-origin'; const destinationYardId = 'yard-dest'; @@ -336,6 +413,49 @@ describe('BookingBatchService — PAID reconcile', () => { // Never reserved — waits for its partner in a later cycle. expect(notifier.payNow).not.toHaveBeenCalled(); }); + + it('clears a stale FULL flag and fills a train whose bookings all expired', async () => { + // The deadlock: train A filled once, every booking then expired, but + // bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever + // reads the budget, so the batch skipped the train forever — it just cycled + // PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd + // already-pinned booking got settled, one per cycle. + const staleFull = { + id: trainA, + maxWagons: 1, + bookingWindowStatus: 'FULL', + // The batch runs while the customer window is closed. + windowPhase: 'PAYMENT', + direction: 'IMPORT', + trainSetId: `set-${trainA}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + originStationId: originYardId, + destinationStationId: destinationYardId, + }; + trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]); + // Live capacity says the train is empty: 1 free wagon, nothing allocated. + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull); + // refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase); + // the re-read reports it, and isFillable() admits CLOSED during PAYMENT. + trainSchedulesRepository.findById.mockResolvedValue({ + id: trainA, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + }); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ + commercial('waiting', 30), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + // The train was reopened to the batch and actually filled, not skipped. + expect(touched).toEqual([trainA]); + expect(notifier.payNow).toHaveBeenCalledTimes(1); + expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); }); describe('expireUnacceptedForRouteDay — doc-review sweep', () => { @@ -510,4 +630,177 @@ describe('BookingBatchService — PAID reconcile', () => { expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false); }); }); + + describe('settleDueReservations — expire then promote the waiting list', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const trainId = 'train-a'; + // 14m / 70t default wagon → two wagon slots on this locomotive. + const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 }; + + const booking = (id: string, priority: number, overrides = {}): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + bookingContainers: [], + originYardId, + destinationYardId, + trainScheduleId: trainId, + ...overrides, + }) as unknown as Booking; + + beforeEach(() => { + const scheduleRow = { + id: trainId, + maxWagons: 2, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + direction: 'IMPORT', + trainSetId: `set-${trainId}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + originStationId: originYardId, + destinationStationId: destinationYardId, + }; + trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow); + trainSchedulesRepository.findById.mockResolvedValue({ + id: trainId, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + scheduledDepartureDate: scheduleRow.scheduledDepartureDate, + originStationId: originYardId, + destinationStationId: destinationYardId, + }); + }); + + it('promotes a waiting booking into the wagons an expired reservation frees', async () => { + // One reservation whose pay window lapsed, and one booking on the waiting list. + const lapsed = booking('lapsed', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + const waiting = booking('waiting', 10, { trainScheduleId: null }); + + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one + .mockResolvedValue([]); // afterwards nothing is reserved + // The day pool the top-up draws from: only the waiting booking is eligible. + bookingsRepository.findBatchPoolByCorridorDay + .mockResolvedValueOnce([waiting]) + .mockResolvedValue([]); + + await service.settleDueReservations(trainId); + + // The lapsed reservation expired... + expect(notifier.expired).toHaveBeenCalledTimes(1); + expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed'); + // ...and the waiting booking was promoted in the SAME settle, not next cycle. + expect(notifier.payNow).toHaveBeenCalledTimes(1); + expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); + }); + + it('serialises concurrent settles so the same reservation is not settled twice', async () => { + const lapsed = booking('lapsed', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + // Both callers read the reservation; the lock must stop the second from + // acting on rows the first already expired. (The PAYMENT transition and the + // tick's overdue backstop do exactly this, in the same second.) + let reads = 0; + bookingsRepository.findReservedForSchedule.mockImplementation(() => { + reads += 1; + return Promise.resolve(reads === 1 ? [lapsed] : []); + }); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + + await Promise.all([ + service.settleDueReservations(trainId), + service.settleDueReservations(trainId), + ]); + + expect(notifier.expired).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('BookingBatchService — wagonsFor', () => { + // wagonsFor is pure arithmetic over its two arguments and touches no injected + // dependency, so the service can be built with none. + const service = new BookingBatchService( + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + ) as unknown as { + wagonsFor(booking: unknown, dims: unknown): number; + }; + + // PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m. + const dims = { + container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, + bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }, + }; + + const bulk = (cargoTons: number, over: Record = {}) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: cargoTons, + bookingContainers: [], + ...over, + }); + + it('sizes a bulk booking by cargo ÷ rated payload, not a flat 1 wagon', () => { + // 37 × 1400 fertilizer packages × 50kg = 2590T of cargo. + expect(service.wagonsFor(bulk(2590), dims)).toBe(37); + }); + + it('rounds a partial wagon up', () => { + expect(service.wagonsFor(bulk(70.1), dims)).toBe(2); + expect(service.wagonsFor(bulk(70), dims)).toBe(1); + }); + + it('still floors at one wagon when a bulk booking has no recorded cargo', () => { + expect(service.wagonsFor(bulk(0), dims)).toBe(1); + }); + + it('honours an explicit wagonsRequired override', () => { + expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40); + }); + + it('takes the binding axis for containers: weight can exceed TEU geometry', () => { + // Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 210, + bookingContainers: [ + { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(3); + }); + + it('keeps TEU geometry when it binds before weight', () => { + // Four 20ft units => 2 wagons by geometry; 40T of cargo needs only 1 by weight. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 40, + bookingContainers: [ + { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(2); + }); }); 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 26f8749be..93227f3ab 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 @@ -29,20 +29,30 @@ import { BillingService } from "../billing/billing.service"; import { + DEFAULT_BULK_WAGON_CAPACITY_TONS, DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, + DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, } from "./booking-batch.constants"; import { + WagonTypeDimensions, + bookingGrossWeightTons, bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, + trainHardCaps, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; -import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; +import { + MAX_TEU_SLOTS_PER_WAGON, + containerWagonsForLines, +} from './wagon-plan.util'; import { Capacity, CorridorBudget, @@ -60,7 +70,14 @@ interface RouteDayGroup { day: string; } -type WagonLengths = { container: number; bulk: number }; +/** + * Per-freight-type wagon dimensions used to size a booking's capacity draw: + * its length on the train and the tare it adds to the locomotive's gross load. + */ +type WagonDims = { + container: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; + bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; +}; export type BatchBoardBookingState = | "ALLOCATED" @@ -204,6 +221,14 @@ export interface BatchBoardSchedule { export class BookingBatchService implements OnModuleInit { private readonly logger = new Logger(BookingBatchService.name); + /** + * Serialises settle/top-up per schedule. The PAYMENT phase transition and the + * tick's overdue backstop both call settleDueReservations for the same schedule + * in the same second; without this they interleave and the top-up runs against a + * schedule whose phase has already been concluded. + */ + private readonly scheduleLocks = new Map>(); + constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, @@ -498,8 +523,8 @@ export class BookingBatchService implements OnModuleInit { } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); - const required = need ?? this.needFor(booking, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( @@ -508,7 +533,7 @@ export class BookingBatchService implements OnModuleInit { const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonDims); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg corridorMatched = true; @@ -546,8 +571,8 @@ export class BookingBatchService implements OnModuleInit { if (!partner || partner.status !== 'FULLY_EXECUTED') { return; } - const wagonLengths = await this.loadWagonLengths(); - const need = this.combinedNeed(booking, partner, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const need = this.combinedNeed(booking, partner, wagonDims); const scheduleId = await this.pickExportSchedule(booking, need); await this.reserveOnExport([booking, partner], scheduleId); } @@ -612,12 +637,14 @@ export class BookingBatchService implements OnModuleInit { trainSet: { locomotive: true }, originStation: true, destinationStation: true, - route: true, + // Yards supply the route's display name for `routeName` below. + route: { originYard: true, destinationYard: true }, }, order: { scheduledDepartureDate: "ASC" }, }); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); + const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const board: BatchBoardSchedule[] = []; @@ -632,7 +659,7 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); const items: BatchBoardBooking[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); return { id: b.id, reference: b.reference ?? b.id.slice(0, 8), @@ -652,7 +679,7 @@ export class BookingBatchService implements OnModuleInit { }; }); - board.push(this.buildScheduleSummary(s, items)); + board.push(this.buildScheduleSummary(s, items, rules)); } return board; } @@ -675,7 +702,8 @@ export class BookingBatchService implements OnModuleInit { ); } - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); + const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -721,7 +749,7 @@ export class BookingBatchService implements OnModuleInit { } const items: BatchBoardBookingDetail[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { id: b.id, @@ -868,7 +896,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -899,6 +927,13 @@ export class BookingBatchService implements OnModuleInit { return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } + /** + * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already + * includes the tare of the wagons it occupies), so the ceiling it is measured + * against must be the same one the fill loop spends from: the locomotive floored + * by the global rule caps and widened by its overage tolerance. Reading the raw + * `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use. + */ private computeBoardCapacity( items: Array<{ state: BatchBoardBookingState; @@ -908,22 +943,40 @@ export class BookingBatchService implements OnModuleInit { }>, loco: Locomotive | null, maxWagons: number | null, + rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); + const caps = loco + ? trainHardCaps( + { + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + overageToleranceTons: Number(loco.overageToleranceTons) || 0, + overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, + }, + { + maxTrainWeightTons: rules?.maxTrainWeightTons + ? Number(rules.maxTrainWeightTons) + : undefined, + maxTrainLengthMeters: rules?.maxTrainLengthMeters + ? Number(rules.maxTrainLengthMeters) + : undefined, + }, + ) + : null; + const round2 = (value: number) => Math.round(value * 100) / 100; + return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), - allocatedLengthMeters: - Math.round( - allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, - ) / 100, - maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: - Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / - 100, - maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + allocatedLengthMeters: round2( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0), + ), + maxLengthMeters: caps ? caps.maxLengthMeters : null, + usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), + maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, }; } @@ -931,6 +984,7 @@ export class BookingBatchService implements OnModuleInit { private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], + rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule { const loco = s.trainSet?.locomotive ?? null; @@ -963,7 +1017,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1014,33 +1068,39 @@ export class BookingBatchService implements OnModuleInit { return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; } - /** Fill one schedule from its priority-ordered pool until full. */ - async fillSchedule(scheduleId: string): Promise { + /** + * Fill one schedule from its priority-ordered pool until full. Returns the + * number of commercial units it RESERVED this pass (0 for government-only or + * no-fit passes) so a top-up caller can extend the payment phase only when a + * fresh pay window actually opened. + */ + async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || !this.isFillable(schedule)) return; + if (!schedule || !this.isFillable(schedule)) return 0; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${scheduleId} has no locomotive/train set — skipped.`, ); - return; + return 0; } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonDims); if (budget.maxRemaining().wagons <= 0) { await this.setWindow(scheduleId, "FULL"); - return; + return 0; } const pool = await this.bookingsRepository.findBatchPool(scheduleId); const units = this.groupConsolidatedPool(pool); let armed = false; let reservedThisPass = 0; + let commercialReserved = 0; // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when // reservations trickle instead of landing in one pass (a reserve() throwing @@ -1055,8 +1115,8 @@ export class BookingBatchService implements OnModuleInit { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. @@ -1075,7 +1135,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, budget, - wagonLengths, + wagonDims, ); if (!freed) continue; // still doesn't fit even after preempt } else { @@ -1106,6 +1166,7 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking, scheduleId); if (partner) await this.reserve(partner, scheduleId); armed = true; + commercialReserved += 1; } budget.subtract(need, leg); reservedThisPass += 1; @@ -1125,6 +1186,7 @@ export class BookingBatchService implements OnModuleInit { if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); + return commercialReserved; } /** @@ -1140,6 +1202,42 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { + const { scheduleIds } = await this.fillRouteDayInternal( + originYardId, + destinationYardId, + day, + ); + return scheduleIds; + } + + /** + * Route-day top-up for a single schedule: re-run the DAY pool over the whole + * corridor the schedule belongs to, and report how many commercial units got a + * fresh pay window. + * + * `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on + * `booking.train_schedule_id = :scheduleId`, but under day-level pooling a + * booking that has not been reserved yet has a NULL `train_schedule_id` — it is + * only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows + * and the waiting list never boarded after an expiry freed capacity; bookings + * trickled in one per window cycle instead. + */ + private async topUpFill(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return 0; + const { commercialReserved } = await this.fillRouteDayInternal( + schedule.originStationId, + schedule.destinationStationId, + eatDay(schedule.scheduledDepartureDate), + ); + return commercialReserved; + } + + private async fillRouteDayInternal( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise<{ scheduleIds: string[]; commercialReserved: number }> { // The day's fillable schedules on this exact corridor, earliest first. Fillable // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — // the batch must run while the customer window is closed. @@ -1157,23 +1255,36 @@ export class BookingBatchService implements OnModuleInit { }, ], }); - const scheduleIds = corridor + const onDay = corridor .filter( (s) => s.scheduledDepartureDate != null && - eatDay(s.scheduledDepartureDate) === day && - this.isFillable(s), + eatDay(s.scheduledDepartureDate) === day, ) .sort( (a, b) => a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), - ) - .map((s) => s.id); + ); - if (scheduleIds.length === 0) return []; + // A schedule flagged FULL is rejected by isFillable() before its budget is + // ever consulted. Re-derive that flag from live capacity first, so a train + // whose bookings all expired is not skipped forever with an empty consist. + for (const s of onDay) { + if (s.bookingWindowStatus === "FULL") { + await this.refreshWindowStatus(s.id); + const fresh = await this.trainSchedulesRepository.findById(s.id); + if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus; + } + } + + const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id); + + if (scheduleIds.length === 0) { + return { scheduleIds: [], commercialReserved: 0 }; + } const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; @@ -1189,10 +1300,10 @@ export class BookingBatchService implements OnModuleInit { } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: false }); } - if (trains.length === 0) return []; + if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; // The day pool covers every booking whose leg lies somewhere on one of the // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an @@ -1214,13 +1325,14 @@ export class BookingBatchService implements OnModuleInit { `poolSize=${pool.length} units=${units.length}`, ); let reservedThisPass = 0; + let commercialReserved = 0; for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => @@ -1256,7 +1368,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, t.budget, - wagonLengths, + wagonDims, ); if (freed) { target = t; @@ -1273,7 +1385,12 @@ export class BookingBatchService implements OnModuleInit { // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. const offered = await this.maybeOfferPartial(booking, isPair, trains, need); - if (offered) continue; + if (offered) { + // A partial offer opens a real commercial pay window, same as reserve(). + commercialReserved += 1; + reservedThisPass += 1; + continue; + } // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); if (partner) this.notifier.unplaced(partner, day); @@ -1294,6 +1411,7 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking, target.id); if (partner) await this.reserve(partner, target.id); target.armed = true; + commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); reservedThisPass += 1; @@ -1315,7 +1433,7 @@ export class BookingBatchService implements OnModuleInit { void this.triggerWagonAllocation(t.id); } - return trains.map((t) => t.id); + return { scheduleIds: trains.map((t) => t.id), commercialReserved }; } /** @@ -1385,7 +1503,7 @@ export class BookingBatchService implements OnModuleInit { if (booking.consolidationPartnerId) return null; if (await this.splitService.findOpenOffer(booking.id)) return null; - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); const sized = await this.splitService.sizeOffer( booking, @@ -1397,11 +1515,16 @@ export class BookingBatchService implements OnModuleInit { const offeredNeed: Capacity = { wagons: sized.offeredWagons, - weightTons: sized.offeredWeightTons, - lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + sized.offeredWeightTons, + sized.offeredWagons, + this.tareFor(booking.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + booking.freightType, + sized.offeredWagons, + this.lengthsOf(wagonDims), + ), }; if (!this.fits(offeredNeed, budget)) return null; @@ -1490,16 +1613,93 @@ export class BookingBatchService implements OnModuleInit { return anySettled; } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + /** + * Durable settle: allocate paid / expire overdue reservations, then top up the + * freed capacity from the waiting list. + * + * Serialised per schedule. Two callers race here every time a payment phase + * ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations` + * backstop. Both read the same reserved rows in the same second, so without the + * lock the second caller re-settles rows the first is mid-way through expiring, + * and `concludeCycle` observes capacity that is neither pre- nor post-expiry. + */ async settleDueReservations(scheduleId: string): Promise { - const anySettled = await this.settleReserved(scheduleId, false); - // A settle that allocated/expired anything frees or fills capacity → re-run the - // fill so the next waiting-list bookings get a fresh pay window (top-up). - if (anySettled) { + await this.withScheduleLock(scheduleId, () => + this.settleAndTopUp(scheduleId, false), + ); + } + + /** + * Settle, then keep promoting the waiting list until the train can take no more. + * Returns whether anything settled. + * + * One top-up pass is not enough: expiring an N-wagon booking can free room for + * several smaller ones, and reserving those can in turn leave room for the next + * size down. Loop until a pass reserves nothing, so the batch ends with the train + * as full as the pool allows — rather than leaving a booking stranded until the + * next window cycle. + * + * Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so + * `concludeCycle` cannot fire before the promoted customers' deadlines. + */ + private async settleAndTopUp( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { + const anySettled = await this.settleReserved( + scheduleId, + expireUnpaidUnknownDeadline, + ); + if (!anySettled) return false; + + this.logger.log( + `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + ); + + // Bounded: every round either reserves at least one unit (shrinking the pool) + // or breaks. The cap is a backstop against a pathological reserve/expire cycle. + let promoted = 0; + for (let round = 0; round < 10; round += 1) { + const reservedThisRound = await this.topUpFill(scheduleId); + if (reservedThisRound <= 0) break; + promoted += reservedThisRound; + await this.extendPaymentPhaseForTopUp(scheduleId); + } + + if (promoted > 0) { this.logger.log( - `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + `[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` + + `— payment phase extended for them`, ); - await this.fillSchedule(scheduleId); + } + return true; + } + + /** + * Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the + * in-flight run rather than interleaving with it. Single-process only — a second + * API replica would need a row lock on the schedule instead. + */ + private async withScheduleLock( + scheduleId: string, + fn: () => Promise, + ): Promise { + const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve(); + // Chain onto the previous holder; swallow its rejection so one failure does + // not poison every later caller's lock. + const run = inFlight.catch(() => undefined).then(fn); + const gate = run.then( + () => undefined, + () => undefined, + ); + this.scheduleLocks.set(scheduleId, gate); + try { + return await run; + } finally { + // Last one out clears the slot so the map does not grow without bound. + if (this.scheduleLocks.get(scheduleId) === gate) { + this.scheduleLocks.delete(scheduleId); + } } } @@ -1508,8 +1708,9 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - await this.settleReserved(scheduleId, true); - await this.fillSchedule(scheduleId); + await this.withScheduleLock(scheduleId, () => + this.settleAndTopUp(scheduleId, true), + ); void this.triggerWagonAllocation(scheduleId); } @@ -1612,9 +1813,16 @@ export class BookingBatchService implements OnModuleInit { .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + // Capture the train before expire() detaches the booking from it — the + // top-up has to run against the schedule whose wagons were just freed. + const freedScheduleId = booking.trainScheduleId; await this.expire(booking); - if (booking.trainScheduleId) - await this.fillSchedule(booking.trainScheduleId); + if (freedScheduleId) { + const topUpReserved = await this.topUpFill(freedScheduleId); + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(freedScheduleId); + } + } } // ---- intercity ride-along API --------------------------------------------- @@ -1635,10 +1843,10 @@ export class BookingBatchService implements OnModuleInit { const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); - return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; + const budget = await this.remainingBudget(schedule, limits, wagonDims); + return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } /** @@ -1671,6 +1879,28 @@ export class BookingBatchService implements OnModuleInit { * so the engine sets it as it picks the train. */ private async reserve(booking: Booking, scheduleId: string): Promise { + // Idempotency guard: a booking already reserved (pay window open) or already + // paid on THIS schedule must never be re-reserved — that would fire a second + // `payNow` and reset its deadline, the "asked to pay again after paying" + // symptom. Read fresh state (the in-memory `booking` may be stale from the + // pooled query). Only bookings not yet committed to this train pass through. + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: booking.id } }); + if ( + fresh && + fresh.trainScheduleId === scheduleId && + (fresh.status === "SELECTED_FOR_BATCH" || + fresh.status === "AWAITING_PAYMENT" || + fresh.status === "PAID" || + fresh.paymentStatus === "PAID") + ) { + this.logger.debug( + `[BATCH] reserve skipped for ${booking.reference} — already ` + + `${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`, + ); + return; + } const now = new Date(); const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { @@ -1689,8 +1919,9 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims()); this.logger.log( - `[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` + + `[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + `pay by ${deadline.toISOString()}`, ); @@ -1781,6 +2012,7 @@ export class BookingBatchService implements OnModuleInit { * it failed to pay for — it's back in the day pool for staff to act on. */ private async expire(booking: Booking): Promise { + const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, status: "EXPIRED", @@ -1789,6 +2021,9 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // The wagons this reservation held are back — a schedule parked at FULL + // because of it must reopen, or it can never be filled again. + if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId); // An unpaid partial offer dies with the reservation — the booking stays whole. if (this.splitService) { await this.splitService.expireOpenOffer(booking.id); @@ -1893,7 +2128,7 @@ export class BookingBatchService implements OnModuleInit { need: Capacity, leg: CorridorLeg, budget: CorridorBudget, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Promise { if (budget.fits(need, leg)) return true; const reservedCommercial = ( @@ -1941,7 +2176,10 @@ export class BookingBatchService implements OnModuleInit { ); }); this.notifier.displaced(victim); - budget.add(this.needFor(victim, wagonLengths), victimLeg); + budget.add(this.needFor(victim, wagonDims), victimLeg); + // Displacing frees wagons the same way an expiry does — don't leave the + // schedule stuck at FULL. + await this.refreshWindowStatus(scheduleId); } return budget.fits(need, leg); } @@ -1995,51 +2233,118 @@ export class BookingBatchService implements OnModuleInit { private combinedNeed( primary: Booking, partner: Booking, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Capacity { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); const totalContainers = containers(primary) + containers(partner); - const sharedWagons = + const cargoTons = + Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + + // Consolidation shares TEU slots, never rated payload: the pair still needs + // enough wagons to carry its combined cargo, so the weight axis bounds the + // shared count exactly as it bounds an individual booking's. + const capacityTons = this.capacityFor(primary.freightType, wagonDims); + const byWeight = + cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + const byLength = totalContainers > 0 ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) - : this.wagonsFor(primary) + this.wagonsFor(partner); - const weightTons = - Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + : this.wagonsFor(primary, wagonDims) + this.wagonsFor(partner, wagonDims); + const sharedWagons = Math.max(byLength, byWeight); + return { wagons: sharedWagons, - weightTons, - lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + // Consolidation saves tare as well as slots: the pair rides `sharedWagons` + // wagons, so it is charged `sharedWagons` tares, not one per booking. + weightTons: bookingGrossWeightTons( + cargoTons, + sharedWagons, + this.tareFor(primary.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + primary.freightType, + sharedWagons, + this.lengthsOf(wagonDims), + ), }; } - private wagonsFor(booking: Booking): number { + /** Per-wagon tare for the wagon type this freight rides on. */ + private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number { + return freightType === 'BULK' + ? wagonDims.bulk.tareWeightTons + : wagonDims.container.tareWeightTons; + } + + private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } { + return { + container: wagonDims.container.lengthMeters, + bulk: wagonDims.bulk.lengthMeters, + }; + } + + /** + * Wagons a booking occupies. Two axes bind independently and the booking needs + * enough wagons to satisfy BOTH, so the count is the larger of: + * + * weight — ceil(cargoTons / wagonType.capacityTons), the rated payload + * length — TEU geometry, two 20ft to a wagon (container bookings only) + * + * The weight axis was missing entirely. A BULK booking carries no container + * lines, so `containerWagonsForLines` returned 0 and every bulk booking + * collapsed to a single wagon no matter its tonnage — a 2590T fertilizer + * booking counted as 1 wagon, and `needFor` then charged 1 tare instead of 37. + * That under-reported the board and let the fill loop overbook the train. + */ + private wagonsFor(booking: Booking, wagonDims: WagonDims): number { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); } - const fromContainers = (booking.bookingContainers ?? []).reduce( - (sum, c) => sum + Number(c.quantity ?? 0), - 0, - ); - return Math.max( - DEFAULT_WAGONS_PER_BOOKING, - fromContainers || DEFAULT_WAGONS_PER_BOOKING, - ); + + // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. + const byLength = containerWagonsForLines(booking.bookingContainers ?? []); + + const capacityTons = this.capacityFor(booking.freightType, wagonDims); + const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); + const byWeight = + cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + + return Math.max(DEFAULT_WAGONS_PER_BOOKING, byLength, byWeight); } - /** What one booking consumes along all three capacity axes. */ - private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity { - const wagons = this.wagonsFor(booking); + private capacityFor( + freightType: string | null | undefined, + wagonDims: WagonDims, + ): number { + return freightType === "BULK" + ? wagonDims.bulk.capacityTons + : wagonDims.container.capacityTons; + } + + /** + * What one booking consumes along all three capacity axes. + * + * The weight axis is GROSS — cargo plus the tare of every wagon the booking + * occupies — because it is spent against the locomotive's pull limit, which + * governs the whole train and not just its payload. Charging cargo alone let a + * 37-wagon box-wagon train read 2590T when it really weighed 3522T. + */ + private needFor(booking: Booking, wagonDims: WagonDims): Capacity { + const wagons = this.wagonsFor(booking, wagonDims); return { wagons, - weightTons: Number(booking.cargoTotalWeightVgm ?? 0), - lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + Number(booking.cargoTotalWeightVgm ?? 0), + wagons, + this.tareFor(booking.freightType, wagonDims), + ), + lengthMeters: bookingTrainLengthMeters( + booking.freightType, + wagons, + this.lengthsOf(wagonDims), + ), }; } @@ -2051,7 +2356,11 @@ export class BookingBatchService implements OnModuleInit { ); } - /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ + /** + * Hard caps for a schedule's train: gross pull weight, train length, and the + * length-derived wagon slot count (never a fixed 53). Bookings spend against + * these via {@link needFor}, whose weight axis is gross. + */ private async capacityLimits( locomotive: Locomotive, rules: TrainSchedulingGlobalRules | null, @@ -2061,6 +2370,8 @@ export class BookingBatchService implements OnModuleInit { { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, + overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, { @@ -2094,31 +2405,54 @@ export class BookingBatchService implements OnModuleInit { } } - private async loadWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: "NW5" }, { code: "CW3" }], - }); + /** + * Every active wagon type, so the slot count is derived from the shortest wagon + * the fleet can actually marshal rather than from an arbitrary two-code sample. + */ + private async loadWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } - private async loadWagonLengths(): Promise { + /** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */ + private async loadWagonDims(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ where: [{ code: "NW5" }, { code: "CW3" }], }); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), ); + const nw5 = byCode.get("NW5"); + const cw3 = byCode.get("CW3"); + // capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload + // must fall back rather than yield an infinite wagon count. + const payload = (value: number | undefined, fallback: number): number => + value && value > 0 ? value : fallback; return { - container: - byCode.get("NW5")?.lengthMeters ?? - DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: { + lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, + capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS), + }, + bulk: { + lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, + capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS), + }, }; } @@ -2155,7 +2489,7 @@ export class BookingBatchService implements OnModuleInit { private async remainingBudget( schedule: TrainSchedule, limits: Capacity, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits); @@ -2167,7 +2501,7 @@ export class BookingBatchService implements OnModuleInit { ); for (const b of [...allocated, ...reserved]) { budget.subtract( - this.needFor(b, wagonLengths), + this.needFor(b, wagonDims), budget.legForYards(b.originYardId, b.destinationYardId), ); } @@ -2179,7 +2513,7 @@ export class BookingBatchService implements OnModuleInit { * ≤ 0 means no leg can take another booking — the train-wide FULL signal. */ private async remainingWagons(schedule: TrainSchedule): Promise { - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const budget = await this.remainingBudget( schedule, { @@ -2187,7 +2521,7 @@ export class BookingBatchService implements OnModuleInit { weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, - wagonLengths, + wagonDims, ); return budget.maxRemaining().wagons; } @@ -2212,6 +2546,32 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * A reservation on this schedule still has time left to pay. + * + * The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt` + * is stamped when the phase starts, then `reserve()` gives each booking + * `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So + * the first settle after the phase deadline finds every reservation still in date, + * expires nothing, reports `anySettled = false`, runs no top-up — and the caller + * concludes the cycle out from under customers who still had time to pay. The next + * tick then expires them with no cycle left to promote the waiting list into. + * + * Callers must not conclude the cycle while this returns true. + */ + async hasLiveReservations(scheduleId: string): Promise { + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); + const now = Date.now(); + return reserved.some( + (b) => + b.paymentStatus !== "PAID" && + b.status !== "PAID" && + b.paymentDeadline != null && + b.paymentDeadline.getTime() > now, + ); + } + /** No wagon slots left for allocated + reserved bookings. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -2220,6 +2580,35 @@ export class BookingBatchService implements OnModuleInit { return (await this.remainingWagons(schedule)) <= 0; } + /** + * Re-derive `bookingWindowStatus` from live capacity after wagons were freed + * (a reservation expired, a booking was displaced, a link was removed). + * + * FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before + * it ever looks at the budget, and the only writers of OPEN skip a FULL row. So + * a train that filled once and then lost every booking to expiry stayed FULL + * with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT + * forever while `concludeCycle` (which reads real capacity, not the flag) kept + * reopening it. Clearing FULL here is what lets the next batch actually run. + * + * Only the customer-facing OPEN phases may go back to OPEN; a schedule mid + * DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits. + */ + async refreshWindowStatus(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "FULL") return; + if ((await this.remainingWagons(schedule)) <= 0) return; + + const customerWindowOpen = + schedule.windowPhase == null || schedule.windowPhase === "OPEN"; + await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); + this.logger.log( + `[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` + + `${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`, + ); + } + // ---- timer plumbing ------------------------------------------------------- /** Configured customer pay window in ms (global rules, with defaults). */ @@ -2256,6 +2645,45 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * A top-up reservation (settle freed capacity mid-cycle, so the next waiting + * booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's + * `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run + * concludeCycle — was frozen when the phase started. Without this, concludeCycle + * fires before the top-up customer's deadline and expires a booking that still + * had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment + * window from now, but never past departure. Only while the schedule is still + * in the PAYMENT phase (a reopened cycle manages its own phase). + */ + async extendPaymentPhaseForTopUp(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule || schedule.windowPhase !== "PAYMENT") return; + const windowMs = await this.paymentWindowMs(); + let target = new Date(Date.now() + windowMs); + if ( + schedule.scheduledDepartureDate && + target > schedule.scheduledDepartureDate + ) { + target = schedule.scheduledDepartureDate; + } + // Only ever push the deadline OUT, never pull it in. + if ( + schedule.paymentPhaseEndsAt && + schedule.paymentPhaseEndsAt.getTime() >= target.getTime() + ) { + return; + } + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { paymentPhaseEndsAt: target }); + this.logger.log( + `[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` + + `(top-up reservation opened a fresh pay window)`, + ); + } + private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index f96c388f8..3286da0eb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => { expireUnacceptedForRouteDay: jest.Mock; settleDueReservations: jest.Mock; isScheduleFull: jest.Mock; + hasLiveReservations: jest.Mock; + refreshWindowStatus: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => { expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined), settleDueReservations: jest.fn().mockResolvedValue(undefined), isScheduleFull: jest.fn().mockResolvedValue(false), + // No reservation is mid-pay-window by default, so the cycle concludes. + hasLiveReservations: jest.fn().mockResolvedValue(false), + refreshWindowStatus: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => { expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); }); + it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => { + // `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each + // booking's own deadline milliseconds later. So the phase deadline always passes + // first, and concluding here would kill customers who still had time to pay — and + // leave no cycle for the waiting-list top-up to run in. + batch.hasLiveReservations.mockResolvedValue(true); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'), + }); + + const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z')); + + expect(advanced).toBe(true); + expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); + // Still PAYMENT — the cycle was NOT concluded and the window did not reopen. + expect(s.windowPhase).toBe('PAYMENT'); + expect(batch.isScheduleFull).not.toHaveBeenCalled(); + }); + it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => { batch.isScheduleFull.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'PAYMENT' }); 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 0d9087419..a730b6aa6 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 @@ -304,6 +304,38 @@ export class BookingWindowService implements OnModuleInit { `(allocate paid / expire unpaid) then concluding the cycle`, ); await this.bookingBatchService.settleDueReservations(schedule.id); + + // The settle expires unpaid reservations and promotes the waiting list into + // the wagons they free. Those promoted customers get a fresh pay window, and + // `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to + // cover it. Concluding here on the STALE in-memory timestamp would end the + // cycle the top-up just extended and expire them before they could pay — so + // re-read, and stay in PAYMENT if the deadline moved. + const settled = await this.trainSchedulesRepository.findById(schedule.id); + if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) { + schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt; + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT extended to ` + + `${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` + + `promoted into the freed wagons; not concluding this cycle yet`, + ); + return true; + } + + // `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own + // deadline is set milliseconds later, per booking, so the phase always expires + // a fraction before the reservations it opened. Concluding here would end the + // cycle while customers still had time to pay, and the settle that finally + // expires them (next tick) would have no cycle left to promote the waiting + // list into. Hold in PAYMENT until every reservation has actually resolved. + if (await this.bookingBatchService.hasLiveReservations(schedule.id)) { + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` + + `are still within their pay windows — holding the cycle open`, + ); + return true; + } + await this.concludeCycle(schedule, cfg, now); return true; } @@ -328,6 +360,18 @@ export class BookingWindowService implements OnModuleInit { return; } + // Not full, so any FULL flag left over from a batch whose bookings later + // expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below + // refuses to reopen a FULL schedule, which is how a train with an empty + // consist used to cycle forever without ever being fillable again. Re-read + // the flag onto the in-memory row — advanceSchedule keeps looping on this + // same object, and PRE_WINDOW→OPEN reads it. + if (schedule.bookingWindowStatus === 'FULL') { + await this.bookingBatchService.refreshWindowStatus(schedule.id); + const fresh = await this.trainSchedulesRepository.findById(schedule.id); + if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; + } + // Doc review + payment have already run, so the desk is ready to reopen NOW — // office hours decide whether that is this afternoon or tomorrow morning. Past // the last cycle before departure, nextCycleOpensAt returns null and we finish. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts index b5e93f5da..1fd1e9a08 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts @@ -51,7 +51,7 @@ export class AssignBookingsDto { @IsUUID('4', { each: true }) bookingIds!: string[]; - @ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' }) + @ApiPropertyOptional({ description: 'Suppress soft hold and overweight warnings' }) @IsOptional() @IsBoolean() forceAssign?: boolean; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts index 76ea00422..54bf7a181 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -15,7 +15,6 @@ const nw5: WagonType = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 3b00abd0a..6173da6b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -4,6 +4,7 @@ import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, + containerWagonsForLines, roundTons, type WagonPlanSlot, } from './wagon-plan.util'; @@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n return Math.max(1, Math.ceil(weight / capacity)); } - const lineSlots = (booking.bookingContainers ?? []).reduce( - (sum, line) => sum + Number(line.wagonsRequired ?? 0), - 0, - ); - return Math.max(1, lineSlots); + // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 + // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // fraction. Ceiling per line would over-count split 20ft lines. + return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts new file mode 100644 index 000000000..912f437f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts @@ -0,0 +1,118 @@ +import { TrainSchedulingService } from './train-scheduling.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; + +type Row = Pick & { + metadata?: Record | null; + triggeredAt?: Date | null; +}; + +/** + * The gate pass is secured once per train schedule, but each booking only earns + * its GATEPASS_GRANTED milestone after settling freight payment. An unpaid + * booking must not ride a paid neighbour's grant — the train proceeds, that + * booking stays pending. + */ +function makeService(bookings: Array>, rows: Row[]) { + const milestoneRepo = { + find: jest.fn().mockResolvedValue(rows), + save: jest.fn((row: Row) => Promise.resolve(row)), + }; + const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) }; + const dataSource = { + getRepository: (entity: unknown) => + entity === Booking ? bookingRepo : milestoneRepo, + }; + + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + dataSource, + logger: { warn: jest.fn(), log: jest.fn() }, + }); + return { service, milestoneRepo }; +} + +/** Reach the private bridge write under test. */ +function grant(service: TrainSchedulingService, at: Date): Promise { + return ( + service as unknown as { + completeGatepassMilestoneForSchedule(id: string, at: Date): Promise; + } + ).completeGatepassMilestoneForSchedule('sched-1', at); +} + +const securedAt = new Date('2026-07-09T08:00:00.000Z'); + +describe('gate pass is withheld from bookings that have not paid freight', () => { + it('grants the paid booking and leaves the unpaid one pending', async () => { + const rows: Row[] = [ + { bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, + { bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + { bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, + { bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + ]; + const { service, milestoneRepo } = makeService( + [ + { id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, + { id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, + ], + rows, + ); + + await grant(service, securedAt); + + const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r); + expect(saved).toHaveLength(1); + expect(saved[0]!.bookingId).toBe('paid'); + expect(saved[0]!.status).toBe('COMPLETED'); + expect(saved[0]!.triggeredAt).toBe(securedAt); + + const unpaid = rows.find( + (r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED', + ); + expect(unpaid!.status).toBe('PENDING'); + }); + + it('treats a booking paid outside the milestone path as paid', async () => { + // Some payment paths settle the invoice without writing the milestone; the + // clearance views self-heal it on read, so the gate pass must not lag. + const rows: Row[] = [ + { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, + { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + ]; + const { service, milestoneRepo } = makeService( + [{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }], + rows, + ); + + await grant(service, securedAt); + + expect(milestoneRepo.save).toHaveBeenCalledTimes(1); + expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1'); + }); + + it('leaves an already-granted milestone untouched', async () => { + const rows: Row[] = [ + { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, + { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' }, + ]; + const { service, milestoneRepo } = makeService( + [{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }], + rows, + ); + + await grant(service, securedAt); + + expect(milestoneRepo.save).not.toHaveBeenCalled(); + }); + + it('does nothing when the schedule carries no customs bookings', async () => { + const { service, milestoneRepo } = makeService([], []); + + await grant(service, securedAt); + + expect(milestoneRepo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index d72f3d311..e1b4064bb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -1,41 +1,188 @@ import { + bookingGrossWeightTons, bookingTrainLengthMeters, + consistUsage, + consistViolations, deriveTrainCapacityFromLocomotive, + grossWagonWeightTons, + minLocomotiveLimits, } from './train-capacity.util'; describe('train-capacity.util', () => { - const nw5 = { lengthMeters: 14, capacityTons: 70 }; + // Real EDR wagon specs. + const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 }; + const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; + const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 }; - it('derives wagon slots from locomotive length and weight, not a fixed 53', () => { - const shortLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, - [nw5], - ); - expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14 - expect(shortLoco.maxWagonSlots).not.toBe(53); - - const heavyLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, - [nw5], - ); - expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70 + const caps = (over = {}) => ({ + maxWeightTons: 3500, + maxLengthMeters: 760, + maxWagonSlots: 54, + ...over, }); - it('uses shortest wagon type when mixed types are present', () => { - const longBulk = { lengthMeters: 18, capacityTons: 80 }; - const mixed = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, - [nw5, longBulk], - ); - expect(mixed.maxWagonSlots).toBe( - Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)), - ); + const slots = (n: number, type: typeof nw5, cargoTons: number) => + Array.from({ length: n }, () => ({ + lengthMeters: type.lengthMeters, + tareWeightTons: type.tareWeightTons, + cargoTons, + })); + + describe('deriveTrainCapacityFromLocomotive', () => { + it('derives wagon slots from train length, not a fixed 53', () => { + const shortLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, + [nw5], + ); + expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966) + expect(shortLoco.maxWagonSlots).not.toBe(53); + }); + + it('does not shrink slots by assuming every wagon rides at full payload', () => { + // A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a + // LENGTH figure — the cargo that decides weight does not exist yet. + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30 + expect(derived.maxWeightTons).toBe(2100); + }); + + it('admits the railway 53-wagon NW5 marshalling figure', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53); + }); + + it('uses the shortest wagon type when mixed types are present', () => { + const mixed = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5, pw2, gw2], + ); + expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62 + }); + + it('extends weight/length caps by the locomotive overage tolerance', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + [pw2], + ); + expect(derived.maxWeightTons).toBe(3590); + }); + + it('ignores overage tolerance when unset (strict cap)', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + + it('floors the locomotive by the global rule caps', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 5000, maxTrainLengthMeters: 900 }, + [nw5], + { maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 }, + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + }); + + describe('gross weight', () => { + it('counts the wagon as well as its cargo', () => { + expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2); + }); + + it('charges a booking one tare per wagon it occupies', () => { + // 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel. + expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2); + }); + + it('is cargo alone when the wagon type has no tare on record', () => { + expect(bookingGrossWeightTons(100, 3, 0)).toBe(100); + }); + }); + + describe('consistUsage', () => { + it('sums each wagon own length and tare rather than averaging a type', () => { + const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)]; + const usage = consistUsage(mixed, caps()); + + expect(usage.wagonCount).toBe(3); + expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066 + expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2 + expect(usage.usedCargoWeightTons).toBe(40); + expect(usage.usedGrossWeightTons).toBe(110); + expect(usage.remainingGrossWeightTons).toBe(3390); + expect(usage.remainingWagons).toBe(51); + }); + + it('reports an empty consist as fully available', () => { + const usage = consistUsage([], caps()); + expect(usage.usedGrossWeightTons).toBe(0); + expect(usage.remainingLengthMeters).toBe(760); + expect(usage.remainingWagons).toBe(54); + }); + }); + + describe('consistViolations', () => { + it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => { + // 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T. + const consist = slots(37, pw2, 70); + + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([ + expect.stringContaining('3522.4T'), + ]); + expect( + consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })), + ).toEqual([]); + }); + + it('blocks a train the old cargo-only math would have waved through', () => { + // Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T. + const consist = slots(37, pw2, 70); + const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0); + + expect(cargoOnly).toBeLessThan(3500); + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]); + }); + + it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => { + // 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T. + expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]); + }); + + it('flags an over-length consist', () => { + const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 })); + expect(violations).toEqual([expect.stringContaining('exceeds max train length')]); + }); + + it('flags an over-count consist', () => { + const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 })); + expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]); + }); + + it('reports every broken axis at once', () => { + expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3); + }); }); it('computes booking length by freight type', () => { - expect( - bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }), - ).toBe(28); + expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28); expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); + + it('takes the weakest locomotive across a multi-locomotive set', () => { + const limits = minLocomotiveLimits([ + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, + ]); + expect(limits?.maxPullWeightTons).toBe(3500); + expect(limits?.overageToleranceTons).toBe(20); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 5ec385924..79adf8d8f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -1,73 +1,212 @@ +/** + * Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable: + * + * count — how many wagons fit end to end on the longest allowed train + * length — Σ wagonType.lengthMeters over the real consist + * weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist + * + * The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it. + * The old code compared the locomotive's pull limit against cargo payload alone + * and so overbooked every train by roughly the tare fraction (~27% on PW2). + * + * The weight axis is also driven by ACTUAL booked cargo, never by an assumed + * full payload. That is what makes the real EDR numbers fall out: + * + * NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon + * marshalling figure is length-bound, and those trains never carry 53×70T. + * PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T, + * which clears 3500T only via the locomotive's overage tolerance. Weight + * binds first, hence "37 wagons per train". + * + * So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo + * exists. Weight is enforced against the consist as bookings are allocated. + */ + /** Physical dimensions used when deriving how many wagons a locomotive can pull. */ export type WagonTypeDimensions = { lengthMeters: number; capacityTons: number; + tareWeightTons: number; +}; + +/** One occupied wagon slot in a real consist. */ +export type ConsistSlot = { + lengthMeters: number; + tareWeightTons: number; + /** Actual cargo/container weight riding on this wagon, not its rated capacity. */ + cargoTons: number; }; export type LocomotiveLimits = { maxPullWeightTons: number; maxTrainLengthMeters: number; + /** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */ + overageToleranceTons?: number | null; + /** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */ + overageToleranceMeters?: number | null; }; export type DerivedTrainCapacity = { + /** Gross (tare + cargo) tons the train may weigh, tolerance included. */ maxWeightTons: number; maxLengthMeters: number; + /** Length-derived slot count. Weight is enforced separately against real cargo. */ maxWagonSlots: number; }; +/** What a consist currently uses, and what is left on each axis. */ +export type ConsistUsage = { + wagonCount: number; + usedLengthMeters: number; + /** Σ (tare + cargo). */ + usedGrossWeightTons: number; + usedTareWeightTons: number; + usedCargoWeightTons: number; + remainingLengthMeters: number; + remainingGrossWeightTons: number; + remainingWagons: number; +}; + +export const MAX_FALLBACK_WEIGHT = 3500; +export const MAX_FALLBACK_LENGTH = 760; + const DEFAULT_WAGON_LENGTH_M = 14; const DEFAULT_WAGON_CAPACITY_T = 70; +/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */ +const DEFAULT_WAGON_TARE_T = 22.4; + +function num(value: unknown, fallback = 0): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ +export function grossWagonWeightTons(slot: Pick): number { + return num(slot.tareWeightTons) + num(slot.cargoTons); +} /** - * Derive train capacity from locomotive pull weight and train length. - * Wagon count is NOT a fixed 53 — it is the minimum of: - * - floor(maxLength / shortest wagon type length) - * - floor(maxWeight / lightest wagon type capacity) + * Hard caps for a train: the locomotive's own limits, floored by the global rule + * caps, then widened by the locomotive's overage tolerance. + */ +export function trainHardCaps( + locomotive: LocomotiveLimits, + ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, +): { maxWeightTons: number; maxLengthMeters: number } { + const overageTons = num(locomotive.overageToleranceTons); + const overageMeters = num(locomotive.overageToleranceMeters); + + const weight = + Math.min( + num(locomotive.maxPullWeightTons, Infinity) || Infinity, + ruleCaps?.maxTrainWeightTons ?? Infinity, + ) + overageTons; + const length = + Math.min( + num(locomotive.maxTrainLengthMeters, Infinity) || Infinity, + ruleCaps?.maxTrainLengthMeters ?? Infinity, + ) + overageMeters; + + return { + maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT, + maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH, + }; +} + +/** + * Derive the planning capacity of a train from its locomotive. + * + * `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within + * the train-length cap — the optimistic slot count, since a mixed consist of + * longer wagons will hit the length cap sooner. It is deliberately NOT reduced by + * weight: with no bookings yet there is no cargo, and assuming every wagon rides + * at full rated payload would report 37 NW5 slots where the railway marshals 53. + * Weight is enforced by {@link consistUsage} / {@link consistViolations} against + * the cargo actually allocated. */ export function deriveTrainCapacityFromLocomotive( locomotive: LocomotiveLimits, wagonTypes: WagonTypeDimensions[], ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, ): DerivedTrainCapacity { - const maxWeightTons = Math.min( - Number(locomotive.maxPullWeightTons) || Infinity, - ruleCaps?.maxTrainWeightTons ?? Infinity, - ); - const maxLengthMeters = Math.min( - Number(locomotive.maxTrainLengthMeters) || Infinity, - ruleCaps?.maxTrainLengthMeters ?? Infinity, - ); + const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps); - const types = - wagonTypes.length > 0 - ? wagonTypes - : [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }]; + const lengths = wagonTypes + .map((w) => num(w.lengthMeters)) + .filter((l) => l > 0); + const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M; - const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M)); - const minCapacity = Math.min( - ...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T), - ); + const maxWagonSlots = + minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0; - const byLength = - minLength > 0 && Number.isFinite(maxLengthMeters) - ? Math.floor(maxLengthMeters / minLength) - : 0; - const byWeight = - minCapacity > 0 && Number.isFinite(maxWeightTons) - ? Math.floor(maxWeightTons / minCapacity) - : byLength; + return { maxWeightTons, maxLengthMeters, maxWagonSlots }; +} - const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight)); +/** + * What a real, mixed-type consist uses on all three axes, and what is left. + * Every wagon contributes its own length and its own tare — no averaging over a + * representative wagon type. + */ +export function consistUsage( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): ConsistUsage { + let usedLengthMeters = 0; + let usedTareWeightTons = 0; + let usedCargoWeightTons = 0; + + for (const slot of slots) { + usedLengthMeters += num(slot.lengthMeters); + usedTareWeightTons += num(slot.tareWeightTons); + usedCargoWeightTons += num(slot.cargoTons); + } + + const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons; return { - maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT, - maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH, - maxWagonSlots, + wagonCount: slots.length, + usedLengthMeters: round3(usedLengthMeters), + usedGrossWeightTons: round3(usedGrossWeightTons), + usedTareWeightTons: round3(usedTareWeightTons), + usedCargoWeightTons: round3(usedCargoWeightTons), + remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters), + remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons), + remainingWagons: caps.maxWagonSlots - slots.length, }; } -export const MAX_FALLBACK_WEIGHT = 3500; -export const MAX_FALLBACK_LENGTH = 760; +/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */ +export function consistViolations( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): string[] { + const usage = consistUsage(slots, caps); + const violations: string[] = []; + + if (usage.usedGrossWeightTons > caps.maxWeightTons) { + violations.push( + `Total train gross weight ${usage.usedGrossWeightTons}T ` + + `(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` + + `exceeds max pull weight ${round3(caps.maxWeightTons)}T`, + ); + } + if (usage.usedLengthMeters > caps.maxLengthMeters) { + violations.push( + `Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`, + ); + } + if (usage.wagonCount > caps.maxWagonSlots) { + violations.push( + `Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`, + ); + } + + return violations; +} + +function round3(value: number): number { + return Number.isFinite(value) ? Number(value.toFixed(3)) : value; +} /** * Effective pull limits for a train set with multiple locomotives: the weakest @@ -75,16 +214,22 @@ export const MAX_FALLBACK_LENGTH = 760; * across all assigned locomotives. Returns null when no locomotives are given. */ export function minLocomotiveLimits( - locomotives: Array>, + locomotives: Array< + Pick & + Partial> + >, ): LocomotiveLimits | null { if (!locomotives.length) return null; return { maxPullWeightTons: Math.min( - ...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity), + ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity), ), maxTrainLengthMeters: Math.min( - ...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity), + ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), + // Weakest locomotive's tolerance governs the set, same as its caps. + overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))), + overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))), }; } @@ -98,12 +243,27 @@ export function bookingTrainLengthMeters( return wagonCount * perWagon; } +/** + * Gross weight a booking adds to its train: its cargo plus the tare of every + * wagon it occupies. A booking is never weightless just because it is light — + * the empty wagons still have to be pulled. + */ +export function bookingGrossWeightTons( + cargoTons: number, + wagonCount: number, + tarePerWagonTons: number, +): number { + return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons)); +} + export function wagonTypeDimensionsFromEntity(wt: { lengthMeters?: number | string | null; capacityTons?: number | string | null; + tareWeightTons?: number | string | null; }): WagonTypeDimensions { return { - lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, - capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, + capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 04a972ed7..e97b3de56 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -14,7 +14,6 @@ const nw5 = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, @@ -35,7 +34,6 @@ const cw3 = { name: 'Covered Wagon', capacityTons: 60, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['BULK'], isActive: true, supportsContainer: false, @@ -74,7 +72,7 @@ const makeBooking = ( wagonsRequired, vgmPerUnitTons: weight / quantity, isOverweight: false, - containerType: { code: containerCode, label: containerCode }, + containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id }, }, ], ...extra, @@ -284,7 +282,7 @@ describe('TrainSchedulingService', () => { expect(result.warnings[0]).toContain('soft hold window'); }); - it('flags the overweight booking as invalid', async () => { + it('warns on the overweight booking but still allows scheduling', async () => { const bookings = [ makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, { bookingContainers: [ @@ -295,7 +293,7 @@ describe('TrainSchedulingService', () => { wagonsRequired: 80, vgmPerUnitTons: 45, isOverweight: true, - containerType: { code: '40FT', label: '40FT' }, + containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id }, }, ], }), @@ -313,8 +311,8 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-destination', }); - expect(result.valid).toBe(false); - expect(result.violations.some((v) => v.includes('overweight'))).toBe(true); + expect(result.violations.some((v) => v.includes('overweight'))).toBe(false); + expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true); }); it('allows preview when bookings are already on the target schedule', async () => { 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 754a302d3..b72194299 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 @@ -17,7 +17,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm'; +import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -43,8 +43,6 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { CargoType } from '../rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -104,10 +102,13 @@ import { deriveTrainCapacityFromLocomotive, minLocomotiveLimits, wagonTypeDimensionsFromEntity, + WagonTypeDimensions, } from './train-capacity.util'; import { DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; import { computeExportWindowTimes, @@ -1004,13 +1005,19 @@ export class TrainSchedulingService { throw new BadRequestException('Schedule train set has no locomotives'); } // forceAssign lets staff overload the locomotive set knowingly — the - // validator has already surfaced it as a warning in that case. - if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { + // validator has already surfaced it as a warning in that case. Each + // locomotive's overageToleranceTons/Meters extends the hard cap before that + // override is even needed (e.g. the fertilizer example's +90T deviation). + const weightCapWithOverage = + limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0); + const lengthCapWithOverage = + limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0); + if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -1066,11 +1073,16 @@ export class TrainSchedulingService { containerPlacements ?? [], ); + // The link above puts these bookings on the train: they are SCHEDULED, not + // ELIGIBLE. Leaving them ELIGIBLE re-offers an allocated booking to the next + // batch fill, which unlinks it and frees its wagons on the next window cycle. + const scheduledAt = new Date(); for (const booking of bookings) { await this.bookingsRepository.updateSchedulingFields( booking.id, { - schedulingStatus: SchedulingStatus.Eligible, + schedulingStatus: SchedulingStatus.Scheduled, + scheduledAt, wagonsRequired: sumWagonsRequired(booking), }, manager, @@ -1648,6 +1660,13 @@ export class TrainSchedulingService { * clearance views still reading that milestone (older deployed builds) see * the gate pass as done. Drop once every clearance-api deployment reads * ImportDjiboutiOperation.gatepassGrantedAt directly. + * + * A booking only earns its gate pass once the customer has settled the freight + * charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train + * schedule, so an unpaid booking must not ride a paid neighbour's grant: it + * keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the + * train and its paid bookings proceed. Re-securing the gate pass after payment + * settles picks the booking up; so does any later call to this bridge. */ private async completeGatepassMilestoneForSchedule( scheduleId: string, @@ -1659,20 +1678,49 @@ export class TrainSchedulingService { if (bookings.length === 0) return; const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const bookingIds = bookings.map((b) => b.id); const rows = await milestoneRepo.find({ where: { - bookingId: In(bookings.map((b) => b.id)), - milestoneCode: 'GATEPASS_GRANTED', + bookingId: In(bookingIds), + milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']), }, }); + const paidBookingIds = new Set( + rows + .filter( + (r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED', + ) + .map((r) => r.bookingId), + ); + // A booking whose payment settled through a path that never wrote the + // milestone still counts as paid — the clearance views self-heal the row on + // read, and the gate pass must not lag behind that. + for (const booking of bookings) { + if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + paidBookingIds.add(booking.id); + } + } + + const skipped: string[] = []; for (const row of rows) { + if (row.milestoneCode !== 'GATEPASS_GRANTED') continue; if (row.status === 'COMPLETED') continue; + if (!row.bookingId || !paidBookingIds.has(row.bookingId)) { + skipped.push(row.bookingId ?? '(unknown)'); + continue; + } row.status = 'COMPLETED'; row.triggeredAt = securedAt; row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; await milestoneRepo.save(row); } + + if (skipped.length > 0) { + this.logger.warn( + `Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`, + ); + } } async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { @@ -1857,7 +1905,7 @@ export class TrainSchedulingService { ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} - ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} + ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} ${esc(Number(wagon.capacityTons || 0).toFixed(3))} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} @@ -2591,7 +2639,8 @@ export class TrainSchedulingService { const schedules = await this.trainSchedulesRepository.findAll({ relations: { trainSet: { locomotive: true, locomotives: { locomotive: true } }, - route: true, + // Yards carry the route's display name used by mapScheduleListItem. + route: { originYard: true, destinationYard: true }, originStation: true, destinationStation: true, scheduleBookings: { booking: true }, @@ -2758,10 +2807,14 @@ export class TrainSchedulingService { `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`, ); } + // Overweight is the soft threshold (maxVgmTons): the customer already + // paid the overweight surcharge at booking. The hard ceiling + // (maxCapacityTons) blocks booking creation, so anything reaching + // scheduling is shippable — warn the planner, never block allocation. const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); if (overweightLines.length) { - violations.push( - `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, + warnings.push( + `Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`, ); } } @@ -2947,8 +3000,10 @@ export class TrainSchedulingService { } if ( setLimits && - (setLimits.maxPullWeightTons < totalWeightTons || - setLimits.maxTrainLengthMeters < totalLengthMeters) + (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < + totalWeightTons || + setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < + totalLengthMeters) ) { pushLimit([ 'Assigned locomotives cannot support the total train weight and length', @@ -2966,8 +3021,10 @@ export class TrainSchedulingService { if ( !inServiceLocomotives.some( (l) => - Number(l.maxPullWeightTons) >= totalWeightTons && - Number(l.maxTrainLengthMeters) >= totalLengthMeters, + Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= + totalWeightTons && + Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= + totalLengthMeters, ) ) { pushLimit(['No locomotive can support the total train weight and length']); @@ -3022,7 +3079,10 @@ export class TrainSchedulingService { maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; }, - locomotive?: Pick, + locomotive?: Pick< + Locomotive, + 'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters' + >, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -3049,6 +3109,8 @@ export class TrainSchedulingService { { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, + overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, { @@ -3112,16 +3174,27 @@ export class TrainSchedulingService { }; } - private async loadSchedulingWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], - }); + /** + * Every active wagon type: the slot count derives from the shortest wagon the + * fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at + * 12.228m) and under-report how many wagons the train length allows. + */ + private async loadSchedulingWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } @@ -3431,37 +3504,6 @@ export class TrainSchedulingService { return wagonType; } - /** - * Soft wagon-type resolution for the customer-facing availability preview - * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; - * returns null (→ "no days") instead of throwing when nothing is configured, - * since this only estimates which days have wagons and creates no booking. - */ - private async resolveWagonTypeForPreview( - freightType: 'CONTAINER' | 'BULK', - cargoTypeCode: string | null, - ): Promise { - if (freightType === 'BULK') { - if (!cargoTypeCode) return null; - const cargoType = await this.dataSource.getRepository(CargoType).findOne({ - where: { code: cargoTypeCode }, - relations: { wagonType: true }, - }); - return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; - } - - // Container preview: the input carries no specific container type, so use the - // wagon type of the first configured (active) container type. - const containerType = await this.dataSource - .getRepository(ContainerType) - .findOne({ - where: { isActive: true, wagonTypeId: Not(IsNull()) }, - relations: { wagonType: true }, - order: { displayOrder: 'ASC' }, - }); - return containerType?.wagonType?.isActive ? containerType.wagonType : null; - } - /** * Stamp each plan slot with the leg it occupies (dynamic consist): the * boarding/alighting yards of the bookings it carries. Null means the @@ -3719,10 +3761,17 @@ export class TrainSchedulingService { if (locomotive.status !== 'AVAILABLE') { throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); } - if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + if ( + Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) < + totalWeightTons + ) { throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`); } - if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + if ( + Number(locomotive.maxTrainLengthMeters) + + (Number(locomotive.overageToleranceMeters) || 0) < + totalLengthMeters + ) { throw new BadRequestException( `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, ); @@ -4215,13 +4264,14 @@ export class TrainSchedulingService { } /** - * Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given - * cargo. A day is selectable only when ≥1 OPEN schedule on the route that day - * has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that - * schedule's origin yard, and (b) remaining train capacity (not fully - * allocated). Days with trains but not enough matching wagons are excluded. - * Same `{ days: string[] }` shape as getAvailableDays — the customer still - * picks a DAY, not a train. + * Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day + * is selectable when ≥1 OPEN schedule on the route that day still has remaining + * train capacity (not fully allocated). Wagon availability is deliberately NOT + * checked here: whether a matching wagon currently sits in the right yard is an + * operational question staff resolve when they approve or reject the booking, + * not something the customer can act on while choosing a date. Same + * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, + * not a train. */ async getAvailableDaysForCargo(input: { originYardId?: string; @@ -4237,85 +4287,17 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - // Resolve the wagon type this cargo needs via the cargo/container-type FK. - // Soft (customer availability preview): no days if unresolved, never throws. - const requiredType = await this.resolveWagonTypeForPreview( - input.freightType, - input.cargoTypeCode ?? null, - ); - if (!requiredType) return { days: [] }; - - // How many wagons of that type the cargo needs. - const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); - void slotsNeeded; // TEMP: unused while the wagon-availability filter is off. - - // TEMP (per request): wagon-availability filtering is DISABLED. A day is now - // offered whenever a bookable schedule that day has remaining train capacity - // — regardless of whether matching wagons are actually available at the - // origin / boarding yard. This surfaces days even when no wagon is on hand. - // Restore the block below to bring back the "enough matching wagons" gate. - // - // // AVAILABLE wagons of the required type, counted once per origin yard. - // const availableByYard = new Map(); - // const availableAt = async (yardId: string): Promise => { - // const cached = availableByYard.get(yardId); - // if (cached !== undefined) return cached; - // const counts = await this.countFleetAvailability(yardId); - // const n = - // counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; - // availableByYard.set(yardId, n); - // return n; - // }; - const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; - // TEMP (per request): wagon-availability check commented out — see note - // above. Dynamic consist: wagons may ride from the train's origin OR - // already sit at the booking's own boarding yard and attach when the train - // arrives — either pool can serve a sub-corridor booking. - // let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; - // if ( - // !enoughWagons && - // input.originYardId && - // input.originYardId !== s.originStationId - // ) { - // enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded; - // } - // if (!enoughWagons) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } return { days: [...days].sort() }; } - /** - * Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight / - * capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per - * wagon. Mirrors wagon-plan.util without fabricating Booking entities. - */ - private wagonsNeededForCargo( - input: { - freightType: 'CONTAINER' | 'BULK'; - totalWeightTons?: number; - containers?: Array<{ containerSize: string; quantity: number }>; - }, - wagonType: WagonType, - ): number { - if (input.freightType === 'BULK') { - const capacity = Number(wagonType.capacityTons) || 1; - const weight = Number(input.totalWeightTons ?? 0); - return Math.max(1, Math.ceil(weight / capacity)); - } - const teu = (input.containers ?? []).reduce((sum, c) => { - const per = c.containerSize === '40ft' ? 2 : 1; - return sum + per * Math.max(0, Number(c.quantity ?? 0)); - }, 0); - return Math.max(1, Math.ceil(teu / 2)); - } - /** * Ordered stop yards of a schedule's route: origin → milestones → destination, * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 824c45e6e..e180b482b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -6,6 +6,7 @@ import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, + containerWagonsForLines, expandBookingContainerUnits, expandContainerItems, roundTons, @@ -20,7 +21,6 @@ const nw5: WagonType = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, @@ -32,7 +32,6 @@ const cw3: WagonType = { name: 'Covered Wagon', capacityTons: 60, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['BULK'], isActive: true, supportsContainer: false, @@ -200,3 +199,60 @@ describe('wagon-plan.util', () => { expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); }); }); + +describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => { + const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ + quantity, + wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, + containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + }); + + it('20×20ft = 10 wagons (not 20)', () => { + expect(containerWagonsForLines([line(20, 0.5)])).toBe(10); + }); + + it('38×20ft = 19 wagons', () => { + expect(containerWagonsForLines([line(38, 0.5)])).toBe(19); + }); + + it('2×20ft = 1 wagon', () => { + expect(containerWagonsForLines([line(2, 0.5)])).toBe(1); + }); + + it('odd 3×20ft = 2 wagons (single line ceils)', () => { + expect(containerWagonsForLines([line(3, 0.5)])).toBe(2); + }); + + it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => { + // per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3. + expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3); + }); + + it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => { + // per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2. + expect( + containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]), + ).toBe(2); + }); + + it('5×20ft + 2×40ft = 5 wagons', () => { + expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5); + }); + + it('21×40ft = 21 wagons', () => { + expect(containerWagonsForLines([line(21, 1)])).toBe(21); + }); + + it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + // No containerType relation loaded → use the stored (0.5-aware) fraction. + expect( + containerWagonsForLines([ + { quantity: 20, wagonsRequired: 10 } as never, + ]), + ).toBe(10); + }); + + it('empty line set = 0 wagons', () => { + expect(containerWagonsForLines([])).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 21dd7b985..30a46a099 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { consistViolations } from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -35,6 +36,9 @@ export type WagonPlanSlot = { wagonTypeCode: string; capacityTons: number; lengthMeters: number; + /** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */ + tareWeightTons: number; + /** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */ assignedWeightTons: number; allocations: WagonAllocationRecord[]; slotLoadType?: SlotLoadType; @@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number { return Number(numericValue.toFixed(3)); } +/** + * Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill; + * a missing tare must read as 0 rather than silently inventing dead weight. + */ +export function tareTonsOf(wagonType: Pick): number { + return roundTons(wagonType.tareWeightTons ?? 0); +} + /** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; @@ -89,18 +101,38 @@ export function containersPerWagonFromType(wagonsPerUnit: number): number { return Math.max(1, Math.round(1 / wpu)); } -function lineWagonsRequired(line: { +type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; -}): number { +}; + +/** + * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit + * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so + * the BOOKING total is ceiled once — ceiling per line over-counts a booking that + * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + */ +function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; const wpu = Number(line.containerType?.wagonsPerUnit); if (Number.isFinite(wpu) && wpu > 0) { - return Math.ceil(qty * wpu); + return qty * wpu; } - return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1))); + // No wagonsPerUnit on the type: fall back to the line's stored fraction, else + // treat the whole line as one wagon. + const stored = Number(line.wagonsRequired); + return Number.isFinite(stored) && stored > 0 ? stored : 1; +} + +/** + * Whole wagons a set of container lines needs: ceil the summed RAW fraction so a + * half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0. + */ +export function containerWagonsForLines(lines: ContainerLine[]): number { + const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0); + return raw > 0 ? Math.ceil(raw) : 0; } /** @@ -110,21 +142,23 @@ export function buildContainerWagonPlan( bookings: Booking[], wagonType: WagonType, ): WagonPlanSlot[] { + // Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit + // can share a wagon with another 20ft of the SAME booking, never across + // bookings), then sum. Ceiling per line instead would over-count a booking + // that splits its 20ft units across several lines. const totalSlots = bookings.reduce((sum, booking) => { - const lineSlots = (booking.bookingContainers ?? []).reduce( - (lineSum, line) => lineSum + lineWagonsRequired(line), - 0, - ); - return sum + Math.max(lineSlots, 1); + const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []); + return sum + Math.max(bookingSlots, 1); }, 0); - const slots = Math.max(1, Math.ceil(totalSlots)); + const slots = Math.max(1, totalSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, wagonTypeId: wagonType.id, wagonTypeCode: wagonType.code, capacityTons: Number(wagonType.capacityTons), lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -154,6 +188,7 @@ export function buildBulkWagonPlan( wagonTypeCode: wagonType.code, capacityTons: capacity, lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -193,6 +228,7 @@ export function buildMixedWagonPlan( wagonTypeCode: containerWagonType.code, capacityTons: Number(containerWagonType.capacityTons), lengthMeters: Number(containerWagonType.lengthMeters), + tareWeightTons: tareTonsOf(containerWagonType), assignedWeightTons: 0, allocations: [], slotLoadType: 'CONTAINER', @@ -422,47 +458,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string return violations; } +/** + * Check a consist against its train's three limits. Weight is GROSS — every slot + * contributes its own tare plus the cargo assigned to it — because the locomotive + * pull limit governs what it drags, not what was sold. Length and tare are summed + * per slot, so a mixed consist is measured as it actually stands rather than + * through one representative wagon type. + * + * `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain` + * is absent; slot dimensions always win over it. + */ export function validateTrainLimits( wagonPlan: WagonPlanSlot[], - wagonType: WagonType, + wagonType: Pick, limits?: TrainLimitConfig, ): string[] { - const violations: string[] = []; const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const wagonLength = Number(wagonType.lengthMeters) || 14; - const maxWagonsPerTrain = - limits?.maxWagonsPerTrain ?? - Math.floor(maxLengthMeters / wagonLength); + const maxWagonSlots = + limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength); - const totalWeightTons = roundTons( - wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0), + const violations = consistViolations( + wagonPlan.map((slot) => ({ + lengthMeters: Number(slot.lengthMeters), + tareWeightTons: Number(slot.tareWeightTons ?? 0), + cargoTons: Number(slot.assignedWeightTons), + })), + { maxWeightTons, maxLengthMeters, maxWagonSlots }, ); - const totalLengthMeters = roundTons( - wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), - ); - - if (totalWeightTons > maxWeightTons) { - violations.push( - `Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`, - ); - } - if (totalLengthMeters > maxLengthMeters) { - violations.push( - `Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`, - ); - } - if (wagonPlan.length > maxWagonsPerTrain) { - violations.push( - `Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`, - ); - } violations.push(...validateBulkWagonSlotWeights(wagonPlan)); return violations; } +/** + * Mixed consist: the wagon-count fallback uses the shortest type present, since + * that is the most wagons that could ever fit. Weight and length still come from + * the slots themselves. + */ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], wagonTypes: WagonType[], @@ -478,7 +513,7 @@ export function validateMixedTrainLimits( return validateTrainLimits( wagonPlan, - { maxWagonsPerTrain } as WagonType, + { lengthMeters: minWagonLength }, { ...limits, maxWagonsPerTrain }, ); } diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 6de5debda..49bbd1f5f 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -3,7 +3,6 @@ import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, - IsInt, IsNumber, IsOptional, IsString, @@ -14,9 +13,6 @@ import { const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); -const toOptionalNumber = ({ value }: { value: unknown }) => - value === '' || value == null ? undefined : Number(value); - const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; if (value === 'true') return true; @@ -60,12 +56,16 @@ export class CreateWagonTypeDto { @Min(0.001) lengthMeters!: number; - @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 }) - @IsOptional() - @Transform(toOptionalNumber) - @IsInt() - @Min(1) - maxWagonsPerTrain?: number; + @ApiProperty({ + description: + 'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' + + 'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.', + example: 22.4, + }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + tareWeightTons!: number; @ApiPropertyOptional({ description: 'Supported load types, e.g. CONTAINER,BULK', diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts index 2181a2bd1..b3e410f4b 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -19,9 +19,6 @@ export class WagonType extends BaseEntity { @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) lengthMeters!: number; - @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true }) - maxWagonsPerTrain?: number | null; - @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' }) supportedLoadTypes!: string[]; @@ -31,8 +28,9 @@ export class WagonType extends BaseEntity { @Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true }) equatedLengthM?: number | null; - @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) - tareWeightTons?: number | null; + /** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */ + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + tareWeightTons!: number; @Column({ name: 'supports_container', type: 'boolean', default: false }) supportsContainer!: boolean; diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index ec69bb76a..a61880f06 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -80,7 +80,7 @@ export class WagonTypesService { name: dto.name.trim(), capacityTons: dto.capacityTons, lengthMeters: dto.lengthMeters, - maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + tareWeightTons: dto.tareWeightTons ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? [], isActive: dto.isActive ?? true, }); @@ -101,8 +101,6 @@ export class WagonTypesService { ...dto, ...(nextCode ? { code: nextCode } : {}), ...(dto.name ? { name: dto.name.trim() } : {}), - maxWagonsPerTrain: - dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? undefined, }); diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index 03a930b11..d1939c9f5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -1,5 +1,5 @@ import { WagonStatus } from '@edr/types'; -import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; +import { IsString, IsUUID, IsOptional, IsInt, Min, IsEnum } from 'class-validator'; export class CreateWagonDto { @IsString() @@ -17,13 +17,8 @@ export class CreateWagonDto { @Min(1) sequenceNumber?: number; - @IsNumber() - @Min(0) - tareWeight!: number; - - @IsNumber() - @Min(0) - maxPayloadWeight!: number; + // Tare weight and payload capacity are not accepted here: they belong to the + // wagon type and are resolved through wagonTypeId. @IsOptional() @IsEnum(WagonStatus) diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 195b4932b..9f2d41416 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -7,6 +7,7 @@ import { TrainSchedule } from '../../train-schedules/entities/train-schedule.ent import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { Container } from '../../container-management/entities/container.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; export const WAGON_STATUSES = [ WagonStatus.Available, @@ -28,17 +29,19 @@ export class Wagon extends BaseEntity { @Column({ name: 'wagon_type_id', type: 'uuid' }) wagonTypeId!: string; + /** Owns this wagon's spec: tare weight, payload capacity, length. */ + @ManyToOne(() => WagonType) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId!: string | null; @Column({ name: 'sequence_number', type: 'int', nullable: true }) sequenceNumber!: number | null; - @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) - tareWeight!: number; - - @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) - maxPayloadWeight!: number; + // Tare weight and payload capacity are properties of the wagon TYPE — read them + // through `wagonType`, never off the individual wagon. @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 9d1f1b41f..b010c0351 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -52,14 +52,23 @@ export class WagonsService { }); } - const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '') + // Spec columns (tare, payload) are no longer sortable here — they live on the + // wagon type, so sorting by them is sorting by wagonTypeId. + const sortable: Array = [ + 'wagonNumber', + 'status', + 'currentYardId', + 'sequenceNumber', + 'wagonTypeId', + ]; + const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon) ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ where: search ? where : filters, - relations: { currentYard: true }, + relations: { currentYard: true, wagonType: true }, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, @@ -69,7 +78,7 @@ export class WagonsService { async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, - relations: { currentYard: true }, + relations: { currentYard: true, wagonType: true }, }); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); return wagon; 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 9eb4ea502..242cecc76 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 @@ -361,6 +361,16 @@ export class WarehouseInventoryController { return this.handoverService.requestSignature(bookingId); } + @Get('bookings/:bookingId/handover-document') + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) + async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/container-items') @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { 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 14f4e27f3..06e84d455 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 @@ -860,6 +860,25 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + /** Sent after the transaction commits so the gateway never blocks the receive. */ + const pendingNotifications: Array<{ + owner: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }; + booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }; + bookingId: string; + }> = []; await this.dataSource.transaction(async (manager) => { await this.validateLocation(manager, { @@ -1032,21 +1051,33 @@ export class WarehouseInventoryService { manager, ); - await this.notifyOwnerInventoryReceived({ - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }, + booking, + bookingId, }); result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); - void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); + // Fan out after commit, un-awaited: the receive response must not wait on the + // SMS gateway. Both notifiers swallow their own errors. + for (const pending of pendingNotifications) { + void this.notifyOwnerInventoryReceived(pending.owner); + void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId); + } + return result; } @@ -2603,12 +2634,13 @@ export class WarehouseInventoryService { Array<{ containerNumber: string; goods: string | null; - stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; + stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; grnNumber: string | null; truckAssignmentId: string | null; truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; @@ -2624,6 +2656,7 @@ export class WarehouseInventoryService { truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; @@ -2637,6 +2670,7 @@ export class WarehouseInventoryService { a.plate_number AS "truckPlate", (a.arrived_at IS NOT NULL) AS "truckArrived", (a.departed_at IS NOT NULL) AS "truckLeft", + (ctc.loaded_at IS NOT NULL) AS loaded, b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", @@ -2666,22 +2700,28 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, + // A container the customer assigned to a truck is ASSIGNED (planned); it + // only becomes LOADED once the operator loads it (loaded_at) on truck + // leaving. Departed → LEFT, delivered → DELIVERED. stage: r.delivered ? 'DELIVERED' : r.truckLeft ? 'LEFT' - : r.truckAssignmentId + : r.loaded ? 'LOADED' - : r.grnNumber - ? 'GRN' - : r.received - ? 'RECEIVED' - : 'PENDING', + : r.truckAssignmentId + ? 'ASSIGNED' + : r.grnNumber + ? 'GRN' + : r.received + ? 'RECEIVED' + : 'PENDING', grnNumber: r.grnNumber, truckAssignmentId: r.truckAssignmentId, truckPlate: r.truckPlate, truckArrived: r.truckArrived, truckLeft: r.truckLeft, + loaded: r.loaded, bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, @@ -3016,6 +3056,21 @@ export class WarehouseInventoryService { }; } + /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ + async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + 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 updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3242,7 +3297,22 @@ export class WarehouseInventoryService { [item.bookingId], ); } else { - await this.handover.ensureAtDelivery(item.bookingId, {}, manager); + // EDR last-mile: the handover is per delivering truck. Resolve the + // vehicle that carried this item's container so each truck gets its own + // handover (falls back to a booking-level one when unresolvable). + let truckPlate: string | null = null; + if (item.containerId) { + const [veh]: Array<{ plate: string | null }> = await manager.query( + `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate + FROM freight.last_mile_container_allocations lca + JOIN freight.vehicles v ON v.id = lca.vehicle_id + WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL + LIMIT 1`, + [item.containerId], + ); + truckPlate = veh?.plate ?? null; + } + await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager); } } }); diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index 9aa9e169c..a8abee67b 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -227,7 +227,6 @@ async function ensureReferences(manager: any) { name: 'Gate Pass Demo Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, equatedLengthM: 14, @@ -420,8 +419,6 @@ async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: nu wagonTypeId, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: yardId, currentTrainScheduleId: scheduleId, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 3ebcca6ab..4f801330a 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -102,7 +102,6 @@ async function main() { name: 'Negad Demo Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, equatedLengthM: 14, @@ -307,8 +306,6 @@ async function main() { wagonTypeId: wagonType.id, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: indode.id, notes: 'Demo wagon for Negad to Indode marshalling', diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts index c5a49e629..ebee2a547 100644 --- a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; @@ -91,12 +92,26 @@ export class Batch5TestDataSeeder { return; } + // bookings.company_id AND bookings.company_profile_id are both NOT NULL, so a + // seed booking needs an owning company profile. Resolve the profile and take + // its company from it, so the two columns can never disagree. Without this the + // seeder aborted on its first insert. + const companyProfile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: {} }); + if (!companyProfile) { + this.logger.warn('No company profile found; skipping Batch 5 seed'); + return; + } + const now = new Date(); for (const seed of SEEDS) { const booking = await bookingRepo.save( bookingRepo.create({ reference: seed.ref, + companyId: companyProfile.companyId, + companyProfileId: companyProfile.id, originYardId: originYard.id, destinationYardId: destYard.id, serviceTypeId: serviceType.id, 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 c42d831bc..3720c0e2a 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -211,7 +211,6 @@ export class DemoBookingsSeeder { name: "Flat Wagon", capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ["CONTAINER"], isActive: true, equatedLengthM: 14, @@ -224,7 +223,6 @@ export class DemoBookingsSeeder { name: "Covered Hopper", capacityTons: 60, lengthMeters: 12, - maxWagonsPerTrain: 55, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 12, @@ -236,7 +234,6 @@ export class DemoBookingsSeeder { name: "Powder Wagon", capacityTons: 55, lengthMeters: 12, - maxWagonsPerTrain: 55, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 12, @@ -248,7 +245,6 @@ export class DemoBookingsSeeder { name: "Open Wagon", capacityTons: 65, lengthMeters: 13, - maxWagonsPerTrain: 53, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 13, @@ -508,8 +504,6 @@ export class DemoBookingsSeeder { wagonTypeId: nw5.id, trainId: null, sequenceNumber: null, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Available, currentYardId: index % 2 === 0 ? djibouti.id : addis.id, notes: "Demo wagon for train scheduling", diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index da2da818f..45c374332 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -76,15 +76,11 @@ export class DemoFreightDataSeeder { } const toCreate = MIN_WAGONS_PER_TYPE - existing; - const tare = Number(type.tareWeightTons ?? 20); - const maxPayload = Number(type.capacityTons ?? 60); const rows = Array.from({ length: toCreate }, (_, i) => { const seq = existing + i + 1; return wagonRepo.create({ wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`, wagonTypeId: type.id, - tareWeight: tare, - maxPayloadWeight: maxPayload, status: WagonStatus.Available, }); }); diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 1aef33801..99dae5974 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -33,8 +33,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ }, { fileKey: "commercial_license", - fileLabel: "Commercial License", - helpText: "Verified against the government trade system during registration.", + fileLabel: "Commercial Registration", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -108,7 +109,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ { fileKey: "business_license", fileLabel: "Business License / Trade License", - helpText: "Verified against the government trade system during registration.", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -489,9 +491,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ const CONTRACT_INTAKE_ENTITY = "contract_intake"; const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ - clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, { - required: false, - }), + clearanceField( + "commercial_framework", + "Commercial Framework / Agreement", + 1, + { + required: false, + }, + ), clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { required: false, }), @@ -542,7 +549,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { await this.dataSource.transaction(async (manager) => { @@ -552,35 +559,35 @@ export class FileUploadSettingsSeeder { const allSettings: Array< OnboardingDocumentSetting & { description: string } > = [ - ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ - ...s, - description: COMPANY_ONBOARDING_DESCRIPTION, - })), - ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: CLEARANCE_DESCRIPTION, - })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), - ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: - "Documents uploaded against a driver profile (license, ID, contracts, etc.).", - })), - ]; + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", + })), + ...SELF_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", + })), + ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ + ...s, + description: + "Commercial/framework documents attached at contract submission.", + })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), + ]; for (const documentSetting of allSettings) { await settingRepository.upsert( @@ -601,7 +608,9 @@ export class FileUploadSettingsSeeder { }); if (!setting) { - throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + throw new Error( + `file_upload_setting_seed_failed:${documentSetting.code}`, + ); } await fieldRepository.delete({ settingId: setting.id }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d4d85e84c..e281e6464 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'), perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'), perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'), + perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'), ]; // D. Finance — payments + invoices @@ -204,6 +205,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), + perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'), perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), @@ -394,6 +396,7 @@ export const FREIGHT_PERMS = { update: 'edr_freight_app:customers:update', deactivate: 'edr_freight_app:customers:deactivate', verify: 'edr_freight_app:customers:verify', + resetPassword: 'edr_freight_app:customers:reset-password', }, payments: { view: 'edr_freight_app:payments:view', @@ -477,6 +480,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: 'edr_freight_app:tracking:view', + manage: 'edr_freight_app:tracking:manage', }, fuel: { view: 'edr_freight_app:fuel:view', @@ -660,9 +664,13 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, // Path A (no customs): Operations reviews the customer's self-clearance docs - // on the contract before the customer may create a shipment booking. + // — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL + // contracts (booking-level document review → finalize → CLEARANCE_READY). FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.opsClearanceReview, + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.bookings.reviewDocuments, + FREIGHT_PERMS.bookings.finalizeClearance, ...allRuleEngineViewKeys(), ], director: [ diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts index 53d6c9eec..eec158856 100644 --- a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -203,7 +203,6 @@ export class MarshallingDemoTrainsSeeder { const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0); const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; const trainSet = await trainSetRepo.save( trainSetRepo.create({ @@ -275,8 +274,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: refs.wagonType.id, yardId: originYard.id, trainScheduleId: schedule.id, - tareWeight, - capacityTons: wagonCapacity, dispatched: hasDeparted, }); @@ -420,8 +417,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: string; yardId: string; trainScheduleId: string; - tareWeight: number; - capacityTons: number; dispatched: boolean; }): Promise { const repo = this.dataSource.getRepository(Wagon); @@ -433,8 +428,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: input.wagonTypeId, currentYardId: input.yardId, currentTrainScheduleId: input.trainScheduleId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, status: WagonStatus.Assigned, notes: 'Marshalling demo seed wagon', }), diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index fbc19100a..d8e585a35 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -60,10 +61,16 @@ export class WarehouseDemoSeeder { (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? (await serviceTypeRepo.findOne({ where: { isActive: true } })); const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + // bookings.company_id AND company_profile_id are both NOT NULL — a demo booking + // still needs an owner. Take the company from the profile so they always agree. + const companyProfile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: {} }); - if (!djibYard || !ethYard || !serviceType) { + if (!djibYard || !ethYard || !serviceType || !companyProfile) { this.logger.warn( - `Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`, + `Missing yards/service type/company profile (djib=${djibYard?.code}, eth=${ethYard?.code}, ` + + `svc=${serviceType?.code}, companyProfile=${companyProfile?.id ?? 'none'}); skipping`, ); return; } @@ -89,7 +96,7 @@ export class WarehouseDemoSeeder { ): Promise => bookingRepo.save( bookingRepo.create({ - ...this.demoBookingDefaults(), + ...this.demoBookingDefaults(companyProfile), reference, originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, @@ -182,7 +189,15 @@ export class WarehouseDemoSeeder { } // 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet. - await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360)); + await this.seedArrivedImportTrain( + djibYard, + ethYard, + serviceType, + cargoType, + companyProfile, + ago(60), + ago(360), + ); created += 1; this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`); @@ -199,6 +214,7 @@ export class WarehouseDemoSeeder { ethYard: Yard, serviceType: ServiceType, cargoType: CargoType | null, + owner: CompanyProfile, arrival: Date, departure: Date, ): Promise { @@ -238,7 +254,7 @@ export class WarehouseDemoSeeder { for (let i = 1; i <= 3; i++) { const b = await bookingRepo.save( bookingRepo.create({ - ...this.demoBookingDefaults(), + ...this.demoBookingDefaults(owner), reference: `WH-DEMO-ARR-${i}`, originYardId: djibYard.id, destinationYardId: ethYard.id, @@ -258,8 +274,10 @@ export class WarehouseDemoSeeder { } } - private demoBookingDefaults(): Partial { + private demoBookingDefaults(owner: CompanyProfile): Partial { return { + companyId: owner.companyId, + companyProfileId: owner.id, scheduledDate: new Date(), contractType: 'SPOT', equipmentReturn: 'TERMINAL', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index df8db49d4..0bee03fc6 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -53,11 +53,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; -// Hidden for now — Shipment Requests pages disabled (imports kept commented). -// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; -// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; +import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; +import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; @@ -187,13 +187,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - // Hidden for now — Shipment Requests nav item disabled. - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -752,7 +757,6 @@ const App = () => { } /> - {/* Hidden for now — Shipment Requests pages disabled. { } /> - */} {/* GL (Path B) contract clearance review hub */} { } /> - {/* Path A ops queue out of scope for now → fold into the GL hub. */} + {/* Path A — Operations reviews per-booking self-clearance documents + (GENERAL contracts without customs). */} } + element={ + + + + } /> >({}); @@ -240,7 +247,7 @@ export function ClearanceReviewSection({ - {clearance.outputCode && ( + {clearance.outputCode && !phasedCustoms && ( )} - {finalizeMutation.isError && ( + {!phasedCustoms && finalizeMutation.isError && ( }> {finalizeMutation.error instanceof Error ? finalizeMutation.error.message @@ -349,8 +356,10 @@ export function ClearanceReviewSection({ )} - - + {phasedCustoms ? ( + // Phased (GENERAL customs) — no legacy finalize; the milestone steps in + // the action panel drive the workflow, same as ONE_TIME contracts. + - + {clearance.allApproved ? ( + + ) : ( + + )} {clearance.allApproved - ? "All required documents are approved — you can finalize." - : "Approve every required document to unlock finalization."} + ? "All required documents are approved. Continue declaration, duty, and transit in the action panel." + : "Approve every required document to unlock the customs milestone steps."} - - - + + ) : ( + + + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + )} {viewer} ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index dc59c4dfc..c94e005ae 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -50,6 +50,13 @@ import { import { contractsService } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; +/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */ +function todayISODate(): string { + const now = new Date(); + const tz = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - tz).toISOString().slice(0, 10); +} + /** * Export customs flow, ordered per the stakeholder process: * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) @@ -937,6 +944,7 @@ export function ReleaseOrderCard({ ); const [loading, setLoading] = useState(false); const [amendLoading, setAmendLoading] = useState(false); + const minVesselDate = useMemo(todayISODate, []); return ( @@ -949,6 +957,7 @@ export function ReleaseOrderCard({ label="Vessel departure date" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={minVesselDate} size="sm" /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index 87a8993ee..1953796dd 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { DateInput } from "@mantine/dates"; import { Ship, Upload } from "lucide-react"; @@ -40,6 +40,13 @@ export function GlClearanceUploadModal({ vesselDepartureDate ? new Date(vesselDepartureDate) : null, ); const [loading, setLoading] = useState(false); + // Earliest selectable vessel date (today, local) — refreshed on each open. + const todayISODate = useMemo(() => { + if (!opened) return undefined; + const now = new Date(); + const tz = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - tz).toISOString().slice(0, 10); + }, [opened]); const isDo = kind === "do"; const isRo = kind === "ro"; @@ -115,6 +122,7 @@ export function GlClearanceUploadModal({ label="Vessel departure date" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={todayISODate} size="sm" required /> @@ -123,6 +131,7 @@ export function GlClearanceUploadModal({ label="Vessel arrival date (optional)" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={todayISODate} size="sm" clearable /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 547e9ce9f..a744453c0 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -64,6 +64,21 @@ import { /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; +// ISO 6346: 4-letter owner/category code + 6-digit serial + check digit. +// Same rule the customer portal shipment form enforces. +const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; + +interface UnitErrors { + containerNumber?: string; + vgmTons?: string; +} + +interface BulkErrors { + quantity?: string; + hazardous?: string; + reefer?: string; +} + function fmtWindowOpensAt(iso: string): string { const date = new Date(iso).toLocaleDateString("en-GB", { weekday: "short", @@ -372,6 +387,72 @@ export default function GlCreateBookingForm() { prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), ); + // Same client-side validation as the customer portal shipment form: ISO + // container numbers (unique within the shipment) and a positive VGM per unit; + // bulk needs a positive quantity with hazardous/reefer portions bounded by it. + const [showErrors, setShowErrors] = useState(false); + + const unitErrors = useMemo(() => { + if (!isContainer) return []; + const numberCounts = new Map(); + containerLines.forEach((line) => + line.units.forEach((u) => { + const key = u.containerNumber.trim().toUpperCase(); + if (!key) return; + numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1); + }), + ); + return containerLines.map((line) => + line.units.map((u) => { + const errs: UnitErrors = {}; + const key = u.containerNumber.trim().toUpperCase(); + if (!key) { + errs.containerNumber = "Container number is required."; + } else if (!ISO_CONTAINER_NUMBER_REGEX.test(key)) { + errs.containerNumber = + "Enter a valid ISO container number (e.g. ABCD1234567)."; + } else if ((numberCounts.get(key) ?? 0) > 1) { + errs.containerNumber = "Duplicate container number in this shipment."; + } + const vgm = Number(u.vgmTons); + if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) { + errs.vgmTons = "Enter a valid VGM."; + } + return errs; + }), + ); + }, [isContainer, containerLines]); + + const bulkErrors = useMemo(() => { + if (isContainer) return []; + return bulkLines.map((line) => { + const errs: BulkErrors = {}; + const qty = Number(line.cargoWeightTons || line.itemCount || 0); + if (Number.isNaN(qty) || qty <= 0) { + errs.quantity = "Enter a quantity greater than 0."; + } + const h = Number(line.hazardousQuantity || 0); + if (Number.isNaN(h) || h < 0) { + errs.hazardous = "Enter a valid hazardous quantity."; + } else if (qty > 0 && h > qty) { + errs.hazardous = `Can't exceed the cargo quantity (${qty}).`; + } + const r = Number(line.reeferQuantity || 0); + if (Number.isNaN(r) || r < 0) { + errs.reefer = "Enter a valid refrigerated quantity."; + } else if (qty > 0 && r > qty) { + errs.reefer = `Can't exceed the cargo quantity (${qty}).`; + } + return errs; + }); + }, [isContainer, bulkLines]); + + const cargoValid = isContainer + ? unitErrors.every((line) => + line.every((e) => !e.containerNumber && !e.vgmTons), + ) + : bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer); + const canSubmit = windowOpen && Boolean(scheduledDate) && @@ -401,7 +482,7 @@ export default function GlCreateBookingForm() { hazardousQuantity: l.units.filter((u) => u.hazardous).length, reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ - containerNumber: u.containerNumber, + containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, })), @@ -458,6 +539,13 @@ export default function GlCreateBookingForm() { const overweightLines = validation?.overweightLines ?? []; const openPriceModal = () => { + // Surface the per-field errors (portal-parity validation) instead of + // sending an invalid payload to the price preview. + if (!cargoValid) { + setShowErrors(true); + return; + } + setShowErrors(false); setPriceOpen(true); const payload = buildPayload(); if (payload) { @@ -467,7 +555,7 @@ export default function GlCreateBookingForm() { }; const handleSubmit = () => { - if (!contract || !windowOpen) return; + if (!contract || !windowOpen || !cargoValid) return; // Never book past unresolved 20ft pairing hard-blocks. if (pairingErrors.length > 0) return; // A line above the container type's max capacity can never book. @@ -483,8 +571,13 @@ export default function GlCreateBookingForm() { } catch { // Non-fatal } - navigate(`/dashboard/bookings/${booking.id}/clearance`); + } + if (contract.contractKind === "GENERAL") { + // GENERAL per-booking clearance: land on the booking's clearance + // detail — the same page the Shipments tab on the hub opens. + navigate(`/dashboard/clearance/${booking.id}`); } else { + // ONE_TIME customs keeps its clearance on the contract. navigate(`/dashboard/contracts/clearance/${contract.id}`); } }, @@ -692,6 +785,11 @@ export default function GlCreateBookingForm() { label={unitIdx === 0 ? "Container number *" : undefined} placeholder="e.g. MSCU1234567" value={unit.containerNumber} + error={ + showErrors + ? unitErrors[lineIdx]?.[unitIdx]?.containerNumber + : undefined + } onChange={(e) => patchUnit(lineIdx, unitIdx, { containerNumber: e.currentTarget.value, @@ -720,6 +818,11 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={unit.vgmTons} + error={ + showErrors + ? unitErrors[lineIdx]?.[unitIdx]?.vgmTons + : undefined + } onChange={(v) => patchUnit(lineIdx, unitIdx, { vgmTons: v }) } @@ -808,6 +911,7 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={line.cargoWeightTons} + error={showErrors ? bulkErrors[idx]?.quantity : undefined} onChange={(v) => patchBulk(idx, { cargoWeightTons: v })} radius={10} styles={fieldStyles} @@ -818,6 +922,7 @@ export default function GlCreateBookingForm() { placeholder="e.g. 500" min={0} value={line.itemCount} + error={showErrors ? bulkErrors[idx]?.quantity : undefined} onChange={(v) => patchBulk(idx, { itemCount: v })} radius={10} styles={fieldStyles} @@ -828,6 +933,7 @@ export default function GlCreateBookingForm() { label="Hazardous quantity" min={0} value={line.hazardousQuantity} + error={showErrors ? bulkErrors[idx]?.hazardous : undefined} onChange={(v) => patchBulk(idx, { hazardousQuantity: v })} radius={10} styles={fieldStyles} @@ -838,6 +944,7 @@ export default function GlCreateBookingForm() { label="Refrigerated quantity" min={0} value={line.reeferQuantity} + error={showErrors ? bulkErrors[idx]?.reefer : undefined} onChange={(v) => patchBulk(idx, { reeferQuantity: v })} radius={10} styles={fieldStyles} @@ -905,26 +1012,39 @@ export default function GlCreateBookingForm() { marginTop: 24, }} > - - - - + + {showErrors && !cargoValid ? ( + } + mb="sm" + > + Fix the highlighted cargo fields before reviewing the price. + + ) : null} + + + + + isImport @@ -251,9 +262,17 @@ export function PhasedClearanceActionPanel({ effectiveBookingCreated, bookingMilestones, t1Uploaded, + freightPaid, ) : 0, - [clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded], + [ + clearance, + isImport, + effectiveBookingCreated, + bookingMilestones, + t1Uploaded, + freightPaid, + ], ); if (isImport) { @@ -554,14 +573,26 @@ export function PhasedClearanceActionPanel({ )} + : } + > + + + : } > - + + ); + } + const wagonAllocated = Boolean(clearance.train?.wagonAllocated); return ( @@ -1015,6 +1064,18 @@ function RiskStep({ ); } + // Customs cannot rate cargo still under transit — the server rejects the + // assignment until the T1 is closed, so do not offer the control yet. + if (!clearance.t1?.closed) { + return ( + + ); + } + if (!canAct || !bookingId) { return ( ; +} + +/** + * Staff-triggered password reset. Sends a one-time code to the customer's + * primary contact; the customer picks their own new password. No credential is + * ever shown to or handled by staff. + */ +export default function ResetPasswordAction({ company }: ResetPasswordActionProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [opened, setOpened] = useState(false); + const [channel, setChannel] = useState("phone"); + + const { mutate, isPending } = useMutation( + api.customers.resetPassword.mutationOptions({ + onSuccess: (result) => { + setOpened(false); + toast({ + title: "Reset code sent", + description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`, + }); + }, + onError: (error) => { + toast({ + title: "Could not send reset code", + description: error.message, + variant: "destructive", + }); + }, + }), + ); + + if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null; + + return ( + <> + + + setOpened(false)} + title="Send a password-reset code" + centered + > + + + We'll send a one-time code to this customer's primary contact. + They choose their own new password — you will not see it. + + + setChannel(v as ResetChannel)} + label="Send the code via" + > + + + + + + + + The code goes to the primary contact's own email or phone, which + may differ from the company contact details shown above. + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 2a87e3b0c..d42673d2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -13,5 +13,9 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { + default as ResetPasswordAction, + type ResetPasswordActionProps, +} from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 391a8157d..698b0fc2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -250,7 +250,10 @@ const FreightSidebar = ({ diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 8553bcf0b..8c1aed302 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -307,13 +307,24 @@ const RuleEngineFormDialog = ({ ); } + const isNumber = field.type === "number"; + return ( setField(field.name, e.currentTarget.value)} + onChange={(e) => { + const next = e.currentTarget.value; + if (isNumber && next.trim().startsWith("-")) return; + setField(field.name, next); + }} placeholder={field.placeholder} required={field.required} size="md" 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 ec3774ce6..1bfa392bc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({ } min={1} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> ) : ( @@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({ } min={0} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx index 06c9013d4..9c3c54f3c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -98,6 +98,7 @@ export default function DurationField({ emitNative(v === "" ? "" : Number(v), unit) } clampBehavior="none" + allowNegative={false} allowDecimal min={min != null ? convert(min, nativeUnit, unit) : 0} disabled={disabled} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx index 6ea7dfc5b..c173614dc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx @@ -67,9 +67,13 @@ export default function EditScheduleDateModal({ ); const [value, setValue] = useState(""); + // Earliest selectable departure, refreshed each time the modal opens. + const [minValue, setMinValue] = useState(""); useEffect(() => { - if (opened) setValue(toLocalInputValue(currentDate)); + if (!opened) return; + setValue(toLocalInputValue(currentDate)); + setMinValue(toLocalInputValue(new Date().toISOString())); }, [opened, currentDate]); const handleSave = async () => { @@ -77,6 +81,13 @@ export default function EditScheduleDateModal({ toast({ title: "Pick a departure date", variant: "destructive" }); return; } + if (new Date(value).getTime() < Date.now()) { + toast({ + title: "Departure date must be in the future", + variant: "destructive", + }); + return; + } try { await save.mutateAsync({ id: scheduleId, @@ -124,6 +135,7 @@ export default function EditScheduleDateModal({ setValue(e.currentTarget.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index 84367fad7..00b0abd87 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -207,9 +207,9 @@ function RankedCard({ {/* Wagons */} - + {/* {booking.wagons}w - + */} {/* State chip / pay countdown */} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx index d0f670e51..271603bca 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx @@ -1,9 +1,19 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core"; import toast from "react-hot-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; +/** `min` for a `datetime-local` input: now, in the browser's local zone. */ +function nowLocalDateTime(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `T${pad(now.getHours())}:${pad(now.getMinutes())}` + ); +} + export function RescheduleTrainDialog({ scheduleId, currentBookingIds, @@ -20,12 +30,21 @@ export function RescheduleTrainDialog({ const [newDepartureDate, setNewDepartureDate] = useState(""); const [reason, setReason] = useState(""); const [loading, setLoading] = useState(false); + // Earliest selectable departure, refreshed each time the dialog opens. + const minDepartureDate = useMemo( + () => (opened ? nowLocalDateTime() : ""), + [opened], + ); const handleSubmit = async () => { if (!newDepartureDate) { toast.error("Select a new departure date"); return; } + if (new Date(newDepartureDate).getTime() < Date.now()) { + toast.error("New departure must be in the future"); + return; + } setLoading(true); try { await trainSchedulingService.maintenanceReschedule(scheduleId, { @@ -53,6 +72,7 @@ export function RescheduleTrainDialog({ setNewDepartureDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx index bb769df46..57be67d68 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -37,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [ { value: 'ALL', label: 'All' }, { value: 'RECEIVED', label: 'Received' }, { value: 'GRN', label: "GRN'd" }, + { value: 'ASSIGNED', label: 'Assigned' }, { value: 'LOADED', label: 'Loaded' }, { value: 'LEFT', label: 'Left' }, { value: 'DELIVERED', label: 'Delivered' }, @@ -46,13 +47,15 @@ const STAGE_COLOR: Record = { PENDING: 'gray', RECEIVED: 'blue', GRN: 'teal', + ASSIGNED: 'indigo', LOADED: 'grape', LEFT: 'orange', DELIVERED: 'green', }; -/** Loadable = not yet on a truck (before LOADED). */ -const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN'; +/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */ +const isLoadable = (i: ContainerItem) => + i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED'; export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) { const { toast } = useToast(); @@ -77,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen () => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)), [items, tab], ); + // Only arrived, not-yet-departed trucks can be loaded. const truckOptions = trucks - .filter((t) => !(t as { departedAt?: string }).departedAt) + .filter( + (t) => + Boolean((t as { arrivedAt?: string }).arrivedAt) && + !(t as { departedAt?: string }).departedAt, + ) .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` })); const loadMutation = useMutation({ @@ -181,7 +189,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen {i.contractId ? Contract : '—'} {i.hasLastMile ? EDR : Self-haul} - {i.truckAssignmentId && ( + {i.loaded && i.truckAssignmentId && ( truck.value === truckPlateNumber) ? truckPlateNumber : null} onChange={(value) => { const truck = truckSelectOptions.find((row) => row.value === value); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 77a3bc95e..c6f98c8a2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -86,6 +86,8 @@ export const URL_CONSTANTS = { `/bookings/by-company/${id}/customer-view`, PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, + RESET_PASSWORD: (companyId: string) => + `/backoffice/customers/${companyId}/reset-password`, }, BILLING: { diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c8bfa7c84..92fe85ad7 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -62,6 +62,7 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:customers:update", deactivate: "edr_freight_app:customers:deactivate", verify: "edr_freight_app:customers:verify", + resetPassword: "edr_freight_app:customers:reset-password", }, payments: { view: "edr_freight_app:payments:view", @@ -145,6 +146,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: "edr_freight_app:tracking:view", + manage: "edr_freight_app:tracking:manage", }, fuel: { view: "edr_freight_app:fuel:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index a514cd3ec..2cb5404e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() { hideSummary approvalsLocked={isPhasedGeneral && docsPhaseComplete} queriesLocked={queriesLocked} + phasedCustoms={isPhasedGeneral} onChanged={() => void refetch()} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx index da93d5995..542852a5f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx @@ -128,7 +128,16 @@ function DirectionIcon({ direction }: { direction: string }) { ); } -export default function DocumentClearanceListPage() { +export default function DocumentClearanceListPage({ + opsMode = false, +}: { + /** + * true → Operations self-clearance queue: NON-customs bookings whose + * per-booking clearance docs the operations team reviews (GENERAL Path A). + * false → legacy GL queue: customs bookings only. + */ + opsMode?: boolean; +}) { const navigate = useNavigate(); const [pageTab, setPageTab] = useState("queue"); const [activeTab, setActiveTab] = useState("all"); @@ -139,7 +148,7 @@ export default function DocumentClearanceListPage() { const isHistory = pageTab === "history"; const { data, isLoading, isError, isFetching, refetch } = useQuery({ - queryKey: ["clearance", "list", isHistory], + queryKey: ["clearance", "list", isHistory, opsMode], queryFn: () => bookingsService.list({ status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS, @@ -148,8 +157,10 @@ export default function DocumentClearanceListPage() { }); const allRows = useMemo(() => { - // GL clearance queue: customs bookings only - const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms); + // opsMode: self-clearance (non-customs) bookings; else customs bookings only. + const rows = (data?.items ?? []) + .map(toClearanceRow) + .filter((r) => (opsMode ? !r.hasCustoms : r.hasCustoms)); if (isHistory) { return [...rows].sort((a, b) => { @@ -159,7 +170,7 @@ export default function DocumentClearanceListPage() { }); } return rows; - }, [data?.items, isHistory]); + }, [data?.items, isHistory, opsMode]); const tabCounts = useMemo( () => ({ @@ -310,8 +321,12 @@ export default function DocumentClearanceListPage() { (defaultQueue); @@ -202,16 +209,29 @@ export default function ContractClearanceListPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } = - useContractClearanceQueue(queueTab === "all"); + useContractClearanceQueue(queueTab === "all" || queueTab === "shipments"); const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } = useEtClearanceQueue(queueTab === "et"); + const { + data: bookingQueue, + isLoading: bookingsLoading, + isError: bookingsError, + isFetching: bookingsFetching, + refetch: refetchBookings, + } = useBookingEtClearanceQueue(queueTab === "shipments"); const data = queueTab === "et" ? etData : allData; const isLoading = queueTab === "et" ? etLoading : allLoading; const isError = queueTab === "et" ? etError : allError; - const isFetching = queueTab === "et" ? etFetching : allFetching; + const isFetching = + queueTab === "et" + ? etFetching + : queueTab === "shipments" + ? bookingsFetching + : allFetching; const refetch = () => { if (queueTab === "et") void refetchEt(); + else if (queueTab === "shipments") void refetchBookings(); else void refetchAll(); }; @@ -239,9 +259,43 @@ export default function ContractClearanceListPage() { ), }); } + if (canReview || canEt) { + opts.push({ + value: "shipments", + label: ( + + + Shipments + + ), + }); + } return opts; }, [canReview, canEt]); + // GENERAL-contract shipment bookings in per-booking clearance (ET queue). + const bookingRows = useMemo(() => { + const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({ + id: b.id, + reference: b.reference, + customerLabel: b.company?.name ?? b.governmentInstitution ?? "—", + originLabel: b.originYard?.name ?? "—", + destinationLabel: b.destinationYard?.name ?? "—", + tradeDirection: b.tradeDirection ?? "—", + freightType: b.freightType ?? "—", + status: b.status, + })); + const q = query.trim().toLowerCase(); + if (!q) return rows; + return rows.filter( + (r) => + r.reference.toLowerCase().includes(q) || + r.customerLabel.toLowerCase().includes(q) || + r.originLabel.toLowerCase().includes(q) || + r.destinationLabel.toLowerCase().includes(q), + ); + }, [bookingQueue, query]); + const allRows = useMemo( () => (data?.items ?? []).map(toClearanceRow), [data?.items], @@ -269,7 +323,7 @@ export default function ContractClearanceListPage() { ); }, [allRows, query]); - const total = rows.length; + const total = queueTab === "shipments" ? bookingRows.length : rows.length; const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); const pagedRows = useMemo(() => { @@ -410,16 +464,29 @@ export default function ContractClearanceListPage() { } action={ - refetch()} - loading={isFetching} - aria-label="Refresh" - > - - + + {canCreateBooking ? ( + + ) : null} + refetch()} + loading={isFetching} + aria-label="Refresh" + > + + + } /> @@ -528,7 +595,14 @@ export default function ContractClearanceListPage() { - {view === "table" ? ( + {queueTab === "shipments" ? ( + navigate(`/dashboard/clearance/${id}`)} + /> + ) : view === "table" ? ( columns={columns} @@ -567,6 +641,143 @@ export default function ContractClearanceListPage() { ); } +interface ShipmentBookingRow { + id: string; + reference: string; + customerLabel: string; + originLabel: string; + destinationLabel: string; + tradeDirection: string; + freightType: string; + status: string; +} + +const prettyStatus = (s: string) => + s + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +const shipmentStatusColor = (s: string) => { + if (s === "AWAITING_DOCUMENTS") return "yellow"; + if (s === "DOCUMENTS_UNDER_REVIEW") return "blue"; + if (s === "CLEARANCE_READY") return "edr-green"; + return "gray"; +}; + +/** GENERAL-contract shipment bookings currently in per-booking clearance. */ +function ShipmentBookingsTable({ + rows, + loading, + error, + onOpen, +}: { + rows: ShipmentBookingRow[]; + loading: boolean; + error: boolean; + onOpen: (id: string) => void; +}) { + const columns = useMemo[]>( + () => [ + { + id: "booking", + header: () => Booking, + cell: ({ row }) => ( +
+
+ +
+
+

+ {row.original.reference} +

+

+ + {row.original.customerLabel} +

+
+
+ ), + }, + { + id: "route", + header: () => Route, + cell: ({ row }) => ( + + + {row.original.originLabel} + + + + {row.original.destinationLabel} + + + ), + }, + { + id: "kind", + header: () => Type, + cell: ({ row }) => ( + + + {prettyStatus(row.original.tradeDirection)} + + + {prettyStatus(row.original.freightType)} + + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + {prettyStatus(row.original.status)} + + ), + }, + { + id: "chevron", + header: "", + cell: () => ( + + + + ), + }, + ], + [], + ); + + if (!loading && !error && rows.length === 0) { + return ( + + + + + No shipment bookings in clearance. + + ); + } + + return ( + + + columns={columns} + data={rows} + status={loading ? "loading" : error ? "error" : "success"} + onRowClick={(row) => onOpen(row.id)} + containerClassName="border-0 shadow-none bg-transparent" + /> + + ); +} + function ClearanceCardGrid({ rows, loading, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index d5e3f3954..2f42866a9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() { {data.kind === "booking" ? ( - + ) : ( + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +/** + * GL Djibouti clearance queues: + * - Contracts: ONE_TIME customs contracts in phased clearance (legacy flow). + * - Shipments: GENERAL-contract bookings in per-booking clearance awaiting a DJ + * action (DO collection after ET finalizes pre-clearance, RO for exports, + * loading milestones). Managed like the one-time flow, but per booking. + */ export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); + const [tab, setTab] = useState("shipments"); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); + const { data: bookingQueue, isLoading: bookingsLoading } = + useBookingDjClearanceQueue(); const contractItems = contractQueue?.items ?? []; + const bookingItems = bookingQueue ?? []; + const isLoading = tab === "contracts" ? contractsLoading : bookingsLoading; return ( - - {contractsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 ? ( - - No Djibouti customs contracts yet. - - ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
-
- - - Contract - - - + + + + setTab(v as QueueTab)} + radius="md" + data={[ + { + value: "shipments", + label: ( + + + Shipments + + {bookingItems.length} + -
- )) - )} -
- )} + ), + }, + { + value: "contracts", + label: ( + + + Contracts + + {contractItems.length} + + + ), + }, + ]} + /> + + {isLoading ? ( + + + + ) : tab === "contracts" ? ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. + + ) : ( + contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {prettyStatus(c.status)} + +
+
+ + + Contract + + + +
+
+ )) + )} +
+ ) : ( + + {bookingItems.length === 0 ? ( + + No shipment bookings awaiting a Djibouti action. + + ) : ( + bookingItems.map((b) => ( + navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)} + > + + + +
+ {b.reference} + + {b.tradeDirection} · {prettyStatus(b.status)} + {b.company?.name ? ` · ${b.company.name}` : ""} + +
+
+ + + Shipment + + + +
+
+ )) + )} +
+ )} +
); } diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index c68a4ca25..317f915fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -43,6 +43,7 @@ import { ProfileChips, ProfileStatusBadge, ProfileTypeBadge, + ResetPasswordAction, TableCard, formatBytes, formatDate, @@ -573,6 +574,7 @@ export default function CustomerDetailPage() { } + action={} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index c8cbaa453..7ce03b96b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -107,6 +107,10 @@ const normalizePayload = (values: Record) => .filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)), ); +/** Render a spec value inherited from the wagon type; em dash when the type isn't loaded. */ +const fmtTypeSpec = (value: number | undefined | null, unit: string) => + value == null ? '—' : `${Number(value)} ${unit}`; + const extractBackendErrors = (error: unknown) => { const responseData = (error as { response?: { data?: unknown } })?.response?.data; const data = responseData && typeof responseData === 'object' ? responseData as Record : undefined; @@ -536,7 +540,7 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, - maxWagonsPerTrain: '', + tareWeightTons: '', supportedLoadTypes: '', isActive: true, }); @@ -587,7 +591,7 @@ export function WagonTypesCrudPage() { name: '', capacityTons: 0, lengthMeters: 0, - maxWagonsPerTrain: '', + tareWeightTons: '', supportedLoadTypes: '', isActive: true, }); @@ -601,7 +605,7 @@ export function WagonTypesCrudPage() { name: type.name ?? '', capacityTons: type.capacityTons ?? 0, lengthMeters: type.lengthMeters ?? 0, - maxWagonsPerTrain: type.maxWagonsPerTrain ?? '', + tareWeightTons: type.tareWeightTons ?? '', supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '', isActive: type.isActive, }); @@ -610,10 +614,17 @@ export function WagonTypesCrudPage() { const validateWagonType = () => { const errors: Record = {}; + // normalizePayload strips empty strings, so a blank numeric field would be + // dropped from the payload rather than rejected. Each must be a positive + // number here — the API's @Min(0.001) agrees. + const positive = (value: FormValue) => Number.isFinite(Number(value)) && Number(value) > 0; + if (!String(form.code ?? '').trim()) errors.code = 'Code is required'; if (!String(form.name ?? '').trim()) errors.name = 'Name is required'; - if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number'; - if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number'; + if (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0'; + if (!positive(form.lengthMeters)) errors.lengthMeters = 'Length must be greater than 0'; + if (!positive(form.tareWeightTons)) + errors.tareWeightTons = 'Tare weight is required and must be greater than 0'; return errors; }; @@ -710,6 +721,7 @@ export function WagonTypesCrudPage() { Length (m) + Tare weight (tons) Load types Status Actions @@ -722,6 +734,7 @@ export function WagonTypesCrudPage() { {type.name} {type.capacityTons} {type.lengthMeters} + {type.tareWeightTons ?? '-'} {type.supportedLoadTypes?.join(', ') || '-'} @@ -750,7 +763,7 @@ export function WagonTypesCrudPage() { ))} {!query.isLoading && filtered.length === 0 ? ( - + No wagon types found. @@ -759,7 +772,7 @@ export function WagonTypesCrudPage() { ) : null} {query.isLoading ? ( - + Loading... @@ -815,10 +828,13 @@ export function WagonTypesCrudPage() { onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))} /> setForm((current) => ({ ...current, maxWagonsPerTrain: value }))} + value={form.tareWeightTons === '' || form.tareWeightTons == null ? '' : Number(form.tareWeightTons)} + error={fieldErrors.tareWeightTons} + onChange={(value) => setForm((current) => ({ ...current, tareWeightTons: value }))} /> fmtTypeSpec(wagon.wagonType?.tareWeightTons, 't'), + }, + { + key: 'maxPayloadWeight', + label: 'Max payload', + render: (wagon) => fmtTypeSpec(wagon.wagonType?.capacityTons, 't'), + }, { key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) }, ]} fields={[ @@ -921,11 +947,6 @@ export function WagonsCrudPage() { type: 'select', required: true, options: wagonTypeOptions, - onValueChange: (value, current) => { - const selectedType = wagonTypes.find((type: any) => type.id === value); - if (!selectedType || Number(current.maxPayloadWeight) > 0) return {}; - return { maxPayloadWeight: Number(selectedType.capacityTons) }; - }, }, { key: 'currentLocationYardId', @@ -934,8 +955,6 @@ export function WagonsCrudPage() { required: true, options: yardOptions, }, - { key: 'tareWeight', label: 'Tare weight', type: 'number', required: true }, - { key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true }, { key: 'status', label: 'Status', @@ -951,7 +970,7 @@ export function WagonsCrudPage() { }, { key: 'notes', label: 'Notes' }, ]} - emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }} + emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', status: 'AVAILABLE', notes: '' }} /> ); } @@ -1118,6 +1137,16 @@ export function LocomotivesCrudPage() { { key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) }, { key: 'maxPullWeightTons', label: 'Max pull (tons)' }, { key: 'maxTrainLengthMeters', label: 'Max length (m)' }, + { + key: 'overageToleranceTons', + label: 'Weight tolerance (t)', + render: (locomotive) => locomotive.overageToleranceTons ?? '-', + }, + { + key: 'overageToleranceMeters', + label: 'Length tolerance (m)', + render: (locomotive) => locomotive.overageToleranceMeters ?? '-', + }, ]} fields={[ { key: 'code', label: 'Code', required: true }, @@ -1146,6 +1175,11 @@ export function LocomotivesCrudPage() { }, { key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true }, { key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true }, + // Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2 + // wagons gross 3,522.4T against a 3,500T pull limit and only board because + // of the weight tolerance. + { key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' }, + { key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' }, { key: 'powerKw', label: 'Power (kW)', type: 'number' }, { key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' }, { key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' }, @@ -1157,6 +1191,8 @@ export function LocomotivesCrudPage() { status: 'AVAILABLE', maxPullWeightTons: 0, maxTrainLengthMeters: 760, + overageToleranceTons: '', + overageToleranceMeters: '', powerKw: '', tractionForceKn: '', maxSpeedKmh: '', diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index 14bb32d33..958d99775 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -27,6 +27,8 @@ import { import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { vehiclesService } from "@/services/vehicles.service"; import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service"; import { freightBrand } from "@/theme/freight-brand"; @@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) { export function TrackingPage() { const { toast } = useToast(); const qc = useQueryClient(); + const { user } = useAuth(); + const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage); const [selectedId, setSelectedId] = useState(null); const [hoverId, setHoverId] = useState(null); const [mapsReady, setMapsReady] = useState(false); @@ -288,9 +292,11 @@ export function TrackingPage() { Real-Time Vehicle Tracking Live GPS positions from GT06 trackers - + {canManage && ( + + )} @@ -358,9 +364,11 @@ export function TrackingPage() { }> {selected.online ? "Live" : "Offline"} - deleteMutation.mutate(selected.id)}> - - + {canManage && ( + deleteMutation.mutate(selected.id)}> + + + )} @@ -393,6 +401,7 @@ export function TrackingPage() { data={vehicleOptions} value={selected.vehicleId ?? null} onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })} + disabled={!canManage} searchable clearable /> @@ -421,14 +430,16 @@ export function TrackingPage() { {d.online ? "Live" : "Offline"} - { e.stopPropagation(); openEdit(d); }} - > - - + {canManage && ( + { e.stopPropagation(); openEdit(d); }} + > + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 3ac7f2458..091e056b7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, { id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" }, { id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" }, + { id: "overageToleranceTons", header: "Weight tolerance (t)", accessorKey: "overageToleranceTons", format: "number" }, + { id: "overageToleranceMeters", header: "Length tolerance (m)", accessorKey: "overageToleranceMeters", format: "number" }, ], // Code is auto-generated server-side (LOCO-NNN) — omitted from the form. formFields: [ @@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true }, { name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true }, + // Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2 + // wagons gross 3,522.4T against a 3,500T pull limit and only board because + // of the weight tolerance. + { name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" }, + { name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" }, { name: "powerKw", label: "Power (kW)", type: "number" }, { name: "tractionForceKn", label: "Traction force (kN)", type: "number" }, { name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" }, @@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ currentYardId: "", maxPullWeightTons: 2500, maxTrainLengthMeters: 760, + overageToleranceTons: "", + overageToleranceMeters: "", powerKw: "", tractionForceKn: "", maxSpeedKmh: "", @@ -254,17 +263,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ cardSubtitleKey: "currentYard", searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"], columns: [ + // Tare weight and payload capacity are not wagon columns — they belong to the + // wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages). { id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" }, { id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" }, - { id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, - { name: "tareWeight", label: "Tare weight", type: "number", required: true }, - { name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, { name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS }, { name: "notes", label: "Notes", type: "textarea" }, @@ -272,8 +280,6 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ emptyValues: { wagonNumber: "", wagonTypeId: "", - tareWeight: 0, - maxPayloadWeight: 0, currentYardId: "", status: Freight.WagonStatus.Available, notes: "", diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index a9ad6ea39..b5eb71862 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -279,7 +279,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { id: "name", header: "Name", accessorKey: "name" }, { id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" }, { id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" }, - { id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" }, + { id: "tareWeightTons", header: "Tare (t)", accessorKey: "tareWeightTons", format: "number" }, { id: "supportedLoadTypes", header: "Load types", @@ -291,11 +291,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ { name: "name", label: "Name", type: "text", required: true }, { name: "capacityTons", label: "Capacity (tons)", type: "number", required: true }, { name: "lengthMeters", label: "Length (meters)", type: "number", required: true }, + // The locomotive's pull limit is a GROSS limit, so capacity planning charges + // `cargo + wagons × tare` against it. The API rejects a create without this. { - name: "maxWagonsPerTrain", - label: "Max wagons per train", + name: "tareWeightTons", + label: "Tare weight (tons)", type: "number", - optional: true, + required: true, + description: "Empty wagon weight — counts against the locomotive's pull limit alongside the cargo", }, { name: "supportedLoadTypes", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index b02bf7e22..1b7e490e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -175,6 +175,7 @@ function CapacityChip({ ); } +/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */ function weightPctOf(s: BatchBoardSchedule) { return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0 ? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100 @@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) { ? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100 : null; } +function wagonPctOf(s: BatchBoardSchedule) { + return s.capacity.maxWagons && s.capacity.maxWagons > 0 + ? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100 + : null; +} function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const navigate = useNavigate(); @@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const lengthPct = lengthPctOf(schedule); const weightPct = weightPctOf(schedule); + const wagonPct = wagonPctOf(schedule); const totalBookings = totalBookingCount(counts); @@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { ) : null} - {/* capacity: weight + length rings + wagons (numbers preserved) */} + {/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */} ) : null} - - - - - - {capacity.allocatedWagons} - - - Wagons - - - allocated - - + {wagonPct != null ? ( + + ) : ( + + + + + + {capacity.allocatedWagons} + + + Wagons + + + allocated + + + )} {lengthPct != null ? ( ( - - - - - - - {row.original.capacity.allocatedWagons} - - - wgn - + cell: ({ row }) => { + const { allocatedWagons, maxWagons } = row.original.capacity; + const wagonPct = wagonPctOf(row.original); + return ( + + + + {wagonPct != null ? ( + + ) : ( + + + + {allocatedWagons} + + + wgn + + + )} - - ), + ); + }, }, { id: "bookings", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 6daf79221..a150978d4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -20,7 +20,7 @@ import { AlertTriangle, ArrowLeft, ArrowLeftRight, - Boxes, + // Boxes, CalendarDays, CheckCircle2, ClipboardCheck, @@ -348,9 +348,9 @@ const BOOKING_COLUMNS: ColumnDef[] = [ const b = row.original; return ( - + {/* {b.wagons}w - + */} {fmtTons(b.weightTons)} @@ -890,12 +890,14 @@ export default function BatchScheduleDetailPage() { { + const now = new Date(); + now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); + return now.toISOString().slice(0, 16); +}; + const splitDate = (value?: string | null) => { if (!value) return { day: "—", time: "" }; const date = new Date(value); @@ -103,6 +110,12 @@ export default function TrainScheduleV2ListPage() { const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); const [locomotiveIds, setLocomotiveIds] = useState([]); + // Recomputed each time the create modal opens so a long-lived tab can't keep + // offering a stale "now" as the earliest selectable departure. + const minScheduleDate = useMemo( + () => (createOpen ? nowLocalDateTime() : ""), + [createOpen], + ); const schedulesQuery = useQuery( api.trainScheduling.scheduleList.queryOptions({ input: {} }), @@ -443,6 +456,13 @@ export default function TrainScheduleV2ListPage() { }); return; } + if (new Date(scheduleDate).getTime() < Date.now()) { + toast({ + title: "Departure date must be in the future", + variant: "destructive", + }); + return; + } try { const created = await create.mutateAsync({ payload: { @@ -691,6 +711,7 @@ export default function TrainScheduleV2ListPage() { setScheduleDate(e.currentTarget.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index c71235640..0186d5acb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainWeightTons: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxWagonsPerTrain: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0.001} disabled={loading} @@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} disabled={loading} @@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowOpenHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} @@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowCloseHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index c04c67169..6126022b8 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -12,6 +12,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; import { CreateDropdownOptionDto, @@ -2265,6 +2267,16 @@ export const api = { ({ id }) => QUERY_KEYS.CUSTOMERS.payments(id), ), + resetPassword: endpoint< + { companyId: string; channel: ResetChannel }, + ResetPasswordResult + >( + "customers", + "resetPassword", + ({ companyId, channel }) => + customersService.resetPassword(companyId, channel), + ), + setProfileStatus: endpoint< { profileId: string; status: ProfileStatus; note?: string }, CompanyProfile diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index c23b51df2..de16d9c54 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -482,10 +482,25 @@ export const contractsService = { postContract(C.OPS_CLEARANCE_FINALIZE(id)), // ── Booking under contract (GL ET — Path B) ── - createBookingUnderContract: ( + // The API returns { booking, warnings } (CreateBookingUnderContractResult) — + // unwrap to the booking itself so callers can use its id directly. + createBookingUnderContract: async ( id: string, payload: Freight.CreateBookingUnderContractDto, - ) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload), + ): Promise<{ id: string; reference: string; warnings?: string[] }> => { + const result = await postContract<{ + booking?: { id: string; reference: string }; + id?: string; + reference?: string; + warnings?: string[]; + }>(C.BOOKINGS(id), payload); + const booking = result.booking ?? result; + return { + id: booking.id ?? "", + reference: booking.reference ?? "", + warnings: result.warnings, + }; + }, /** * Pre-create validation + authoritative price preview: the same diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index cafe0aece..0476d41bb 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -11,6 +11,8 @@ import type { CustomerPayment, PaginatedCompanies, ProfileStatus, + ResetChannel, + ResetPasswordResult, } from "@/types/customer"; const cleanParams = (params: object) => @@ -81,6 +83,22 @@ export const customersService = { .then((r) => r.data); }, + /** + * Send a password-reset code to the company's primary contact. Staff never + * receive a credential — the customer sets their own password from the code. + */ + resetPassword( + companyId: string, + channel: ResetChannel, + ): Promise { + return apiClient + .post( + URL_CONSTANTS.COMPANIES.RESET_PASSWORD(companyId), + { channel }, + ) + .then((r) => r.data); + }, + setProfileStatus( profileId: string, status: ProfileStatus, diff --git a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts index f904eec5c..3c96b3cfb 100644 --- a/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/locomotives.service.ts @@ -27,6 +27,10 @@ export interface Locomotive { currentYard?: { id: string; label?: string; code?: string } | null; maxPullWeightTons: number; maxTrainLengthMeters: number; + /** Tons a train may exceed maxPullWeightTons by before scheduling blocks it. */ + overageToleranceTons?: number | null; + /** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */ + overageToleranceMeters?: number | null; powerKw?: number | null; tractionForceKn?: number | null; maxSpeedKmh?: number | null; 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 65de13760..9c59e31ad 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -39,11 +39,16 @@ export interface SaveRoutePayload { status?: RouteStatus; } +/** + * Human-readable route label: yard names, not yard codes. Staff read + * "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the + * code only when a yard has no label. + */ export function formatRouteLabel(route: RouteRecord): string { const origin = - route.originYard?.code ?? route.originYard?.label ?? 'Origin'; + route.originYard?.label ?? route.originYard?.code ?? 'Origin'; const dest = - route.destinationYard?.code ?? route.destinationYard?.label ?? 'Destination'; + route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination'; return `${origin} → ${dest}`; } diff --git a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts index 0532abb62..bb4eead1f 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon-types.service.ts @@ -8,7 +8,8 @@ export interface WagonType { name: string; capacityTons: number; lengthMeters: number; - maxWagonsPerTrain?: number | null; + /** Empty wagon weight; required, since pull limits apply to tare + cargo. */ + tareWeightTons: number; supportedLoadTypes: string[]; isActive: boolean; } diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index a200195e0..e30320988 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -15,14 +15,16 @@ export interface Wagon { label: string; country?: string; } | null; + /** Owns this wagon's spec — tare, capacity, length are read from here, never off the wagon. */ wagonType?: { id: string; code: string; name: string; supportedLoadTypes?: string[]; + tareWeightTons?: number; + capacityTons?: number; + lengthMeters?: number; } | null; - tareWeight: number; - maxPayloadWeight: number; status: Freight.WagonStatus; currentYardId: string | null; currentYard?: { id: string; label?: string; code?: string } | null; 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 6e99c639c..366d8b9f3 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -64,7 +64,7 @@ import type { WarehouseZone, } from '@/types/warehouse'; -export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; +export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; export interface ContainerItem { containerNumber: string; @@ -75,6 +75,8 @@ export interface ContainerItem { truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + /** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */ + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8d76571d4..7328decf0 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -99,6 +99,15 @@ export interface CompanyChangeRequest { updatedAt: string; } +/** The channel a customer's password-reset code is delivered over. */ +export type ResetChannel = "email" | "phone"; + +export interface ResetPasswordResult { + channel: ResetChannel; + /** Where the code went, e.g. `+251•••4821` — safe to show to staff. */ + maskedTarget: string; +} + /** Mirrors backend `Company` (+ its `companyProfiles`). */ export interface Company { id: string; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index ab14a18da..c09c067d8 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -280,10 +280,13 @@ export interface BatchBoardSchedule { capacity: { allocatedWagons: number; allocatedLengthMeters: number; + /** Train-length cap: locomotive floored by global rules, plus overage tolerance. */ maxLengthMeters: number | null; + /** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */ usedWeightTons: number; + /** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */ maxWeightTons: number | null; - /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + /** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */ maxWagons: number | null; }; counts: { diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 697c2f89c..e4c5685fd 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -32,6 +32,7 @@ import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; +import ForgotPasswordPage from "./pages/accounts/ForgotPasswordPage"; import LoginPage from "./pages/accounts/LoginPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; @@ -252,6 +253,7 @@ const App = () => { }> } /> } /> + } /> {/* Signup-flow pages; reached while a session already exists */} diff --git a/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx new file mode 100644 index 000000000..b61bb2eab --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/OtpChannelStep.tsx @@ -0,0 +1,179 @@ +import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core"; +import { + AlertCircle, + ArrowLeft, + Mail, + RotateCw, + ShieldCheck, + Smartphone, +} from "lucide-react"; + +import { maskEmail, maskPhone } from "@/utils/identifier"; + +export type OtpChannel = "phone" | "email"; + +export const OTP_LENGTH = 6; + +export interface OtpChannelSelectProps { + value: OtpChannel; + onChange: (channel: OtpChannel) => void; + disabled?: boolean; + label?: string; +} + +/** Phone/email toggle deciding where the verification code is sent. */ +export function OtpChannelSelect({ + value, + onChange, + disabled, + label = "Send verification code via", +}: OtpChannelSelectProps) { + return ( +
+ + {label} + + onChange(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + /> +
+ ); +} + +export interface OtpChannelStepProps { + channel: OtpChannel; + /** Raw email or phone the code went to; masked before display. */ + target: string; + value: string; + onChange: (otp: string) => void; + onVerify: () => void; + onBack: () => void; + onResend: () => void; + /** Seconds until resend is allowed; 0 enables the button. */ + resendIn: number; + sending: boolean; + verifying: boolean; + error: string | null; + title?: string; + description?: string; + submitLabel: string; +} + +/** + * The "enter the code we sent you" stage. Shared by signup and the + * forgot-password flow — both send through the same `/api/otp/*` service. + */ +export default function OtpChannelStep({ + channel, + target, + value, + onChange, + onVerify, + onBack, + onResend, + resendIn, + sending, + verifying, + error, + title, + description, + submitLabel, +}: OtpChannelStepProps) { + const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target); + const busy = sending || verifying; + + return ( + +
+ + + +
+ +
+

+ {title ?? `Verify your ${channel === "email" ? "email" : "phone"}`} +

+

+ We sent a {OTP_LENGTH}-digit code to{" "} + {maskedTarget}.{" "} + {description ?? "Enter it to continue."} +

+
+ + {error ? ( + }> + {error} + + ) : null} + + + + Verification code + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx new file mode 100644 index 000000000..89cea7a0e --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/PasswordChecklist.tsx @@ -0,0 +1,41 @@ +import { Check, X } from "lucide-react"; + +import { passwordRequirements } from "@/utils/passwordSchema"; + +export interface PasswordChecklistProps { + /** The current password value; the checklist hides itself when empty. */ + value: string; +} + +/** Live pass/fail list of the password rules, shown under a password field. */ +export default function PasswordChecklist({ value }: PasswordChecklistProps) { + if (!value) return null; + + return ( +
+ {passwordRequirements.map((req) => { + const met = req.test(value); + return ( +
+ + {met ? ( + + ) : ( + + )} + + + {req.label} + +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index ab00965fa..849ba50c5 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -286,9 +286,13 @@ export default function OnboardingWizardDialog({ }); }, [roles, nationality, startMutation]); - // Note: no "back to role selection" — once the draft is created the role(s) - // are fixed; the form's first-step Back is a no-op so progress never resets. - const handleBackToRoles = useCallback(() => { }, []); + // Back from the form's first step returns to nationality/role selection. + // Safe to re-enter: startOnboarding is idempotent — it reuses the existing + // draft, refreshes the nationality and creates only roles that don't exist yet. + const handleBackToRoles = useCallback(() => { + setStartError(null); + setPhase("nationality-role"); + }, []); // Save the current step's fields to the draft (PATCH /profile). Returns the // server error message on failure so the form can show it (e.g. duplicate TIN). @@ -359,7 +363,6 @@ export default function OnboardingWizardDialog({ // The active step across the whole journey, driving the header + progress pill. const activeStep: WizardStep = phase === "form" ? formStep : phase; const stepMeta = STEP_META[activeStep]; - console.log({ stepMeta, activeStep, STEP_META }); const activeIdx = WIZARD_STEPS.indexOf(activeStep); // Closing from the congratulations panel also clears the completed flag so a @@ -403,7 +406,6 @@ export default function OnboardingWizardDialog({ onSubmit: handleSubmit, isPending: finishMutation.isPending, onBack: handleBackToRoles, - hideFirstStepBack: true, initialStep: effectiveResumeStep, resyncOpen: opened, onStepChange: handleStepChange, diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 89a3d57e7..5dfda7e58 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,8 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", + FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, USERS: { diff --git a/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts new file mode 100644 index 000000000..deeca8125 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useResendCooldown.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +/** Seconds a user must wait before another OTP can be requested. */ +const DEFAULT_COOLDOWN_SECONDS = 60; + +/** + * Countdown that gates the "Resend code" button. Ticks with setTimeout rather + * than wall-clock arithmetic, so it needs no Date.now(). + */ +export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) { + const [secondsLeft, setSecondsLeft] = useState(0); + + useEffect(() => { + if (secondsLeft <= 0) return; + const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000); + return () => clearTimeout(t); + }, [secondsLeft]); + + return { + secondsLeft, + start: () => setSecondsLeft(seconds), + reset: () => setSecondsLeft(0), + }; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index 239c42bac..64ec1b0dd 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -334,6 +334,19 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, + TRUCK_ASSIGNED: { + stage: 3, + icon: Truck, + iconColor: "edr-green.7", + tile: "edr-soft", + hint: "Truck assigned · preparing for pickup", + step: "edr-green.5", + badgeLabel: "Truck assigned", + badgeBg: "edr-soft", + badgeText: "edr-green.7", + badgeDot: "edr-green.5", + action: { label: "View", kind: "outline" }, + }, IN_TRANSIT: { stage: 3, icon: Truck, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index cc9f81a29..abfb61551 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -51,7 +51,6 @@ export default function CompanyProfileForm({ onBack, initialStep, resyncOpen, - hideFirstStepBack, onStepChange, onSaveStep, rehydrate, @@ -73,8 +72,6 @@ export default function CompanyProfileForm({ initialStep?: CompanyStep; /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ resyncOpen?: boolean; - /** Hide the Back button on the first step (onboarding can't go back to role pick). */ - hideFirstStepBack?: boolean; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; /** Persist the current step's data before advancing; returns an error to show. */ @@ -514,10 +511,6 @@ export default function CompanyProfileForm({ else setStep(stepOrder[currentIdx - 1]); }; - // Back is hidden on the first step during onboarding (can't return to role - // selection); otherwise always available. - const showBack = !(hideFirstStepBack && step === "company"); - return ( <>
e.preventDefault()}> @@ -851,17 +844,13 @@ export default function CompanyProfileForm({ )} - {showBack ? ( - - ) : ( - - )} + + +

+ Remembered it?{" "} + + Back to sign in + +

+ + + ) : null} + + {stage === "otp" ? ( + { + setStage("identify"); + setError(null); + }} + onResend={handleResend} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={error} + title="Enter your reset code" + description="Enter it to choose a new password." + submitLabel="Verify code" + /> + ) : null} + + {stage === "password" ? ( +
+
+

+ Choose a new password +

+

+ Pick something strong you haven't used before. +

+
+ + +
+ setPassword(event.target.value)} + /> + +
+ + setConfirmPassword(event.target.value)} + /> + + {error ? ( + }> + {error} + + ) : null} + + + + +
+
+ ) : null} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 885888a05..7e8e512dd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -5,21 +5,11 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import AuthShell from "@/components/auth/AuthShell"; +import { normaliseIdentifier } from "@/utils/identifier"; import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; -/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ -function normaliseIdentifier(raw: string): string { - const v = raw.trim(); - const digits = v.replace(/\D/g, ""); - if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); - return `+251${local}`; - } - return v.toLowerCase(); -} - export default function LoginPage() { const navigate = useNavigate(); const location = useLocation(); @@ -80,7 +70,7 @@ export default function LoginPage() {
Password Forgot password? diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 87702bdd8..04625f2cd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,4 +1,13 @@ -import { Alert, Box, Button, Group, PasswordInput, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Alert, + Box, + Button, + Group, + PasswordInput, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRight, Check, LockKeyhole, X } from "lucide-react"; import { useMemo, useState } from "react"; @@ -8,27 +17,19 @@ import { z } from "zod"; import useAuth from "@/hooks/useAuth"; import AuthLayout from "@/components/auth/AuthLayout"; - -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, -] as const; +import { + confirmPasswordField, + passwordField, + passwordRequirements, + samePassword, +} from "@/utils/passwordSchema"; const passwordSchema = z .object({ - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { + .refine(samePassword, { message: "Passwords do not match", path: ["confirmPassword"], }); @@ -54,7 +55,8 @@ export default function SetPasswordPage() { const password = watch("password"); const requirements = useMemo( - () => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), + () => + passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), [password], ); @@ -93,11 +95,21 @@ export default function SetPasswordPage() { "Secure freight operations", "Advanced authentication system", ], - stats: { label: "Security Protection", value: "256-bit", footer: "Encrypted", progress: "w-[98%]" }, + stats: { + label: "Security Protection", + value: "256-bit", + footer: "Encrypted", + progress: "w-[98%]", + }, }} > - + diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 5746eefd5..72dafe9f7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,50 +1,38 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, Button, PasswordInput, - PinInput, - SegmentedControl, SimpleGrid, Stack, - Text, TextInput, } from "@mantine/core"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - Check, - Mail, - RotateCw, - ShieldCheck, - Smartphone, - X, -} from "lucide-react"; +import { AlertCircle, ArrowRight } from "lucide-react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; +import { useResendCooldown } from "@/hooks/useResendCooldown"; import type { SignupPayload } from "@/types/auth"; import AuthShell from "@/components/auth/AuthShell"; +import OtpChannelStep, { + OTP_LENGTH, + OtpChannelSelect, + type OtpChannel, +} from "@/components/auth/OtpChannelStep"; +import PasswordChecklist from "@/components/auth/PasswordChecklist"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { api } from "@/services/api"; +import { + confirmPasswordField, + passwordField, + samePassword, +} from "@/utils/passwordSchema"; import { extractApiError } from "@/utils/result"; -const passwordRequirements = [ - { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, - { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, - { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, - { label: "One number", test: (v: string) => /\d/.test(v) }, - { - label: "One special character", - test: (v: string) => /[^A-Za-z0-9]/.test(v), - }, -] as const; - const userSchema = z .object({ email: z.string().email("Invalid email address"), @@ -61,43 +49,23 @@ const userSchema = z en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex(/[A-Z]/, "Password must include an uppercase letter") - .regex(/[a-z]/, "Password must include a lowercase letter") - .regex(/\d/, "Password must include a number") - .regex(/[^A-Za-z0-9]/, "Password must include a special character"), - confirmPassword: z.string().min(1, "Please confirm your password"), + password: passwordField, + confirmPassword: confirmPasswordField, }) - .refine((data) => data.password === data.confirmPassword, { + .refine(samePassword, { message: "Passwords do not match", path: ["confirmPassword"], }); type FormData = z.infer; -/** Mask all but the first 7 chars of an E.164 phone for display. */ -const maskPhone = (p: string) => - p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; - -/** Mask the local part of an email for display (j***e@example.com). */ -const maskEmail = (email: string) => { - const [local, domain] = email.split("@"); - if (!local || !domain) return email; - if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`; - return `${local[0]}***${local[local.length - 1]}@${domain}`; -}; - -type OtpChannel = "phone" | "email"; - export default function SignupPage() { const navigate = useNavigate(); const { signup } = useAuth(); const [error, setError] = useState(null); - // Two-stage signup: fill the form, then a mandatory SMS OTP challenge on the - // phone number before the account is actually created. The account is only + // Two-stage signup: fill the form, then a mandatory OTP challenge on the + // chosen channel before the account is actually created. The account is only // created after the code is verified — the OTP is a hard requirement. const [stage, setStage] = useState<"form" | "otp">("form"); const [pendingData, setPendingData] = useState(null); @@ -109,14 +77,7 @@ export default function SignupPage() { const [verifying, setVerifying] = useState(false); const [otpCode, setOtpCode] = useState(""); const [otpError, setOtpError] = useState(null); - const [resendIn, setResendIn] = useState(0); - - // Resend cooldown countdown (pure setTimeout ticks — no Date.now needed). - useEffect(() => { - if (resendIn <= 0) return; - const t = setTimeout(() => setResendIn((s) => s - 1), 1000); - return () => clearTimeout(t); - }, [resendIn]); + const resendCooldown = useResendCooldown(); const { register, @@ -170,7 +131,7 @@ export default function SignupPage() { setOtpChannel(channel); setOtpCode(""); setOtpError(null); - setResendIn(60); + resendCooldown.start(); setStage("otp"); } catch (err) { setError(extractApiError(err).message); @@ -190,7 +151,7 @@ export default function SignupPage() { : { phone: pendingData.phone }, ); setOtpCode(""); - setResendIn(60); + resendCooldown.start(); } catch (err) { setOtpError(extractApiError(err).message); } finally { @@ -202,8 +163,8 @@ export default function SignupPage() { const confirmOtp = async () => { if (!pendingData) return; setOtpError(null); - if (otpCode.trim().length !== 6) { - setOtpError("Enter the 6-digit code we sent you."); + if (otpCode.trim().length !== OTP_LENGTH) { + setOtpError(`Enter the ${OTP_LENGTH}-digit code we sent you.`); return; } setVerifying(true); @@ -298,35 +259,11 @@ export default function SignupPage() { disabled={sending} /> -
- - Send verification code via - - setChannel(v as OtpChannel)} - data={[ - { - value: "phone", - label: ( - - Phone - - ), - }, - { - value: "email", - label: ( - - Email - - ), - }, - ]} - /> -
+
- {passwordValue.length > 0 ? ( -
- {passwordRequirements.map((req) => { - const met = req.test(passwordValue); - return ( -
- - {met ? ( - - ) : ( - - )} - - - {req.label} - -
- ); - })} -
- ) : null} +
) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6 - digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - .Enter it to finish creating your account. -

-
- - {otpError ? ( - } - > - {otpError} - - ) : null} - - - - Verification code - - - - - - -
- - -
-
+ { + setStage("form"); + setOtpError(null); + }} + onResend={resendOtp} + resendIn={resendCooldown.secondsLeft} + sending={sending} + verifying={verifying} + error={otpError} + description="Enter it to finish creating your account." + submitLabel="Verify & create account" + /> )}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 659f767c7..7384bbc4d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -18,6 +18,7 @@ import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; +import { MileSummaryCard } from "./components/MileSummaryCard"; import { BodyGrid, PageShell } from "./components/layout"; import { CancelledBanner, @@ -220,6 +221,8 @@ export function ReadonlyBookingView({ + + } right={ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index a023b7850..81f727b29 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -16,6 +16,7 @@ import type { Freight } from "@edr/types"; import { invoicesService, type PortalInvoice } from "@/services/invoices.service"; import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; +import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { saveBlob } from "@/utils/download"; import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; @@ -262,7 +263,7 @@ export function BookingPaymentPanel({ ? "Paid" : showCountdown ? "Pay window open" - : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + : paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx index fc308891d..1ad53c0c8 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/KeyFactsStrip.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; +import { paymentStatusLabel } from "@/pages/bookings/booking-display"; + import { fmtDate, yardLabel } from "../utils"; import { SectionCard } from "./layout"; @@ -38,12 +40,7 @@ function Fact({ label, value }: { label: string; value: ReactNode }) { export function KeyFactsStrip({ booking }: { booking: BookingLike }) { const isContract = booking.bookingType === "GENERAL_CONTRACT"; const freight = booking.freightType === "BULK" ? "Bulk" : "Container"; - const payment = booking.paymentStatus - ? booking.paymentStatus - .replace(/_/g, " ") - .toLowerCase() - .replace(/^\w/, (c) => c.toUpperCase()) - : "—"; + const payment = paymentStatusLabel(booking.paymentStatus); return ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx new file mode 100644 index 000000000..bfb57529f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx @@ -0,0 +1,214 @@ +import { Box, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; + +import type { Freight } from "@edr/types"; + +import { bookingsService } from "@/services/bookings.service"; +import type { + MileLegSummary, + MileVehicleSummary, +} from "@/services/bookings.service"; + +import { CardTitle, SectionCard } from "./layout"; + +function StatusPill({ status }: { status: string }) { + const s = status.toUpperCase(); + const done = s.includes("DELIVER") || s.includes("COMPLET") || s.includes("PAID"); + const active = s.includes("TRANSIT") || s.includes("PROGRESS") || s.includes("ASSIGN"); + const dot = done ? "#0EA371" : active ? "#2563EB" : "#94A3B8"; + const color = done ? "#0A6F4D" : active ? "#1E40AF" : "#475569"; + const bg = done ? "#ECF6F1" : active ? "#EAF1FE" : "#F1F4F7"; + const border = done ? "#CDEBDD" : active ? "#CFDDFB" : "#E1E7EE"; + const label = status + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()); + + return ( + + + {label} + + ); +} + +function VehicleRow({ v }: { v: MileVehicleSummary }) { + const parts: string[] = []; + if (v.driverName) parts.push(v.driverName); + if (v.containerNumber) parts.push(`Container ${v.containerNumber}`); + if (v.distanceKm != null) parts.push(`${v.distanceKm} km`); + + return ( + + + + {v.plate || v.code || "Vehicle"} + + {parts.length > 0 && ( + + {parts.join(" · ")} + + )} + + {v.code && v.plate && ( + + {v.code} + + )} + + ); +} + +function LegBlock({ + title, + leg, + address, +}: { + title: string; + leg: MileLegSummary | null; + address?: string | null; +}) { + const fmtMoney = (n: number | null, currency: string) => + n == null + ? null + : `${currency} ${Number(n).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + + return ( + + + + {title} + + {leg ? ( + + ) : ( + + )} + + + {address && ( + + {title.startsWith("First") ? "Pickup" : "Delivery"}:{" "} + {address} + + )} + + {!leg ? ( + + Requested — a vehicle and driver will be assigned soon. + + ) : leg.vehicles.length > 0 ? ( + + {leg.vehicles.map((v, i) => ( + + ))} + + ) : ( + + No vehicle assigned yet. + + )} + + {leg && ( + + + {leg.exactKm != null && ( + + Total distance{" "} + {leg.exactKm} km + + )} + {leg.remainingPayment != null && leg.remainingPayment > 0 && ( + + Balance{" "} + + {fmtMoney(leg.remainingPayment, leg.currency)} + + + )} + + {leg.invoiced && ( + + Invoiced + + )} + + )} + + ); +} + +export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { + const { data } = useQuery({ + queryKey: ["booking-mile-summary", booking.id], + queryFn: () => bookingsService.mileSummary(booking.id), + }); + + const firstLeg = data?.firstMile ?? null; + const lastLeg = data?.lastMile ?? null; + + // The backend doesn't persist an "enabled" flag — the presence of a + // pickup/delivery address is the request signal. Also show a leg once its + // record exists, regardless of address. + const showFirst = !!booking.firstMilePickupAddress || !!firstLeg; + const showLast = !!booking.lastMileDeliveryAddress || !!lastLeg; + + if (!showFirst && !showLast) return null; + + return ( + + + First & Last Mile + + + {showFirst && ( + + )} + {showLast && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx index 6958a12dc..b5494ab9d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PageHeader.tsx @@ -13,6 +13,8 @@ import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; +import { bookingStatusLabel } from "@/pages/bookings/booking-display"; + import { bookingSubtitle, isDraftLike, isNegative } from "../utils"; export interface PageHeaderMenuActions { @@ -60,7 +62,7 @@ export function PageHeader({ className="shrink-0 rounded-full" style={{ width: 7, height: 7, backgroundColor: dotColor }} /> - {status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())} + {bookingStatusLabel(status)} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx index 52451028d..7a6323a58 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; +import { bookingStatusLabel } from "@/pages/bookings/booking-display"; + import { fmtDate, isDraftLike, isNegative } from "../utils"; import { CardTitle, SectionCard } from "./layout"; @@ -15,10 +17,7 @@ function StatusPill({ status }: { status: string }) { const color = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D"; const bg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1"; const border = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD"; - const label = status - .replace(/_/g, " ") - .toLowerCase() - .replace(/\b\w/g, (m) => m.toUpperCase()); + const label = bookingStatusLabel(status); return ( m.toUpperCase()); +} + +/** + * Human label for a booking status. Single source of truth is the booking + * list's `STATUS_CONFIG` badge labels; anything not mapped there falls back to + * a readable title-cased form (never the raw SCREAMING_SNAKE enum). + */ +export function bookingStatusLabel(status?: string | null): string { + if (!status) return "—"; + return STATUS_CONFIG[status]?.badgeLabel ?? titleCaseStatus(status); +} + type BookingLike = Freight.IBooking & { bookingType?: string; freightType?: string; @@ -59,7 +79,12 @@ const PAYMENT_COLORS: Record = { PAID: "green", PENDING: "gray", PNR_GENERATED: "blue", + // Backend emits the long form on some flows; keep the short alias too. VERIFICATION_IN_PROGRESS: "yellow", + PAYMENT_VERIFICATION_IN_PROGRESS: "yellow", + OVERDUE: "red", + REFUNDED: "blue", + CANCELLED: "gray", FAILED: "red", }; @@ -68,9 +93,19 @@ const PAYMENT_LABELS: Record = { PENDING: "Pending", PNR_GENERATED: "PNR generated", VERIFICATION_IN_PROGRESS: "Verifying", + PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying", + OVERDUE: "Overdue", + REFUNDED: "Refunded", + CANCELLED: "Cancelled", FAILED: "Failed", }; +/** Human label for a payment status (plain text, no badge). */ +export function paymentStatusLabel(status?: string | null): string { + if (!status) return "—"; + return PAYMENT_LABELS[status] ?? titleCaseStatus(status); +} + /** Payment status pill. */ export function PaymentBadge({ status }: { status?: string | null }) { if (!status) return ; @@ -81,7 +116,7 @@ export function PaymentBadge({ status }: { status?: string | null }) { color={PAYMENT_COLORS[status] ?? "gray"} styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }} > - {PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")} + {PAYMENT_LABELS[status] ?? titleCaseStatus(status)} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx index e9ee31722..c3a0a8ccd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx @@ -1,11 +1,8 @@ import { Button, type ButtonProps } from "@mantine/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2 } from "lucide-react"; -import type { MouseEvent } from "react"; -import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; +import { type MouseEvent, useState } from "react"; -import { api } from "@/services/api"; +import { ApproveDeliveryModal } from "./ApproveDeliveryModal"; type ApproveDeliveryButtonProps = ButtonProps & { bookingId: string; @@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & { onApproved?: () => void; }; -const errorMessage = (error: unknown) => { - const data = (error as { response?: { data?: { message?: string | string[] } } }) - ?.response?.data; - if (Array.isArray(data?.message)) return data.message.join(", "); - if (data?.message) return data.message; - return error instanceof Error ? error.message : "Could not approve delivery"; -}; - -const downloadBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); -}; - export function ApproveDeliveryButton({ bookingId, stopPropagation, @@ -40,56 +18,31 @@ export function ApproveDeliveryButton({ variant = "filled", ...props }: ApproveDeliveryButtonProps) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions()); - - const mutation = useMutation({ - ...api.bookings.approveDelivery.mutationOptions(), - onSuccess: async (result) => { - try { - const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId }); - downloadBlob(blob, `handover-${bookingId}.pdf`); - toast.success("Delivery approved and signed handover downloaded"); - } catch { - toast.success("Delivery approved and handover signed"); - toast.error("Signed handover document could not be downloaded"); - } - await Promise.all([ - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }), - queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), - queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), - ]); - onApproved?.(); - }, - onError: (error) => { - const message = errorMessage(error); - toast.error(message); - if (message.toLowerCase().includes("save your signature")) { - navigate("/signature"); - } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) { - navigate("/billing"); - } - }, - }); + const [opened, setOpened] = useState(false); const handleClick = (event: MouseEvent) => { if (stopPropagation) event.stopPropagation(); - mutation.mutate({ id: bookingId }); + setOpened(true); }; return ( - + <> + + setOpened(false)} + onApproved={onApproved} + /> + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx new file mode 100644 index 000000000..a3723b650 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx @@ -0,0 +1,171 @@ +import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, Info } from "lucide-react"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; + +type ApproveDeliveryModalProps = { + bookingId: string; + opened: boolean; + onClose: () => void; + onApproved?: () => void; +}; + +const errorMessage = (error: unknown) => { + const data = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return error instanceof Error ? error.message : "Could not approve delivery"; +}; + +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +/** + * Approve-delivery flow: open the handover document for the customer to review, + * then apply their saved signature (approve) and hand back the signed PDF. + */ +export function ApproveDeliveryModal({ + bookingId, + opened, + onClose, + onApproved, +}: ApproveDeliveryModalProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [pdfUrl, setPdfUrl] = useState(null); + + const { + data: docBlob, + isLoading, + isError, + } = useQuery({ + queryKey: ["booking-handover-doc", bookingId], + queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId), + enabled: opened && Boolean(bookingId), + staleTime: 0, + }); + + useEffect(() => { + if (!docBlob) { + setPdfUrl(null); + return; + } + const url = URL.createObjectURL(docBlob); + setPdfUrl(url); + return () => URL.revokeObjectURL(url); + }, [docBlob]); + + const handoverMutation = useMutation( + api.bookings.downloadHandoverDocument.mutationOptions(), + ); + + const approve = useMutation({ + ...api.bookings.approveDelivery.mutationOptions(), + onSuccess: async (result) => { + try { + const signed = await handoverMutation.mutateAsync({ + inventoryId: result.inventoryId, + }); + downloadBlob(signed, `handover-${bookingId}.pdf`); + toast.success("Delivery approved and signed handover downloaded"); + } catch { + toast.success("Delivery approved and handover signed"); + toast.error("Signed handover document could not be downloaded"); + } + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: bookingId }), + }), + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), + ]); + onApproved?.(); + onClose(); + }, + onError: (error) => { + const message = errorMessage(error); + toast.error(message); + if (message.toLowerCase().includes("save your signature")) { + onClose(); + navigate("/signature"); + } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) { + onClose(); + navigate("/billing"); + } + }, + }); + + const busy = approve.isPending || handoverMutation.isPending; + + return ( + + + }> + + Review the handover document below. Approving applies your saved signature + and confirms you received the goods. + + + + {isLoading ? ( + + + + Loading handover document… + + + ) : isError || !pdfUrl ? ( + + Could not load the handover document. It may not be generated yet. + + ) : ( +