Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-10 10:34:49 +03:00
178 changed files with 13503 additions and 1807 deletions

View File

@@ -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`).

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS overage_tolerance_tons,
DROP COLUMN IF EXISTS overage_tolerance_meters;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
`);
}
}

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
DROP COLUMN IF EXISTS max_wagons_per_train;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
ADD COLUMN IF NOT EXISTS max_wagons_per_train INT;
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`DELETE FROM freight.wagon_types WHERE code = ANY($1);`,
[this.wagonTypes.map((wt) => wt.code)],
);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE freight.wagon_types
ALTER COLUMN tare_weight_tons DROP NOT NULL;`,
);
}
}

View File

@@ -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 22T70T 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<void> {
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<void> {
// 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;
`);
}
}

View File

@@ -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 };
}
}

View File

@@ -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<ExternalProfile>,
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<string | null> {
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);
}
}

View File

@@ -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;
}

View File

@@ -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<ResetTicket> {
return this.forgotPasswordService.verifyAndMintTicket(
dto.identifier,
dto.channel,
dto.otp,
);
}
}

View File

@@ -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<User>,
@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<User | null> {
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<User | null> {
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<OtpTarget | null> {
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<ResetTicket> {
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)}`;
}
}

View File

@@ -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 {}

View File

@@ -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<string, any>): MileLegSummary | null {
if (!rec) return null;
const num = (v: unknown) => (v == null ? null : Number(v));
const assignments: Array<Record<string, any>> = 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(

View File

@@ -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),

View File

@@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
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<Booking> {
.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<Booking> {
.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<Booking> {
.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<Booking> {
.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<Booking> {
.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();

View File

@@ -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],
);

View File

@@ -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,
}),
),
);

View File

@@ -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;
}

View File

@@ -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<CompanyChangeRequest, "id" | "status"> & { 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<Row> & { 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<CompanyChangeRequest>;
}
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();
});
});

View File

@@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
/**
* The company's latest "open" request — pending (locks the customer) or the
* most recent rejected one (drives the reapply banner + prefill). Approved
* requests are terminal and ignored here.
* most recent rejected one (drives the reapply banner + prefill).
*
* Only the company's newest request may be open. A rejection is superseded the
* moment the customer resubmits: that resubmit opens a *new* request, so once
* it is approved the newest request is terminal and nothing is open — even
* though the older rejected row still sits in the table as history.
*/
async findLatestOpenByCompanyId(
companyId: string,
): Promise<CompanyChangeRequest | null> {
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<CompanyChangeRequest | null> {

View File

@@ -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, string> = {
[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<CompanyProfile> {
constructor(
@@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
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<CompanyProfile[]> {

View File

@@ -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.

View File

@@ -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<string, ClearanceMilestone>();
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');
});
});

View File

@@ -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<ClearanceMilestone> {
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<void> {
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

View File

@@ -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' &&

View File

@@ -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';

View File

@@ -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()

View File

@@ -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,<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 <token>" 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": "<uuid>", "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 ~12 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,<port>,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.

View File

@@ -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);

View File

@@ -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)))

View File

@@ -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;

View File

@@ -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,

View File

@@ -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) {}

View File

@@ -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:<id>` 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:<id>` room the
* service targets.
*/
@WebSocketGateway({
namespace: NOTIFICATION_WS_NAMESPACE,

View File

@@ -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<string>("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",

View File

@@ -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<string, number>();
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 };
}

View File

@@ -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}`;
}

View File

@@ -22,7 +22,9 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
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 },

View File

@@ -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;

View File

@@ -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<string, unknown> = {}) => ({
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);
});
});

View File

@@ -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' });

View File

@@ -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.

View File

@@ -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;

View File

@@ -15,7 +15,6 @@ const nw5: WagonType = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,

View File

@@ -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<string, { code: string; count: number }> {

View File

@@ -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<ClearanceMilestone, 'bookingId' | 'milestoneCode' | 'status'> & {
metadata?: Record<string, unknown> | 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<Partial<Booking>>, 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<void> {
return (
service as unknown as {
completeGatepassMilestoneForSchedule(id: string, at: Date): Promise<void>;
}
).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();
});
});

View File

@@ -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);
});
});

View File

@@ -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<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): 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<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): 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,
};
}

View File

@@ -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 () => {

View File

@@ -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 {
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td>
@@ -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, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
locomotive?: Pick<
Locomotive,
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
>,
): Promise<Required<TrainLimitConfig>> {
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<WagonTypeDimensions[]> {
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<WagonType | null> {
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<string, number>();
// const availableAt = async (yardId: string): Promise<number> => {
// 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<string>();
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

View File

@@ -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);
});
});

View File

@@ -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<WagonType, 'tareWeightTons'>): 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<WagonType, 'lengthMeters'>,
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 },
);
}

View File

@@ -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',

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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)

View File

@@ -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;

View File

@@ -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<keyof Wagon> = [
'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<Wagon>,
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<Wagon> {
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;

View File

@@ -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) {

View File

@@ -860,6 +860,25 @@ export class WarehouseInventoryService {
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
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);
}
}
});

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,
});
});

View File

@@ -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 });

View File

@@ -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: [

View File

@@ -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<Wagon> {
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',
}),

View File

@@ -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<Booking> =>
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<void> {
@@ -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<Booking> {
private demoBookingDefaults(owner: CompanyProfile): Partial<Booking> {
return {
companyId: owner.companyId,
companyProfileId: owner.id,
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',

View File

@@ -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: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
@@ -752,7 +757,6 @@ const App = () => {
</RequirePermission>
}
/>
{/* Hidden for now — Shipment Requests pages disabled.
<Route
path="shipment-requests"
element={
@@ -773,7 +777,6 @@ const App = () => {
</RequirePermission>
}
/>
*/}
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"
@@ -829,10 +832,17 @@ const App = () => {
</RequirePermission>
}
/>
{/* 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). */}
<Route
path="contracts/ops-clearance"
element={<Navigate to="/dashboard/contracts/clearance" replace />}
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<DocumentClearanceListPage opsMode />
</RequirePermission>
}
/>
<Route
path="contracts/:id/create-booking"

View File

@@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps {
queriesLocked?: boolean;
/** Read-only audit view — no approve/query actions. */
readOnly?: boolean;
/**
* GENERAL customs bookings use the phased milestone workflow (same as
* ONE_TIME contracts): hide the legacy output-documents upload block and the
* finalize button — declaration/duty/transit run in the phased action panel.
*/
phasedCustoms?: boolean;
}
const STATUS_META: Record<
@@ -73,6 +79,7 @@ export function ClearanceReviewSection({
approvalsLocked = false,
queriesLocked = false,
readOnly = false,
phasedCustoms = false,
}: ClearanceReviewSectionProps) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
@@ -240,7 +247,7 @@ export function ClearanceReviewSection({
</Stack>
</SectionCard>
{clearance.outputCode && (
{clearance.outputCode && !phasedCustoms && (
<SectionCard
icon={Upload}
title="Customs output documents"
@@ -341,7 +348,7 @@ export function ClearanceReviewSection({
</SectionCard>
)}
{finalizeMutation.isError && (
{!phasedCustoms && finalizeMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeMutation.error instanceof Error
? finalizeMutation.error.message
@@ -349,8 +356,10 @@ export function ClearanceReviewSection({
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
{phasedCustoms ? (
// Phased (GENERAL customs) — no legacy finalize; the milestone steps in
// the action panel drive the workflow, same as ONE_TIME contracts.
<Paper withBorder radius="md" p="md">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
@@ -358,26 +367,50 @@ export function ClearanceReviewSection({
radius="md"
size={28}
>
<FileCheck2 size={15} />
{clearance.allApproved ? (
<CheckCircle2 size={15} />
) : (
<FileCheck2 size={15} />
)}
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{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."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()}
>
Finalize clearance
</Button>
</Group>
</Paper>
</Paper>
) : (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeMutation.isPending}
onClick={() => finalizeMutation.mutate()}
>
Finalize clearance
</Button>
</Group>
</Paper>
)}
{viewer}
</Stack>
);

View File

@@ -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 (
<Paper withBorder radius="md" p="md">
@@ -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"
/>
<Group>

View File

@@ -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
/>

View File

@@ -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<UnitErrors[][]>(() => {
if (!isContainer) return [];
const numberCounts = new Map<string, number>();
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<BulkErrors[]>(() => {
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,
}}
>
<Group justify="flex-end" maw={896} mx="auto">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
<Box maw={896} mx="auto">
{showErrors && !cargoValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted cargo fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
</Box>
</Box>
<Modal

View File

@@ -99,6 +99,7 @@ function computeImportActiveStep(
bookingCreated: boolean,
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
freightPaid: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
@@ -119,9 +120,12 @@ function computeImportActiveStep(
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!clearance.gatepassGranted) return 8;
if (!t1Uploaded && !clearance.t1?.closed) return 9;
if (!clearance.t1?.closed) return 10;
// The customer pays the train/freight charges on the booking. Until that
// settles the gate pass is not granted for this booking, so the flow stops here.
if (!freightPaid) return 8;
if (!clearance.gatepassGranted) return 9;
if (!t1Uploaded && !clearance.t1?.closed) return 10;
if (!clearance.t1?.closed) return 11;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
@@ -129,15 +133,15 @@ function computeImportActiveStep(
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 11;
if (!riskAssigned) return 12;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 12;
if (!clearance.importReleaseGranted) return 13;
return 14;
if (!secondDutyResolved) return 13;
if (!clearance.importReleaseGranted) return 14;
return 15;
}
function t1FilesFromWorkflow(
@@ -243,6 +247,13 @@ export function PhasedClearanceActionPanel({
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
// Freight (train + service) charges settled on the booking. The gate pass is
// only granted to a booking that has paid, so a granted gate pass is server
// proof of payment — it keeps the stepper moving on a page whose
// bookingMilestones have not loaded yet or point at a different booking.
const freightPaid =
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.gatepassGranted);
const activeStep = useMemo(
() =>
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({
)}
</Stepper.Step>
<Stepper.Step
label="Freight payment"
description="Customer pays the train and service charges"
icon={freightPaid ? <CheckCircle2 size={14} /> : <Receipt size={14} />}
>
<StepStatus
done={freightPaid}
pendingLabel="Waiting for the customer to pay the train and service charges. The gate pass is not granted until this settles."
doneLabel="Train and service charges settled."
/>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after wagon allocation"
description="Secured on the train schedule after payment and wagon allocation"
icon={
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
}
>
<ImportGatepassStep clearance={clearance} />
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
</Stepper.Step>
<Stepper.Step
@@ -927,8 +958,16 @@ function ImportT1CloseStep({
/**
* Gate pass status, read-only. Secured on the train schedule's "Save as
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
* The train may be secured while this booking still owes freight charges; the
* booking only picks the gate pass up once its payment settles.
*/
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
function ImportGatepassStep({
clearance,
freightPaid,
}: {
clearance: ClearanceViewLike;
freightPaid: boolean;
}) {
const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
@@ -943,6 +982,16 @@ function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
);
}
if (!freightPaid) {
return (
<StepStatus
done={false}
pendingLabel="Blocked — the customer must pay the train and service charges before the gate pass is granted for this shipment."
doneLabel=""
/>
);
}
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 (
<StepStatus
done={false}
pendingLabel="Available once the T1 is closed."
doneLabel=""
/>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus

View File

@@ -0,0 +1,105 @@
import { Button, Modal, Radio, Stack, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { KeyRound } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { Company, ResetChannel } from "@/types/customer";
export interface ResetPasswordActionProps {
company: Pick<Company, "id" | "email" | "phone">;
}
/**
* 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<ResetChannel>("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 (
<>
<Button
variant="default"
leftSection={<KeyRound size={16} />}
onClick={() => setOpened(true)}
>
Reset password
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Send a password-reset code"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a one-time code to this customer&apos;s primary contact.
They choose their own new password you will not see it.
</Text>
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
label="Send the code via"
>
<Stack gap="xs" mt="xs">
<Radio
value="phone"
label="SMS"
description={company.phone ?? "No phone on the company record"}
/>
<Radio
value="email"
label="Email"
description={company.email ?? "No email on the company record"}
/>
</Stack>
</Radio.Group>
<Text size="xs" c="dimmed">
The code goes to the primary contact&apos;s own email or phone, which
may differ from the company contact details shown above.
</Text>
<Button
color="edr-green"
loading={isPending}
onClick={() => mutate({ companyId: company.id, channel })}
>
Send reset code
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -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";

View File

@@ -250,7 +250,10 @@ const FreightSidebar = ({
<AppShell.Section
grow
component={ScrollArea}
type="never"
type="hover"
scrollbars="y"
scrollbarSize={6}
scrollHideDelay={500}
px="sm"
pb="md"
>

View File

@@ -307,13 +307,24 @@ const RuleEngineFormDialog = ({
);
}
const isNumber = field.type === "number";
return (
<TextInput
key={field.name}
label={label}
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
description={field.description}
type={isNumber ? "number" : field.type === "date" ? "date" : "text"}
// Every rule-engine number (sizes, capacities, counts, points, rates,
// display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API.
min={isNumber ? 0 : undefined}
value={String(values[field.name] ?? "")}
onChange={(e) => 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"

View File

@@ -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}
/>
)}

View File

@@ -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}

View File

@@ -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({
<TextInput
label="Departure date"
type="datetime-local"
min={minValue}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>

View File

@@ -207,9 +207,9 @@ function RankedCard({
{/* Wagons */}
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{/* <Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Text> */}
</Group>
{/* State chip / pay countdown */}

View File

@@ -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({
<TextInput
label="New departure"
type="datetime-local"
min={minDepartureDate}
value={newDepartureDate}
onChange={(e) => setNewDepartureDate(e.target.value)}
/>

View File

@@ -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<ContainerItemStage, string> = {
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
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
{i.loaded && i.truckAssignmentId && (
<Tooltip
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
disabled={i.handoverSigned}

View File

@@ -347,8 +347,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
placeholder="Select the assigned truck"
searchable
clearable
// Enabled at arrival so the operator picks which assigned truck came;
// only locked on the exit (leaving) step once identity is captured.
disabled={isEntranceLocked}
data={truckSelectOptions}
disabled={isTruckIdentityLocked}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);

View File

@@ -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: {

View File

@@ -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",

View File

@@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() {
hideSummary
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
queriesLocked={queriesLocked}
phasedCustoms={isPhasedGeneral}
onChanged={() => void refetch()}
/>
</Grid.Col>

View File

@@ -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<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("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() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
title={opsMode ? "Self-Clearance Review" : "Document Clearance"}
subtitle={
opsMode
? "Review the customer's own clearance documents per shipment booking, raise queries, and finalize."
: "Review customer documents, raise queries, and finalize clearance for each booking."
}
meta={statusBadge}
action={
<ActionIcon

View File

@@ -25,6 +25,7 @@ import {
PackagePlus,
RefreshCw,
Search,
Send,
ShieldCheck,
ShipWheel,
Table as TableIcon,
@@ -49,11 +50,13 @@ import {
useContractClearanceQueue,
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
type ViewMode = "table" | "cards";
type QueueTab = "all" | "et";
type QueueTab = "all" | "et" | "shipments";
interface ClearanceRow {
id: string;
@@ -194,6 +197,10 @@ export default function ContractClearanceListPage() {
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canCreateBooking = hasPermission(
user,
FREIGHT_PERMS.contracts.createBooking,
);
const defaultQueue: QueueTab = canReview ? "all" : "et";
const [queueTab, setQueueTab] = useState<QueueTab>(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: (
<Group gap={6} wrap="nowrap">
<PackageCheck size={15} />
<Box visibleFrom="sm">Shipments</Box>
</Group>
),
});
}
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() {
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
<Group gap="sm" wrap="nowrap">
{canCreateBooking ? (
<Button
variant="filled"
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
onClick={() => navigate("/dashboard/shipment-requests")}
>
Shipment requests
</Button>
) : null}
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
}
/>
@@ -528,7 +595,14 @@ export default function ContractClearanceListPage() {
</Group>
</Box>
{view === "table" ? (
{queueTab === "shipments" ? (
<ShipmentBookingsTable
rows={bookingRows}
loading={bookingsLoading}
error={bookingsError}
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
/>
) : view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ClearanceRow, unknown>
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<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{row.original.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel}
</p>
</div>
</div>
),
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Text size="sm" className="truncate">
{row.original.originLabel}
</Text>
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" className="truncate">
{row.original.destinationLabel}
</Text>
</Group>
),
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(row.original.tradeDirection)}
</Badge>
<Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)}
</Badge>
</Group>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
),
},
{
id: "chevron",
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
if (!loading && !error && rows.length === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No shipment bookings in clearance.</Text>
</Stack>
);
}
return (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
);
}
function ClearanceCardGrid({
rows,
loading,

View File

@@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() {
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
<ClearanceReviewSection
bookingId={id!}
hideSummary
readOnly
phasedCustoms
/>
) : (
<ContractClearanceReviewSection
contractId={id!}

View File

@@ -1,65 +1,172 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { ChevronRight, Ship } from "lucide-react";
import {
Badge,
Box,
Card,
Group,
Loader,
SegmentedControl,
Stack,
Text,
} from "@mantine/core";
import { ChevronRight, FileSignature, PackageCheck, Ship } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
type QueueTab = "contracts" | "shipments";
const prettyStatus = (s?: string | null) =>
(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<QueueTab>("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 (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs contracts handed off to Djibouti GL."
/>
{contractsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{contractItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet.
</Text>
) : (
contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
<Stack gap="lg">
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
/>
<SegmentedControl
value={tab}
onChange={(v) => setTab(v as QueueTab)}
radius="md"
data={[
{
value: "shipments",
label: (
<Group gap={6} wrap="nowrap">
<PackageCheck size={15} />
<Box visibleFrom="sm">Shipments</Box>
<Badge size="sm" radius="sm" variant="light" color="edr-green">
{bookingItems.length}
</Badge>
</Group>
</Card>
))
)}
</Stack>
)}
),
},
{
value: "contracts",
label: (
<Group gap={6} wrap="nowrap">
<FileSignature size={15} />
<Box visibleFrom="sm">Contracts</Box>
<Badge size="sm" radius="sm" variant="light" color="gray">
{contractItems.length}
</Badge>
</Group>
),
},
]}
/>
{isLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : tab === "contracts" ? (
<Stack gap="sm">
{contractItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet.
</Text>
) : (
contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {prettyStatus(c.status)}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
) : (
<Stack gap="sm">
{bookingItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No shipment bookings awaiting a Djibouti action.
</Text>
) : (
bookingItems.map((b) => (
<Card
key={b.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<PackageCheck
size={18}
className="text-[color:var(--freight-brand)]"
/>
<div>
<Text fw={700}>{b.reference}</Text>
<Text size="sm" c="dimmed">
{b.tradeDirection} · {prettyStatus(b.status)}
{b.company?.name ? ` · ${b.company.name}` : ""}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="blue">
Shipment
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
</Stack>
</PageContainer>
);
}

View File

@@ -43,6 +43,7 @@ import {
ProfileChips,
ProfileStatusBadge,
ProfileTypeBadge,
ResetPasswordAction,
TableCard,
formatBytes,
formatDate,
@@ -573,6 +574,7 @@ export default function CustomerDetailPage() {
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
action={<ResetPasswordAction company={company} />}
/>
<Tabs defaultValue="overview">

View File

@@ -107,6 +107,10 @@ const normalizePayload = (values: Record<string, FormValue>) =>
.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<string, unknown> : 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<string, string> = {};
// 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() {
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>Length (m)</MantineTable.Th>
<MantineTable.Th>Tare weight (tons)</MantineTable.Th>
<MantineTable.Th>Load types</MantineTable.Th>
<MantineTable.Th>Status</MantineTable.Th>
<MantineTable.Th ta="right">Actions</MantineTable.Th>
@@ -722,6 +734,7 @@ export function WagonTypesCrudPage() {
<MantineTable.Td>{type.name}</MantineTable.Td>
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
<MantineTable.Td>{type.tareWeightTons ?? '-'}</MantineTable.Td>
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
<MantineTable.Td>
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
@@ -750,7 +763,7 @@ export function WagonTypesCrudPage() {
))}
{!query.isLoading && filtered.length === 0 ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<MantineTable.Td colSpan={8}>
<Text ta="center" c="dimmed" py="xl">
No wagon types found.
</Text>
@@ -759,7 +772,7 @@ export function WagonTypesCrudPage() {
) : null}
{query.isLoading ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<MantineTable.Td colSpan={8}>
<Text ta="center" c="dimmed" py="xl">
Loading...
</Text>
@@ -815,10 +828,13 @@ export function WagonTypesCrudPage() {
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
/>
<NumberInput
label="Max wagons per train"
label="Tare weight (tons)"
description="Empty wagon weight — counts against the locomotive's pull limit alongside the cargo"
required
min={0}
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
onChange={(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 }))}
/>
<MantineSelect
label="Status"
@@ -910,7 +926,17 @@ export function WagonsCrudPage() {
? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})`
: '-',
},
{ key: 'maxPayloadWeight', label: 'Max payload' },
{
// Read-only: the spec lives on the wagon type, so it is displayed, never edited here.
key: 'tareWeight',
label: 'Tare weight',
render: (wagon) => 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: '',

View File

@@ -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<string | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
const [mapsReady, setMapsReady] = useState(false);
@@ -288,9 +292,11 @@ export function TrackingPage() {
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
{canManage && (
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
)}
</Group>
<Grid>
@@ -358,9 +364,11 @@ export function TrackingPage() {
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
{canManage && (
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
)}
</Group>
</Group>
@@ -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() {
<Table.Td align="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
{canManage && (
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
)}
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -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: "",

View File

@@ -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",

Some files were not shown because too many files have changed in this diff Show More