Merge pull request #554 from Tria-plc/dev

merge
This commit is contained in:
Abubeker Yasin
2026-07-09 09:40:25 +03:00
committed by GitHub
87 changed files with 2633 additions and 614 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

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

@@ -993,6 +993,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 +1024,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 +1063,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 +1128,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 +1142,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

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

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

@@ -24,3 +24,13 @@ 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;

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

View File

@@ -30,19 +30,27 @@ import { BillingService } from "../billing/billing.service";
import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
} from "./booking-batch.constants";
import {
WagonTypeDimensions,
bookingGrossWeightTons,
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
trainHardCaps,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
} from './wagon-plan.util';
import {
Capacity,
CorridorBudget,
@@ -60,7 +68,14 @@ interface RouteDayGroup {
day: string;
}
type WagonLengths = { container: number; bulk: number };
/**
* Per-freight-type wagon dimensions used to size a booking's capacity draw:
* its length on the train and the tare it adds to the locomotive's gross load.
*/
type WagonDims = {
container: { lengthMeters: number; tareWeightTons: number };
bulk: { lengthMeters: number; tareWeightTons: number };
};
export type BatchBoardBookingState =
| "ALLOCATED"
@@ -498,8 +513,8 @@ export class BookingBatchService implements OnModuleInit {
}
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const required = need ?? this.needFor(booking, wagonLengths);
const wagonDims = await this.loadWagonDims();
const required = need ?? this.needFor(booking, wagonDims);
let corridorMatched = false;
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
@@ -508,7 +523,7 @@ export class BookingBatchService implements OnModuleInit {
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
corridorMatched = true;
@@ -546,8 +561,8 @@ export class BookingBatchService implements OnModuleInit {
if (!partner || partner.status !== 'FULLY_EXECUTED') {
return;
}
const wagonLengths = await this.loadWagonLengths();
const need = this.combinedNeed(booking, partner, wagonLengths);
const wagonDims = await this.loadWagonDims();
const need = this.combinedNeed(booking, partner, wagonDims);
const scheduleId = await this.pickExportSchedule(booking, need);
await this.reserveOnExport([booking, partner], scheduleId);
}
@@ -617,7 +632,8 @@ export class BookingBatchService implements OnModuleInit {
order: { scheduledDepartureDate: "ASC" },
});
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const board: BatchBoardSchedule[] = [];
@@ -632,7 +648,7 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const need = this.needFor(b, wagonDims);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
@@ -652,7 +668,7 @@ export class BookingBatchService implements OnModuleInit {
};
});
board.push(this.buildScheduleSummary(s, items));
board.push(this.buildScheduleSummary(s, items, rules));
}
return board;
}
@@ -675,7 +691,8 @@ export class BookingBatchService implements OnModuleInit {
);
}
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -721,7 +738,7 @@ export class BookingBatchService implements OnModuleInit {
}
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id);
return {
id: b.id,
@@ -868,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -899,6 +916,13 @@ export class BookingBatchService implements OnModuleInit {
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/**
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
* includes the tare of the wagons it occupies), so the ceiling it is measured
* against must be the same one the fill loop spends from: the locomotive floored
* by the global rule caps and widened by its overage tolerance. Reading the raw
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
*/
private computeBoardCapacity(
items: Array<{
state: BatchBoardBookingState;
@@ -908,22 +932,40 @@ export class BookingBatchService implements OnModuleInit {
}>,
loco: Locomotive | null,
maxWagons: number | null,
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
const caps = loco
? trainHardCaps(
{
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
},
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
)
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters:
Math.round(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons:
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
allocatedLengthMeters: round2(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
),
maxLengthMeters: caps ? caps.maxLengthMeters : null,
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
maxWeightTons: caps ? caps.maxWeightTons : null,
maxWagons: maxWagons ?? null,
};
}
@@ -931,6 +973,7 @@ export class BookingBatchService implements OnModuleInit {
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
@@ -963,7 +1006,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -1014,33 +1057,39 @@ export class BookingBatchService implements OnModuleInit {
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
}
/** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> {
/**
* Fill one schedule from its priority-ordered pool until full. Returns the
* number of commercial units it RESERVED this pass (0 for government-only or
* no-fit passes) so a top-up caller can extend the payment phase only when a
* fresh pay window actually opened.
*/
async fillSchedule(scheduleId: string): Promise<number> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || !this.isFillable(schedule)) return;
if (!schedule || !this.isFillable(schedule)) return 0;
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
);
return;
return 0;
}
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
if (budget.maxRemaining().wagons <= 0) {
await this.setWindow(scheduleId, "FULL");
return;
return 0;
}
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
const units = this.groupConsolidatedPool(pool);
let armed = false;
let reservedThisPass = 0;
let commercialReserved = 0;
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
// reservations trickle instead of landing in one pass (a reserve() throwing
@@ -1055,8 +1104,8 @@ export class BookingBatchService implements OnModuleInit {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
? this.combinedNeed(booking, partner, wagonDims)
: this.needFor(booking, wagonDims);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
@@ -1075,7 +1124,7 @@ export class BookingBatchService implements OnModuleInit {
need,
leg,
budget,
wagonLengths,
wagonDims,
);
if (!freed) continue; // still doesn't fit even after preempt
} else {
@@ -1106,6 +1155,7 @@ export class BookingBatchService implements OnModuleInit {
await this.reserve(booking, scheduleId);
if (partner) await this.reserve(partner, scheduleId);
armed = true;
commercialReserved += 1;
}
budget.subtract(need, leg);
reservedThisPass += 1;
@@ -1125,6 +1175,7 @@ export class BookingBatchService implements OnModuleInit {
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
return commercialReserved;
}
/**
@@ -1140,6 +1191,42 @@ export class BookingBatchService implements OnModuleInit {
destinationYardId: string,
day: string,
): Promise<string[]> {
const { scheduleIds } = await this.fillRouteDayInternal(
originYardId,
destinationYardId,
day,
);
return scheduleIds;
}
/**
* Route-day top-up for a single schedule: re-run the DAY pool over the whole
* corridor the schedule belongs to, and report how many commercial units got a
* fresh pay window.
*
* `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on
* `booking.train_schedule_id = :scheduleId`, but under day-level pooling a
* booking that has not been reserved yet has a NULL `train_schedule_id` — it is
* only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows
* and the waiting list never boarded after an expiry freed capacity; bookings
* trickled in one per window cycle instead.
*/
private async topUpFill(scheduleId: string): Promise<number> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return 0;
const { commercialReserved } = await this.fillRouteDayInternal(
schedule.originStationId,
schedule.destinationStationId,
eatDay(schedule.scheduledDepartureDate),
);
return commercialReserved;
}
private async fillRouteDayInternal(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<{ scheduleIds: string[]; commercialReserved: number }> {
// The day's fillable schedules on this exact corridor, earliest first. Fillable
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
// the batch must run while the customer window is closed.
@@ -1157,23 +1244,36 @@ export class BookingBatchService implements OnModuleInit {
},
],
});
const scheduleIds = corridor
const onDay = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
this.isFillable(s),
eatDay(s.scheduledDepartureDate) === day,
)
.sort(
(a, b) =>
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
)
.map((s) => s.id);
);
if (scheduleIds.length === 0) return [];
// A schedule flagged FULL is rejected by isFillable() before its budget is
// ever consulted. Re-derive that flag from live capacity first, so a train
// whose bookings all expired is not skipped forever with an empty consist.
for (const s of onDay) {
if (s.bookingWindowStatus === "FULL") {
await this.refreshWindowStatus(s.id);
const fresh = await this.trainSchedulesRepository.findById(s.id);
if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus;
}
}
const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id);
if (scheduleIds.length === 0) {
return { scheduleIds: [], commercialReserved: 0 };
}
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
// Live per-schedule corridor budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
@@ -1189,10 +1289,10 @@ export class BookingBatchService implements OnModuleInit {
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
// The day pool covers every booking whose leg lies somewhere on one of the
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
@@ -1214,13 +1314,14 @@ export class BookingBatchService implements OnModuleInit {
`poolSize=${pool.length} units=${units.length}`,
);
let reservedThisPass = 0;
let commercialReserved = 0;
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
? this.combinedNeed(booking, partner, wagonDims)
: this.needFor(booking, wagonDims);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
@@ -1256,7 +1357,7 @@ export class BookingBatchService implements OnModuleInit {
need,
leg,
t.budget,
wagonLengths,
wagonDims,
);
if (freed) {
target = t;
@@ -1273,7 +1374,12 @@ export class BookingBatchService implements OnModuleInit {
// non-import never split — isSplitEligible guards that. Passing the live
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
if (offered) continue;
if (offered) {
// A partial offer opens a real commercial pay window, same as reserve().
commercialReserved += 1;
reservedThisPass += 1;
continue;
}
// Stays in the pool, retried next batch/window cycle.
this.notifier.unplaced(booking, day);
if (partner) this.notifier.unplaced(partner, day);
@@ -1294,6 +1400,7 @@ export class BookingBatchService implements OnModuleInit {
await this.reserve(booking, target.id);
if (partner) await this.reserve(partner, target.id);
target.armed = true;
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
reservedThisPass += 1;
@@ -1315,7 +1422,7 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(t.id);
}
return trains.map((t) => t.id);
return { scheduleIds: trains.map((t) => t.id), commercialReserved };
}
/**
@@ -1385,7 +1492,7 @@ export class BookingBatchService implements OnModuleInit {
if (booking.consolidationPartnerId) return null;
if (await this.splitService.findOpenOffer(booking.id)) return null;
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
const sized = await this.splitService.sizeOffer(
booking,
@@ -1397,11 +1504,16 @@ export class BookingBatchService implements OnModuleInit {
const offeredNeed: Capacity = {
wagons: sized.offeredWagons,
weightTons: sized.offeredWeightTons,
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
weightTons: bookingGrossWeightTons(
sized.offeredWeightTons,
sized.offeredWagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
sized.offeredWagons,
this.lengthsOf(wagonDims),
),
};
if (!this.fits(offeredNeed, budget)) return null;
@@ -1499,7 +1611,13 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
);
await this.fillSchedule(scheduleId);
const topUpReserved = await this.topUpFill(scheduleId);
// A top-up opened a fresh pay window for waiting bookings — push the
// schedule's PAYMENT phase out so the window tick's concludeCycle doesn't
// fire before those customers' new deadlines and expire them prematurely.
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(scheduleId);
}
}
}
@@ -1509,7 +1627,10 @@ export class BookingBatchService implements OnModuleInit {
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
await this.settleReserved(scheduleId, true);
await this.fillSchedule(scheduleId);
const topUpReserved = await this.topUpFill(scheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(scheduleId);
}
void this.triggerWagonAllocation(scheduleId);
}
@@ -1612,9 +1733,16 @@ export class BookingBatchService implements OnModuleInit {
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Capture the train before expire() detaches the booking from it — the
// top-up has to run against the schedule whose wagons were just freed.
const freedScheduleId = booking.trainScheduleId;
await this.expire(booking);
if (booking.trainScheduleId)
await this.fillSchedule(booking.trainScheduleId);
if (freedScheduleId) {
const topUpReserved = await this.topUpFill(freedScheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(freedScheduleId);
}
}
}
// ---- intercity ride-along API ---------------------------------------------
@@ -1635,10 +1763,10 @@ export class BookingBatchService implements OnModuleInit {
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
}
/**
@@ -1671,6 +1799,28 @@ export class BookingBatchService implements OnModuleInit {
* so the engine sets it as it picks the train.
*/
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
// Idempotency guard: a booking already reserved (pay window open) or already
// paid on THIS schedule must never be re-reserved — that would fire a second
// `payNow` and reset its deadline, the "asked to pay again after paying"
// symptom. Read fresh state (the in-memory `booking` may be stale from the
// pooled query). Only bookings not yet committed to this train pass through.
const fresh = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: booking.id } });
if (
fresh &&
fresh.trainScheduleId === scheduleId &&
(fresh.status === "SELECTED_FOR_BATCH" ||
fresh.status === "AWAITING_PAYMENT" ||
fresh.status === "PAID" ||
fresh.paymentStatus === "PAID")
) {
this.logger.debug(
`[BATCH] reserve skipped for ${booking.reference} — already ` +
`${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`,
);
return;
}
const now = new Date();
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
await this.bookingsRepository.update(booking.id, {
@@ -1781,6 +1931,7 @@ export class BookingBatchService implements OnModuleInit {
* it failed to pay for — it's back in the day pool for staff to act on.
*/
private async expire(booking: Booking): Promise<void> {
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: "EXPIRED",
@@ -1789,6 +1940,9 @@ export class BookingBatchService implements OnModuleInit {
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
// The wagons this reservation held are back — a schedule parked at FULL
// because of it must reopen, or it can never be filled again.
if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId);
// An unpaid partial offer dies with the reservation — the booking stays whole.
if (this.splitService) {
await this.splitService.expireOpenOffer(booking.id);
@@ -1893,7 +2047,7 @@ export class BookingBatchService implements OnModuleInit {
need: Capacity,
leg: CorridorLeg,
budget: CorridorBudget,
wagonLengths: WagonLengths,
wagonDims: WagonDims,
): Promise<boolean> {
if (budget.fits(need, leg)) return true;
const reservedCommercial = (
@@ -1941,7 +2095,10 @@ export class BookingBatchService implements OnModuleInit {
);
});
this.notifier.displaced(victim);
budget.add(this.needFor(victim, wagonLengths), victimLeg);
budget.add(this.needFor(victim, wagonDims), victimLeg);
// Displacing frees wagons the same way an expiry does — don't leave the
// schedule stuck at FULL.
await this.refreshWindowStatus(scheduleId);
}
return budget.fits(need, leg);
}
@@ -1995,7 +2152,7 @@ export class BookingBatchService implements OnModuleInit {
private combinedNeed(
primary: Booking,
partner: Booking,
wagonLengths: WagonLengths,
wagonDims: WagonDims,
): Capacity {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
@@ -2004,15 +2161,36 @@ export class BookingBatchService implements OnModuleInit {
totalContainers > 0
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
: this.wagonsFor(primary) + this.wagonsFor(partner);
const weightTons =
const cargoTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
return {
wagons: sharedWagons,
weightTons,
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
// Consolidation saves tare as well as slots: the pair rides `sharedWagons`
// wagons, so it is charged `sharedWagons` tares, not one per booking.
weightTons: bookingGrossWeightTons(
cargoTons,
sharedWagons,
this.tareFor(primary.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
primary.freightType,
sharedWagons,
this.lengthsOf(wagonDims),
),
};
}
/** Per-wagon tare for the wagon type this freight rides on. */
private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number {
return freightType === 'BULK'
? wagonDims.bulk.tareWeightTons
: wagonDims.container.tareWeightTons;
}
private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } {
return {
container: wagonDims.container.lengthMeters,
bulk: wagonDims.bulk.lengthMeters,
};
}
@@ -2020,26 +2198,39 @@ export class BookingBatchService implements OnModuleInit {
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
return Math.ceil(booking.wagonsRequired);
}
const fromContainers = (booking.bookingContainers ?? []).reduce(
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
return Math.max(
DEFAULT_WAGONS_PER_BOOKING,
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
// booking.wagonsRequired is NULL for most rows (only set on certain
// scheduling paths). Derive from the container lines, TEU-aware: two 20ft
// share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw
// container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and
// wrongly filled the train.
const fromContainers = containerWagonsForLines(
booking.bookingContainers ?? [],
);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
}
/** What one booking consumes along all three capacity axes. */
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
/**
* What one booking consumes along all three capacity axes.
*
* The weight axis is GROSS — cargo plus the tare of every wagon the booking
* occupies — because it is spent against the locomotive's pull limit, which
* governs the whole train and not just its payload. Charging cargo alone let a
* 37-wagon box-wagon train read 2590T when it really weighed 3522T.
*/
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
const wagons = this.wagonsFor(booking);
return {
wagons,
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
weightTons: bookingGrossWeightTons(
Number(booking.cargoTotalWeightVgm ?? 0),
wagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
wagons,
this.lengthsOf(wagonDims),
),
};
}
@@ -2051,7 +2242,11 @@ export class BookingBatchService implements OnModuleInit {
);
}
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
/**
* Hard caps for a schedule's train: gross pull weight, train length, and the
* length-derived wagon slot count (never a fixed 53). Bookings spend against
* these via {@link needFor}, whose weight axis is gross.
*/
private async capacityLimits(
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
@@ -2061,6 +2256,8 @@ export class BookingBatchService implements OnModuleInit {
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
},
wagonTypes,
{
@@ -2094,31 +2291,48 @@ export class BookingBatchService implements OnModuleInit {
}
}
private async loadWagonTypeDimensions(): Promise<
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: "NW5" }, { code: "CW3" }],
});
/**
* Every active wagon type, so the slot count is derived from the shortest wagon
* the fleet can actually marshal rather than from an arbitrary two-code sample.
*/
private async loadWagonTypeDimensions(): Promise<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,
},
];
}
private async loadWagonLengths(): Promise<WagonLengths> {
/** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */
private async loadWagonDims(): Promise<WagonDims> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: "NW5" }, { code: "CW3" }],
});
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
const nw5 = byCode.get("NW5");
const cw3 = byCode.get("CW3");
return {
container:
byCode.get("NW5")?.lengthMeters ??
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
container: {
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
},
bulk: {
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
},
};
}
@@ -2155,7 +2369,7 @@ export class BookingBatchService implements OnModuleInit {
private async remainingBudget(
schedule: TrainSchedule,
limits: Capacity,
wagonLengths: WagonLengths,
wagonDims: WagonDims,
): Promise<CorridorBudget> {
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits);
@@ -2167,7 +2381,7 @@ export class BookingBatchService implements OnModuleInit {
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
this.needFor(b, wagonLengths),
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
@@ -2179,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit {
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
*/
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const budget = await this.remainingBudget(
schedule,
{
@@ -2187,7 +2401,7 @@ export class BookingBatchService implements OnModuleInit {
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
wagonLengths,
wagonDims,
);
return budget.maxRemaining().wagons;
}
@@ -2220,6 +2434,35 @@ export class BookingBatchService implements OnModuleInit {
return (await this.remainingWagons(schedule)) <= 0;
}
/**
* Re-derive `bookingWindowStatus` from live capacity after wagons were freed
* (a reservation expired, a booking was displaced, a link was removed).
*
* FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before
* it ever looks at the budget, and the only writers of OPEN skip a FULL row. So
* a train that filled once and then lost every booking to expiry stayed FULL
* with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT
* forever while `concludeCycle` (which reads real capacity, not the flag) kept
* reopening it. Clearing FULL here is what lets the next batch actually run.
*
* Only the customer-facing OPEN phases may go back to OPEN; a schedule mid
* DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits.
*/
async refreshWindowStatus(scheduleId: string): Promise<void> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
if ((await this.remainingWagons(schedule)) <= 0) return;
const customerWindowOpen =
schedule.windowPhase == null || schedule.windowPhase === "OPEN";
await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED");
this.logger.log(
`[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` +
`${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`,
);
}
// ---- timer plumbing -------------------------------------------------------
/** Configured customer pay window in ms (global rules, with defaults). */
@@ -2256,6 +2499,45 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* A top-up reservation (settle freed capacity mid-cycle, so the next waiting
* booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's
* `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run
* concludeCycle — was frozen when the phase started. Without this, concludeCycle
* fires before the top-up customer's deadline and expires a booking that still
* had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment
* window from now, but never past departure. Only while the schedule is still
* in the PAYMENT phase (a reopened cycle manages its own phase).
*/
async extendPaymentPhaseForTopUp(scheduleId: string): Promise<void> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
const windowMs = await this.paymentWindowMs();
let target = new Date(Date.now() + windowMs);
if (
schedule.scheduledDepartureDate &&
target > schedule.scheduledDepartureDate
) {
target = schedule.scheduledDepartureDate;
}
// Only ever push the deadline OUT, never pull it in.
if (
schedule.paymentPhaseEndsAt &&
schedule.paymentPhaseEndsAt.getTime() >= target.getTime()
) {
return;
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { paymentPhaseEndsAt: target });
this.logger.log(
`[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` +
`(top-up reservation opened a fresh pay window)`,
);
}
private removeTimeout(scheduleId: string): void {
const name = this.timeoutName(scheduleId);
try {

View File

@@ -328,6 +328,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

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

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

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`,
);
@@ -2947,8 +2954,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 +2975,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 +3033,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 +3063,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 +3128,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 +3458,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 +3715,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 +4218,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 +4241,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

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

@@ -2603,12 +2603,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 +2625,7 @@ export class WarehouseInventoryService {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
@@ -2637,6 +2639,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 +2669,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 +3025,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 +3266,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,

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,

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,

View File

@@ -204,6 +204,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'),
@@ -477,6 +478,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

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

@@ -307,13 +307,23 @@ const RuleEngineFormDialog = ({
);
}
const isNumber = field.type === "number";
return (
<TextInput
key={field.name}
label={label}
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
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

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

@@ -145,6 +145,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

@@ -536,7 +536,7 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
tareWeightTons: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -587,7 +587,7 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
tareWeightTons: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -601,7 +601,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 +610,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 +717,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 +730,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 +759,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 +768,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 +824,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"
@@ -1118,6 +1130,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 +1168,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 +1184,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: "",

View File

@@ -279,7 +279,6 @@ 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: "supportedLoadTypes",
header: "Load types",
@@ -291,12 +290,6 @@ 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 },
{
name: "maxWagonsPerTrain",
label: "Max wagons per train",
type: "number",
optional: true,
},
{
name: "supportedLoadTypes",
label: "Supported load types",

View File

@@ -175,6 +175,7 @@ function CapacityChip({
);
}
/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */
function weightPctOf(s: BatchBoardSchedule) {
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
@@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) {
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
: null;
}
function wagonPctOf(s: BatchBoardSchedule) {
return s.capacity.maxWagons && s.capacity.maxWagons > 0
? (s.capacity.allocatedWagons / s.capacity.maxWagons) * 100
: null;
}
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const navigate = useNavigate();
@@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
const lengthPct = lengthPctOf(schedule);
const weightPct = weightPctOf(schedule);
const wagonPct = wagonPctOf(schedule);
const totalBookings = totalBookingCount(counts);
@@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
</Alert>
) : null}
{/* capacity: weight + length rings + wagons (numbers preserved) */}
{/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */}
<Box
py="sm"
px="xs"
@@ -289,26 +296,35 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
{weightPct != null ? (
<CapacityRing
pct={weightPct}
label="WEIGHT"
label="GROSS WT"
current={fmtTons(capacity.usedWeightTons)}
max={fmtTons(capacity.maxWeightTons ?? 0)}
/>
) : null}
<Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="gray">
<Package size={17} />
</ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
{capacity.allocatedWagons}
</Text>
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Wagons
</Text>
<Text size="xs" c="dimmed">
allocated
</Text>
</Stack>
{wagonPct != null ? (
<CapacityRing
pct={wagonPct}
label="WAGONS"
current={String(capacity.allocatedWagons)}
max={String(capacity.maxWagons ?? 0)}
/>
) : (
<Stack gap={0} align="center" style={{ flex: 1 }}>
<ThemeIcon size={34} radius="md" variant="light" color="gray">
<Package size={17} />
</ThemeIcon>
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
{capacity.allocatedWagons}
</Text>
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
Wagons
</Text>
<Text size="xs" c="dimmed">
allocated
</Text>
</Stack>
)}
{lengthPct != null ? (
<CapacityRing
@@ -519,30 +535,42 @@ export default function BatchBoardPage() {
id: "capacity",
header: "Capacity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
{row.original.capacity.allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
wgn
</Text>
cell: ({ row }) => {
const { allocatedWagons, maxWagons } = row.original.capacity;
const wagonPct = wagonPctOf(row.original);
return (
<Group gap={6} wrap="nowrap">
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="gross" />
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
{wagonPct != null ? (
<CapacityChip
icon={Package}
pct={wagonPct}
text={`${allocatedWagons}/${maxWagons} wgn`}
/>
) : (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: "var(--mantine-color-edr-green-0)",
border: "1px solid var(--mantine-color-edr-green-1)",
}}
>
<Package size={12} color="var(--mantine-color-edr-green-7)" />
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
{allocatedWagons}
</Text>
<Text size="10px" c="dimmed" lh={1.2}>
wgn
</Text>
</Group>
)}
</Group>
</Group>
),
);
},
},
{
id: "bookings",

View File

@@ -892,7 +892,9 @@ export default function BatchScheduleDetailPage() {
items={[
{
label: "Allocated wagons",
value: data.capacity.allocatedWagons,
value: data.capacity.maxWagons
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
: data.capacity.allocatedWagons,
hint: "on this train",
icon: Boxes,
},
@@ -904,10 +906,11 @@ export default function BatchScheduleDetailPage() {
icon: Ruler,
},
{
label: "Weight",
label: "Gross weight",
value: data.capacity.maxWeightTons
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
: fmtTons(data.capacity.usedWeightTons),
hint: "wagon tare + cargo",
icon: Weight,
},
{

View File

@@ -55,6 +55,13 @@ import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
const nowLocalDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
};
const splitDate = (value?: string | null) => {
if (!value) return { day: "—", time: "" };
const date = new Date(value);
@@ -103,6 +110,12 @@ export default function TrainScheduleV2ListPage() {
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
() => (createOpen ? nowLocalDateTime() : ""),
[createOpen],
);
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
@@ -443,6 +456,13 @@ export default function TrainScheduleV2ListPage() {
});
return;
}
if (new Date(scheduleDate).getTime() < Date.now()) {
toast({
title: "Departure date must be in the future",
variant: "destructive",
});
return;
}
try {
const created = await create.mutateAsync({
payload: {
@@ -691,6 +711,7 @@ export default function TrainScheduleV2ListPage() {
<TextInput
label="Departure date"
type="datetime-local"
min={minScheduleDate}
value={scheduleDate}
onChange={(e) => setScheduleDate(e.currentTarget.value)}
/>

View File

@@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
@@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
@@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
@@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0.001}
disabled={loading}
@@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() {
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
disabled={loading}
@@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, windowOpenHour: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
max={23}
@@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() {
setForm((current) => ({ ...current, windowCloseHour: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
max={23}

View File

@@ -27,6 +27,10 @@ export interface Locomotive {
currentYard?: { id: string; label?: string; code?: string } | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
/** Tons a train may exceed maxPullWeightTons by before scheduling blocks it. */
overageToleranceTons?: number | null;
/** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */
overageToleranceMeters?: number | null;
powerKw?: number | null;
tractionForceKn?: number | null;
maxSpeedKmh?: number | null;

View File

@@ -8,7 +8,8 @@ export interface WagonType {
name: string;
capacityTons: number;
lengthMeters: number;
maxWagonsPerTrain?: number | null;
/** Empty wagon weight; required, since pull limits apply to tare + cargo. */
tareWeightTons: number;
supportedLoadTypes: string[];
isActive: boolean;
}

View File

@@ -64,7 +64,7 @@ import type {
WarehouseZone,
} from '@/types/warehouse';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
export interface ContainerItem {
containerNumber: string;
@@ -75,6 +75,8 @@ export interface ContainerItem {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;

View File

@@ -280,10 +280,13 @@ export interface BatchBoardSchedule {
capacity: {
allocatedWagons: number;
allocatedLengthMeters: number;
/** Train-length cap: locomotive floored by global rules, plus overage tolerance. */
maxLengthMeters: number | null;
/** GROSS tons on the train — wagon tare + cargo, since the pull limit hauls both. */
usedWeightTons: number;
/** Pull-weight cap: locomotive floored by global rules, plus overage tolerance. */
maxWeightTons: number | null;
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
/** Wagon-slot cap for the train, derived from train length and the shortest wagon type. */
maxWagons: number | null;
};
counts: {

View File

@@ -334,6 +334,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
TRUCK_ASSIGNED: {
stage: 3,
icon: Truck,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Truck assigned · preparing for pickup",
step: "edr-green.5",
badgeLabel: "Truck assigned",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
IN_TRANSIT: {
stage: 3,
icon: Truck,

View File

@@ -18,6 +18,7 @@ import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard";
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
import { KeyFactsStrip } from "./components/KeyFactsStrip";
import { MileSummaryCard } from "./components/MileSummaryCard";
import { BodyGrid, PageShell } from "./components/layout";
import {
CancelledBanner,
@@ -220,6 +221,8 @@ export function ReadonlyBookingView({
<WarehousePaymentsSection bookingId={booking.id} />
<ActivityCard booking={booking} />
<MileSummaryCard booking={booking} />
</>
}
right={

View File

@@ -16,6 +16,7 @@ import type { Freight } from "@edr/types";
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { saveBlob } from "@/utils/download";
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
@@ -262,7 +263,7 @@ export function BookingPaymentPanel({
? "Paid"
: showCountdown
? "Pay window open"
: (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")}
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
</Group>
</Group>

View File

@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { fmtDate, yardLabel } from "../utils";
import { SectionCard } from "./layout";
@@ -38,12 +40,7 @@ function Fact({ label, value }: { label: string; value: ReactNode }) {
export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
const isContract = booking.bookingType === "GENERAL_CONTRACT";
const freight = booking.freightType === "BULK" ? "Bulk" : "Container";
const payment = booking.paymentStatus
? booking.paymentStatus
.replace(/_/g, " ")
.toLowerCase()
.replace(/^\w/, (c) => c.toUpperCase())
: "—";
const payment = paymentStatusLabel(booking.paymentStatus);
return (
<SectionCard p="lg">

View File

@@ -0,0 +1,214 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import type {
MileLegSummary,
MileVehicleSummary,
} from "@/services/bookings.service";
import { CardTitle, SectionCard } from "./layout";
function StatusPill({ status }: { status: string }) {
const s = status.toUpperCase();
const done = s.includes("DELIVER") || s.includes("COMPLET") || s.includes("PAID");
const active = s.includes("TRANSIT") || s.includes("PROGRESS") || s.includes("ASSIGN");
const dot = done ? "#0EA371" : active ? "#2563EB" : "#94A3B8";
const color = done ? "#0A6F4D" : active ? "#1E40AF" : "#475569";
const bg = done ? "#ECF6F1" : active ? "#EAF1FE" : "#F1F4F7";
const border = done ? "#CDEBDD" : active ? "#CFDDFB" : "#E1E7EE";
const label = status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase());
return (
<Group
component="span"
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
border: `1px solid ${border}`,
padding: "4px 10px",
fontSize: 11.5,
fontWeight: 700,
color,
}}
>
<Box
component="span"
style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: dot }}
/>
{label}
</Group>
);
}
function VehicleRow({ v }: { v: MileVehicleSummary }) {
const parts: string[] = [];
if (v.driverName) parts.push(v.driverName);
if (v.containerNumber) parts.push(`Container ${v.containerNumber}`);
if (v.distanceKm != null) parts.push(`${v.distanceKm} km`);
return (
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
py={8}
style={{ borderTop: "1px solid #F2F5F8" }}
>
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fz="13px" fw={700} c="#10202F">
{v.plate || v.code || "Vehicle"}
</Text>
{parts.length > 0 && (
<Text fz="12px" c="#6B7C8E">
{parts.join(" · ")}
</Text>
)}
</Stack>
{v.code && v.plate && (
<Text fz="12px" c="#9AA8B5" style={{ whiteSpace: "nowrap" }}>
{v.code}
</Text>
)}
</Group>
);
}
function LegBlock({
title,
leg,
address,
}: {
title: string;
leg: MileLegSummary | null;
address?: string | null;
}) {
const fmtMoney = (n: number | null, currency: string) =>
n == null
? null
: `${currency} ${Number(n).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
return (
<Box>
<Group justify="space-between" align="center" pb={8}>
<Text fz="13px" fw={700} c="#10202F">
{title}
</Text>
{leg ? (
<StatusPill status={leg.status} />
) : (
<StatusPill status="AWAITING_ASSIGNMENT" />
)}
</Group>
{address && (
<Text fz="12px" c="#6B7C8E" pb={4}>
{title.startsWith("First") ? "Pickup" : "Delivery"}:{" "}
<b style={{ color: "#10202F" }}>{address}</b>
</Text>
)}
{!leg ? (
<Text fz="12px" c="#9AA8B5" fs="italic" py={6}>
Requested a vehicle and driver will be assigned soon.
</Text>
) : leg.vehicles.length > 0 ? (
<Box>
{leg.vehicles.map((v, i) => (
<VehicleRow key={`${v.plate ?? v.code ?? "v"}-${i}`} v={v} />
))}
</Box>
) : (
<Text fz="12px" c="#9AA8B5" fs="italic" py={6}>
No vehicle assigned yet.
</Text>
)}
{leg && (
<Group
justify="space-between"
align="center"
pt={8}
mt={4}
style={{ borderTop: "1px solid #F2F5F8" }}
>
<Group gap={16}>
{leg.exactKm != null && (
<Text fz="12px" c="#6B7C8E">
Total distance{" "}
<b style={{ color: "#10202F" }}>{leg.exactKm} km</b>
</Text>
)}
{leg.remainingPayment != null && leg.remainingPayment > 0 && (
<Text fz="12px" c="#6B7C8E">
Balance{" "}
<b style={{ color: "#10202F" }}>
{fmtMoney(leg.remainingPayment, leg.currency)}
</b>
</Text>
)}
</Group>
{leg.invoiced && (
<Text fz="11px" fw={700} c="#0A6F4D">
Invoiced
</Text>
)}
</Group>
)}
</Box>
);
}
export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) {
const { data } = useQuery({
queryKey: ["booking-mile-summary", booking.id],
queryFn: () => bookingsService.mileSummary(booking.id),
});
const firstLeg = data?.firstMile ?? null;
const lastLeg = data?.lastMile ?? null;
// The backend doesn't persist an "enabled" flag — the presence of a
// pickup/delivery address is the request signal. Also show a leg once its
// record exists, regardless of address.
const showFirst = !!booking.firstMilePickupAddress || !!firstLeg;
const showLast = !!booking.lastMileDeliveryAddress || !!lastLeg;
if (!showFirst && !showLast) return null;
return (
<SectionCard p={22}>
<Box pb={12}>
<CardTitle>First & Last Mile</CardTitle>
</Box>
<Stack gap={20}>
{showFirst && (
<LegBlock
title="First mile"
leg={firstLeg}
address={booking.firstMilePickupAddress}
/>
)}
{showLast && (
<LegBlock
title="Last mile"
leg={lastLeg}
address={booking.lastMileDeliveryAddress}
/>
)}
</Stack>
</SectionCard>
);
}

View File

@@ -13,6 +13,8 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
export interface PageHeaderMenuActions {
@@ -60,7 +62,7 @@ export function PageHeader({
className="shrink-0 rounded-full"
style={{ width: 7, height: 7, backgroundColor: dotColor }}
/>
{status.replace(/_/g, " ").replace(/\b\w/g, (m) => m.toUpperCase())}
{bookingStatusLabel(status)}
</span>
<span className="inline-flex items-center gap-[6px] rounded-full bg-[#F1F4F7] px-[11px] py-1.5 text-xs font-bold text-[#475569]">

View File

@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { fmtDate, isDraftLike, isNegative } from "../utils";
import { CardTitle, SectionCard } from "./layout";
@@ -15,10 +17,7 @@ function StatusPill({ status }: { status: string }) {
const color = negative ? "#A93226" : draft ? "#475569" : "#0A6F4D";
const bg = negative ? "#FBEAE7" : draft ? "#F1F4F7" : "#ECF6F1";
const border = negative ? "#F3C8C1" : draft ? "#E1E7EE" : "#CDEBDD";
const label = status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase());
const label = bookingStatusLabel(status);
return (
<Group

View File

@@ -1,12 +1,32 @@
import { Badge, Group, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
/**
* Shared presentation helpers for booking-like rows (one-time bookings AND
* general contracts). Kept in one place so the bookings list, contracts list,
* and detail page render type/freight/mode/payment consistently.
*/
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
export function titleCaseStatus(status: string): string {
return status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase());
}
/**
* Human label for a booking status. Single source of truth is the booking
* list's `STATUS_CONFIG` badge labels; anything not mapped there falls back to
* a readable title-cased form (never the raw SCREAMING_SNAKE enum).
*/
export function bookingStatusLabel(status?: string | null): string {
if (!status) return "—";
return STATUS_CONFIG[status]?.badgeLabel ?? titleCaseStatus(status);
}
type BookingLike = Freight.IBooking & {
bookingType?: string;
freightType?: string;
@@ -59,7 +79,12 @@ const PAYMENT_COLORS: Record<string, string> = {
PAID: "green",
PENDING: "gray",
PNR_GENERATED: "blue",
// Backend emits the long form on some flows; keep the short alias too.
VERIFICATION_IN_PROGRESS: "yellow",
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
OVERDUE: "red",
REFUNDED: "blue",
CANCELLED: "gray",
FAILED: "red",
};
@@ -68,9 +93,19 @@ const PAYMENT_LABELS: Record<string, string> = {
PENDING: "Pending",
PNR_GENERATED: "PNR generated",
VERIFICATION_IN_PROGRESS: "Verifying",
PAYMENT_VERIFICATION_IN_PROGRESS: "Verifying",
OVERDUE: "Overdue",
REFUNDED: "Refunded",
CANCELLED: "Cancelled",
FAILED: "Failed",
};
/** Human label for a payment status (plain text, no badge). */
export function paymentStatusLabel(status?: string | null): string {
if (!status) return "—";
return PAYMENT_LABELS[status] ?? titleCaseStatus(status);
}
/** Payment status pill. */
export function PaymentBadge({ status }: { status?: string | null }) {
if (!status) return <Text fz={13} c="dimmed"></Text>;
@@ -81,7 +116,7 @@ export function PaymentBadge({ status }: { status?: string | null }) {
color={PAYMENT_COLORS[status] ?? "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{PAYMENT_LABELS[status] ?? status.replace(/_/g, " ")}
{PAYMENT_LABELS[status] ?? titleCaseStatus(status)}
</Badge>
);
}

View File

@@ -1,11 +1,8 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
import type { MouseEvent } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { type MouseEvent, useState } from "react";
import { api } from "@/services/api";
import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
type ApproveDeliveryButtonProps = ButtonProps & {
bookingId: string;
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
export function ApproveDeliveryButton({
bookingId,
stopPropagation,
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
variant = "filled",
...props
}: ApproveDeliveryButtonProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
const mutation = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
try {
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
downloadBlob(blob, `handover-${bookingId}.pdf`);
toast.success("Delivery approved and signed handover downloaded");
} catch {
toast.success("Delivery approved and handover signed");
toast.error("Signed handover document could not be downloaded");
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
onApproved?.();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
navigate("/billing");
}
},
});
const [opened, setOpened] = useState(false);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) event.stopPropagation();
mutation.mutate({ id: bookingId });
setOpened(true);
};
return (
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={mutation.isPending || handoverMutation.isPending}
onClick={handleClick}
>
Approve delivery
</Button>
<>
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
onClick={handleClick}
>
Approve delivery
</Button>
<ApproveDeliveryModal
bookingId={bookingId}
opened={opened}
onClose={() => setOpened(false)}
onApproved={onApproved}
/>
</>
);
}

View File

@@ -0,0 +1,171 @@
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
type ApproveDeliveryModalProps = {
bookingId: string;
opened: boolean;
onClose: () => void;
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
/**
* Approve-delivery flow: open the handover document for the customer to review,
* then apply their saved signature (approve) and hand back the signed PDF.
*/
export function ApproveDeliveryModal({
bookingId,
opened,
onClose,
onApproved,
}: ApproveDeliveryModalProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const {
data: docBlob,
isLoading,
isError,
} = useQuery({
queryKey: ["booking-handover-doc", bookingId],
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
enabled: opened && Boolean(bookingId),
staleTime: 0,
});
useEffect(() => {
if (!docBlob) {
setPdfUrl(null);
return;
}
const url = URL.createObjectURL(docBlob);
setPdfUrl(url);
return () => URL.revokeObjectURL(url);
}, [docBlob]);
const handoverMutation = useMutation(
api.bookings.downloadHandoverDocument.mutationOptions(),
);
const approve = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
try {
const signed = await handoverMutation.mutateAsync({
inventoryId: result.inventoryId,
});
downloadBlob(signed, `handover-${bookingId}.pdf`);
toast.success("Delivery approved and signed handover downloaded");
} catch {
toast.success("Delivery approved and handover signed");
toast.error("Signed handover document could not be downloaded");
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
}),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
onApproved?.();
onClose();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
onClose();
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
onClose();
navigate("/billing");
}
},
});
const busy = approve.isPending || handoverMutation.isPending;
return (
<Modal
opened={opened}
onClose={onClose}
title="Approve delivery — review & sign the handover"
size="xl"
centered
>
<Stack gap="md">
<Alert color="blue" variant="light" icon={<Info size={16} />}>
<Text size="sm">
Review the handover document below. Approving applies your saved signature
and confirms you received the goods.
</Text>
</Alert>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading handover document
</Text>
</Group>
) : isError || !pdfUrl ? (
<Text size="sm" c="red">
Could not load the handover document. It may not be generated yet.
</Text>
) : (
<iframe
title="Handover document"
src={pdfUrl}
style={{
width: "100%",
height: "60vh",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={busy}
disabled={isLoading || isError}
onClick={() => approve.mutate({ id: bookingId })}
>
Approve &amp; sign delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, type KeyboardEvent } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react";
import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core";
@@ -26,6 +26,15 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
/**
* Every quantity on this step is a non-negative magnitude. A native number
* input's `min` only constrains its stepper, so swallow the minus key before it
* can put a negative into the field at all.
*/
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
/**
* One numbered toggle per container unit in the line — tap units to mark how
* many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
@@ -379,6 +388,7 @@ export function Step5CargoDetails({
}}
id="cargoWeight"
type="number"
onKeyDown={blockNegative}
label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
leftSection={
@@ -435,6 +445,7 @@ export function Step5CargoDetails({
render={({ field: hq, fieldState }) => (
<TextInput
type="number"
onKeyDown={blockNegative}
size="sm"
label={
isPerItem
@@ -483,6 +494,7 @@ export function Step5CargoDetails({
render={({ field: rq, fieldState }) => (
<TextInput
type="number"
onKeyDown={blockNegative}
size="sm"
label={
isPerItem
@@ -630,6 +642,7 @@ export function Step5CargoDetails({
}}
onBlur={qtyField.onBlur}
type="number"
onKeyDown={blockNegative}
min={1}
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>

View File

@@ -32,6 +32,28 @@ const TZ = "Africa/Addis_Ababa";
/** Cards visible per carousel page. */
const PER_PAGE = 3;
/** Customer-facing labels for a booking-window phase / status. */
const WINDOW_PHASE_LABELS: Record<string, string> = {
PRE_WINDOW: "Opens soon",
OPEN: "Open now",
DOC_REVIEW: "Document review",
PAYMENT: "Payment due",
DONE: "Closed",
CLOSED_FOR_DAY: "Closed for the day",
};
/** Friendly label for a window phase/status, never the raw enum. */
function windowPhaseLabel(phase?: string | null): string {
if (!phase) return "—";
return (
WINDOW_PHASE_LABELS[phase] ??
phase
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
function fmtDay(iso: string): string {
return new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
@@ -60,7 +82,7 @@ function windowLabel(w: MyBookingWindow): string {
if (w.windowOpensAt) {
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
}
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
return windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus);
}
/**
@@ -163,7 +185,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
: windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
</Badge>
</Group>

View File

@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { PortalFileDropzone } from "@/components/contracts/PortalFileDropzone";
import { contractsService } from "@/services/contracts.service";
@@ -99,7 +100,7 @@ export function ContractClearanceWorkflowBanner({
<Text fz={12} c="dimmed">
Global Logistics has created your shipment booking
{clearance.linkedBookingStatus
? ` (${clearance.linkedBookingStatus.replace(/_/g, " ").toLowerCase()})`
? ` (${bookingStatusLabel(clearance.linkedBookingStatus).toLowerCase()})`
: ""}
. Track its progress from the booking.
</Text>

View File

@@ -85,6 +85,37 @@ const CLEARANCE_UPLOAD_STATUSES = [
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// Customer-facing labels for a shipment-request status (BOOKING_REQUEST_STATUSES).
const BOOKING_REQUEST_STATUS_LABELS: Record<string, string> = {
PENDING: "Pending review",
ACCEPTED: "Accepted",
REJECTED: "Rejected",
CANCELLED: "Cancelled",
};
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
const INVOICE_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
ISSUED: "Issued",
PENDING: "Due",
PARTIALLY_PAID: "Partially paid",
PAID: "Paid",
OVERDUE: "Overdue",
CANCELLED: "Cancelled",
REFUNDED: "Refunded",
EXPIRED: "Expired",
};
function invoiceStatusLabel(status: string): string {
return (
INVOICE_STATUS_LABELS[status] ??
status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase())
);
}
// Business-license document codes — surfaced as their own section so they stand
// out from the rest of the onboarding/profile set.
const BUSINESS_LICENSE_DOC_CODES = new Set([
@@ -667,7 +698,8 @@ export default function ContractDetailPage() {
variant="filled"
radius="sm"
>
{clearanceView.riskLevel}
{clearanceView.riskLevel.charAt(0) +
clearanceView.riskLevel.slice(1).toLowerCase()}
</Badge>
{clearanceView.riskAssignedAt ? (
<Text fz={12} c="dimmed">
@@ -1226,12 +1258,15 @@ export default function ContractDetailPage() {
? "teal"
: req.status === "REJECTED"
? "red"
: "yellow"
: req.status === "CANCELLED"
? "gray"
: "yellow"
}
>
{req.status === "ACCEPTED" && req.createdBookingId
? "Booking created"
: req.status}
: BOOKING_REQUEST_STATUS_LABELS[req.status] ??
req.status}
</Badge>
{req.createdBookingId ? (
<ChevronRight size={16} color={MUTED} />
@@ -1722,7 +1757,7 @@ function FinalInvoiceDueCard({
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoice.status}
{invoiceStatusLabel(invoice.status)}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -58,6 +58,15 @@ type ShipmentForm = ReturnType<
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
>;
/**
* Every quantity on this form is a non-negative magnitude. A native number
* input's `min` only constrains its stepper, so swallow the minus key before it
* can put a negative into the field at all.
*/
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
export default function NewShipmentPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -962,6 +971,7 @@ function CargoStep({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Quantity (tons)"
placeholder="e.g. 1200"
min={0}
@@ -979,6 +989,7 @@ function CargoStep({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Item count (if applicable)"
placeholder="e.g. 500"
min={0}
@@ -997,6 +1008,7 @@ function CargoStep({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Hazardous quantity"
min={0}
step={1}
@@ -1015,6 +1027,7 @@ function CargoStep({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Refrigerated quantity"
min={0}
step={1}
@@ -1093,6 +1106,7 @@ function ContainerLineEditor({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={1}
error={fieldState.error?.message}
@@ -1113,6 +1127,7 @@ function ContainerLineEditor({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
error={fieldState.error?.message}
@@ -1130,6 +1145,7 @@ function ContainerLineEditor({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
error={fieldState.error?.message}
@@ -1151,6 +1167,9 @@ function ContainerLineEditor({
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
@@ -1179,6 +1198,7 @@ function ContainerLineEditor({
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label={u === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}

View File

@@ -149,21 +149,35 @@ export const CONTRACT_STATUS_CONFIG: Record<
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", ...TONE.info },
CLEARANCE_READY: { label: "Clearance Ready", ...TONE.success },
OPERATION_REQUEST_PENDING: { label: "Operation Review", ...TONE.warning },
OPERATION_REQUESTED: { label: "Operation Requested", ...TONE.info },
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", ...TONE.warning },
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", ...TONE.warning },
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", ...TONE.warning },
READY_FOR_ASSIGNMENT: { label: "Assigning Wagon", ...TONE.info },
WAGON_ASSIGNED: { label: "Wagon Assigned", ...TONE.success },
SELECTED_FOR_BATCH: { label: "Awaiting Payment", ...TONE.warning },
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
PAYMENT_VERIFICATION_IN_PROGRESS: { label: "Verifying Payment", ...TONE.warning },
INVOICED: { label: "Invoiced", ...TONE.info },
PAID: { label: "Paid", ...TONE.success },
PENDING_CONSOLIDATION: { label: "Consolidating", ...TONE.info },
CONSOLIDATED: { label: "Consolidated", ...TONE.success },
TRUCK_ASSIGNED: { label: "Truck Assigned", ...TONE.success },
IN_TRANSIT: { label: "In Transit", ...TONE.info },
ARRIVED: { label: "Arrived", ...TONE.success },
DELIVERED: { label: "Delivered", ...TONE.success },
COMPLETED: { label: "Completed", ...TONE.success },
};
export function ContractStatusBadge({ status }: { status: string }) {
const cfg =
CONTRACT_STATUS_CONFIG[status] ?? {
label: status,
// Readable title-case fallback so an unmapped status never leaks the raw
// SCREAMING_SNAKE enum to the customer.
label: status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (m) => m.toUpperCase()),
color: MUTED,
bg: "#EEF2F6",
};

View File

@@ -286,7 +286,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
customsClearingAgent: "",
cargoType: "container",
enabledContainerSizes: ["20ft"],
enabledContainerSizes: [],
containerSizeCaps: {},
cargoTypePath: [],
cargoFreeText: "",

View File

@@ -26,8 +26,17 @@ export interface ShipmentValidationContext {
requiresDate?: boolean;
}
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
const containerUnitSchema = z.object({
containerNumber: z.string().min(1, "Container number is required."),
containerNumber: z
.string()
.min(1, "Container number is required.")
.refine(
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
"Enter a valid ISO container number (e.g. ABCD1234567).",
),
sealNumber: z.string().default(""),
vgmTons: z
.string()
@@ -68,6 +77,18 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}
if (ctx.isContainer) {
// Container numbers must be unique within this shipment (front-end only —
// the DB column is intentionally not unique). Duplicates block submit and
// price generation since both run through this same schema validation.
const numberCounts = new Map<string, number>();
data.containers.forEach((line) => {
line.units.forEach((u) => {
const key = u.containerNumber.trim().toUpperCase();
if (!key) return;
numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1);
});
});
data.containers.forEach((line, i) => {
const qty = Number(line.quantity || 0);
if (qty >= 1 && line.units.length < qty) {
@@ -77,9 +98,25 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
message: `Enter details for all ${qty} container(s).`,
});
}
line.units.forEach((u, j) => {
const key = u.containerNumber.trim().toUpperCase();
if (key && (numberCounts.get(key) ?? 0) > 1) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "units", j, "containerNumber"],
message: "Duplicate container number in this shipment.",
});
}
});
if (ctx.isHazardous) {
const h = Number(line.hazardousQuantity || 0);
if (h > qty) {
if (h < 0) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "hazardousQuantity"],
message: "Enter a valid hazardous quantity.",
});
} else if (h > qty) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "hazardousQuantity"],
@@ -89,7 +126,13 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}
if (ctx.isReefer) {
const r = Number(line.reeferQuantity || 0);
if (r > qty) {
if (r < 0) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "reeferQuantity"],
message: "Enter a valid refrigerated quantity.",
});
} else if (r > qty) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "reeferQuantity"],
@@ -99,10 +142,21 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}
});
} else {
const bulkCap =
ctx.unitOfMeasure === "PER_ITEM"
? Number(data.itemCount || 0)
: Number(data.cargoWeightTons || 0);
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";
const bulkCap = isPerItem
? Number(data.itemCount || 0)
: Number(data.cargoWeightTons || 0);
// The bulk cargo amount itself: a positive magnitude. Without this a
// negative (typed past the input's `min`) reaches the API unchecked.
const bulkPath = isPerItem ? "itemCount" : "cargoWeightTons";
if (Number.isNaN(bulkCap) || bulkCap <= 0) {
refineCtx.addIssue({
code: "custom",
path: [bulkPath],
message: "Enter a quantity greater than 0.",
});
}
const boundBulkPortion = (
on: boolean,

View File

@@ -264,6 +264,13 @@ export const api = {
bookingsService.downloadHandoverDocument(inventoryId),
),
downloadBookingHandoverDocument: endpoint<{ bookingId: string }, Blob>(
"bookings",
"downloadBookingHandoverDocument",
({ bookingId }) =>
bookingsService.downloadBookingHandoverDocument(bookingId),
),
create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking

View File

@@ -7,6 +7,26 @@ import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
export interface MileVehicleSummary {
plate: string | null;
code: string | null;
driverName: string | null;
containerNumber: string | null;
distanceKm: number | null;
}
export interface MileLegSummary {
status: string;
exactKm: number | null;
remainingPayment: number | null;
currency: string;
invoiced: boolean;
vehicles: MileVehicleSummary[];
}
export interface MileSummaryResponse {
firstMile: MileLegSummary | null;
lastMile: MileLegSummary | null;
}
export type CreateBookingPayload = Freight.CreateBookingDto;
export interface ContractView {
@@ -150,6 +170,10 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
mileSummary: async (id: string): Promise<MileSummaryResponse> => {
const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
return data.data;
},
assignCustomerTruck: async (
id: string,
payload: CustomerTruckAssignmentPayload,
@@ -174,6 +198,13 @@ export const bookingsService = {
);
return data;
},
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;

View File

@@ -338,7 +338,14 @@ export class PaymentsService {
};
return this.prisma.paymentIntent.upsert({
where: { bookingId },
update: data,
// amountMinor/currency are refreshed on update too: a cross-currency method switch
// (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
// must reflect the currency the new provider actually charges — not the first one's.
update: {
...data,
amountMinor: snapshot.amountMinor,
currency: snapshot.currency,
},
create: {
bookingId,
amountMinor: snapshot.amountMinor,

View File

@@ -29,12 +29,12 @@ export default function LoginPage() {
const [emailFocused, setEmailFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const [view, setView] = useState<'login' | 'forgot'>('login');
const [forgotEmail, setForgotEmail] = useState('');
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState('');
const [forgotSent, setForgotSent] = useState(false);
const [forgotFocused, setForgotFocused] = useState(false);
const [view, setView] = useState<'login' | 'forgot'>('login');
const [forgotIdentifier, setForgotIdentifier] = useState('');
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState('');
const [forgotSent, setForgotSent] = useState(false);
const [forgotFocused, setForgotFocused] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
@@ -66,13 +66,13 @@ export default function LoginPage() {
setForgotLoading(true);
setForgotError('');
try {
await iamAuthApi.forgotPassword(forgotEmail);
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
setForgotSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setForgotError(
msg === 'user_not_found'
? 'No account found with that email address.'
? 'No account found with that email or phone number.'
: msg || 'Failed to send the reset link. Please try again.'
);
} finally {
@@ -209,7 +209,7 @@ export default function LoginPage() {
<div className="flex justify-end mt-2">
<button
type="button"
onClick={() => { setView('forgot'); setForgotEmail(email); setError(''); }}
onClick={() => { setView('forgot'); setForgotIdentifier(email); setError(''); }}
className="text-xs font-medium text-[rgb(20,113,76)] hover:underline"
>
Forgot password?
@@ -260,7 +260,7 @@ export default function LoginPage() {
Reset your password
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Enter your email address and we&apos;ll send a reset link to the phone number on your account.
Enter your email or phone number and we&apos;ll send a reset link to the phone number on your account.
</p>
</div>
@@ -295,10 +295,10 @@ export default function LoginPage() {
)}
<form onSubmit={handleForgotSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
{/* Email field */}
{/* Email or phone field */}
<div>
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Email address
Email or phone number
</label>
<div className={`relative rounded-xl transition-all duration-200 ${
forgotFocused
@@ -306,15 +306,15 @@ export default function LoginPage() {
: 'ring-1 ring-gray-200 dark:ring-gray-800'
}`}>
<input
type="email"
value={forgotEmail}
onChange={(e) => { setForgotEmail(e.target.value); setForgotError(''); }}
type="text"
value={forgotIdentifier}
onChange={(e) => { setForgotIdentifier(e.target.value); setForgotError(''); }}
onFocus={() => setForgotFocused(true)}
onBlur={() => setForgotFocused(false)}
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
placeholder="name@edr.com"
placeholder="name@edr.com or +251..."
required
autoComplete="email"
autoComplete="username"
/>
</div>
</div>
@@ -322,9 +322,9 @@ export default function LoginPage() {
{/* Submit */}
<button
type="submit"
disabled={forgotLoading || !forgotEmail}
disabled={forgotLoading || !forgotIdentifier.trim()}
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: forgotLoading || !forgotEmail
style={{ background: forgotLoading || !forgotIdentifier.trim()
? 'rgb(20,113,76)'
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
}}

View File

@@ -4,8 +4,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
export const iamAuthApi = {
forgotPassword: (email: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
// `identifier` can be an email address or a phone number — the IAM accepts
// either in the `email` field of the forgot-password body.
forgotPassword: (identifier: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email: identifier }),
resetPassword: (data: {
userId: string;

View File

@@ -6,7 +6,7 @@ import { Train, MailCheck, ArrowLeft } from 'lucide-react';
import { iamAuthApi } from '@/lib/api/auth';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [identifier, setIdentifier] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [sent, setSent] = useState(false);
@@ -16,13 +16,13 @@ export default function ForgotPasswordPage() {
setLoading(true);
setError('');
try {
await iamAuthApi.forgotPassword(email);
await iamAuthApi.forgotPassword(identifier.trim());
setSent(true);
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '';
setError(
msg === 'user_not_found'
? 'No account found with that email address.'
? 'No account found with that email or phone number.'
: msg || 'Failed to send the reset link. Please try again.'
);
} finally {
@@ -41,7 +41,7 @@ export default function ForgotPasswordPage() {
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Reset your password</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Enter your email and we&apos;ll send a reset link to the phone number on your account.
Enter your email or phone number and we&apos;ll send a reset link to the phone number on your account.
</p>
</div>
@@ -69,19 +69,19 @@ export default function ForgotPasswordPage() {
)}
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email or phone number</label>
<input
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
type="text"
value={identifier}
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
className="input-field"
placeholder="your@email.com"
autoComplete="email"
placeholder="your@email.com or +251..."
autoComplete="username"
required
/>
</div>
<button type="submit" className="btn-primary w-full" disabled={loading || !email}>
<button type="submit" className="btn-primary w-full" disabled={loading || !identifier.trim()}>
{loading ? 'Sending...' : 'Send reset link'}
</button>
</form>

View File

@@ -8,8 +8,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
// current password is wrong on change-password, and OTP failures must surface
// as inline errors, not a logout.
export const iamAuthApi = {
forgotPassword: (email: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email }),
// `identifier` can be an email address or a phone number — the IAM accepts
// either in the `email` field of the forgot-password body.
forgotPassword: (identifier: string) =>
axios.post(`${API_URL}/v1/auth/forgot-password`, { email: identifier }),
// Re-sends the registration verification code for a still-pending account.
resendRegistrationCode: (data: { email: string; phoneNumber: string }) =>

View File

@@ -77,8 +77,27 @@ export class IntentsService {
request.referenceId,
);
if (existing) {
const reusable = await this.reuseOrRetire(existing);
if (reusable) return this.toSnapshot(reusable);
// Payer switched method (e.g. Waafi → Telebirr) on an uncharged session: retire the
// open intent and fall through to open a fresh one for the new provider. Only safe
// while REQUIRES_ACTION — PROCESSING/SUCCEEDED intents may have money in flight, so
// they keep the reuse path (the switch is silently refused until they resolve).
const switchingProvider =
existing.provider !== request.provider &&
existing.status === ProviderPaymentStatus.REQUIRES_ACTION;
if (switchingProvider) {
await this.intentsRepository.update(existing.id, {
status: ProviderPaymentStatus.CANCELLED,
failureCode: "METHOD_CHANGED",
failureMessage: `Payer switched from ${existing.provider} to ${request.provider}`,
});
this.logger.log(
`intent ${existing.id} retired (METHOD_CHANGED ${existing.provider}${request.provider}) for ` +
`${request.service}/${request.referenceType}/${request.referenceId}`,
);
} else {
const reusable = await this.reuseOrRetire(existing);
if (reusable) return this.toSnapshot(reusable);
}
}
const provider = this.providers.get(request.provider);

View File

@@ -45,22 +45,16 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
return {
CAC_BASE_URL: this.baseUrl || "(empty)",
CAC_USERNAME: this.username || "(empty)",
CAC_PASSWORD: this.mask(this.password),
CAC_APP_KEY: this.mask(this.appKey),
CAC_API_KEY: this.mask(this.apiKey),
CAC_PASSWORD: this.password,
CAC_APP_KEY: this.appKey,
CAC_API_KEY: this.apiKey,
CAC_COMPANY_SERVICES_ID: this.companyServicesId,
CAC_CURRENCY: this.defaultCurrency,
CAC_TOKEN_TTL_MS: this.tokenTtlMs,
CAC_OTP_EXPIRY_MS: this.otpExpiryMs,
};
}
/** Mask a secret to `set(len=N,…abcd)` / `(empty)` so presence & length are visible but not the value. */
private mask(value: string): string {
if (!value) return "(empty)";
const tail = value.length > 4 ? value.slice(-4) : "";
return `set(len=${value.length},…${tail})`;
}
async initiate(
input: ProviderInitiationInput,