Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-09 08:51:50 +00:00
181 changed files with 7344 additions and 1613 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,114 @@
-- ============================================================================
-- Production DB drift check + fix for the batch/window flow.
--
-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which
-- queries freight.invoices.payments (a jsonb ledger added by migration
-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on
-- production (snapshot/restore drift — the migration can read as "applied" in
-- freight.migrations while the DDL never took effect), every reserve() throws
-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass,
-- and you see exactly:
-- * only ONE booking gets a pay window (the loop dies after the first reserve
-- whose invoice sync throws), and
-- * reservations never expire cleanly (the settle path hits the same query).
--
-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2
-- (idempotent, additive — safe to run even if partially applied).
-- ============================================================================
-- ---------------------------------------------------------------------------
-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing,
-- production has the drift and STEP 2 is required.
-- ---------------------------------------------------------------------------
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name = 'invoices'
AND column_name IN (
'payments', 'subtotal_amount', 'tax_amount',
'paid_amount', 'balance_amount', 'paid_at'
)
ORDER BY column_name;
-- Also confirm the enum has the partial-payment statuses:
SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status;
-- Expect ISSUED and PARTIALLY_PAID to be present.
-- ---------------------------------------------------------------------------
-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns.
-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so
-- re-running is safe. Wrapped so the enum additions (which cannot run inside a
-- transaction block with immediate use) are applied first, then the columns.
-- ---------------------------------------------------------------------------
-- Enum values (no-op if they already exist).
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';
-- Money-tracking + payments ledger columns.
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
-- Backfill derived money fields for existing rows (only rows not already set).
UPDATE freight.invoices
SET subtotal_amount = total_amount,
balance_amount = total_amount
WHERE subtotal_amount = 0 AND balance_amount = 0;
UPDATE freight.invoices
SET paid_amount = total_amount,
balance_amount = 0,
paid_at = COALESCE(paid_at, updated_at)
WHERE status = 'PAID' AND paid_amount = 0;
-- ---------------------------------------------------------------------------
-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should
-- now be present. After this, deploy the freight_feature/usermanagement branch
-- and the batch will reserve ALL fitting bookings + expire non-payers + top up.
-- ---------------------------------------------------------------------------
-- ============================================================================
-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid
-- invoices.payments can hide OTHER columns the batch flow selects. reserve()
-- and settleReserved() load the FULL Booking entity, so ANY missing booking
-- column throws mid-loop (e.g. we already hit
-- `column Booking.consolidation_resume_status does not exist`). This lists every
-- booking column the entity expects that is MISSING from production — expect
-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied.
-- ============================================================================
WITH expected(col) AS (
SELECT unnest(ARRAY[
'reference','customer_id','company_id','company_profile_id','is_government',
'government_institution','train_id','status','contract_id','contract_route_id',
'booking_type','contract_kind','created_by_role','created_by_user_id',
'scheduled_date','estimated_shipment_date','expires_at','total_amount',
'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason',
'contract_validity_days','contract_valid_from','contract_valid_until',
'payment_status','contract_type','service_type_id','customs_clearing_enabled',
'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id',
'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id',
'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity',
'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at',
'pricing_breakdown','locked_at','priority_score','consolidation_partner_id',
'consolidation_resume_status','wagons_required','scheduling_status',
'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id',
'loaded_at','arrived_at','payment_deadline','selected_for_batch_at',
'gl_station_yard_id','clearance_current_phase','duty_required',
'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason',
'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at'
])
)
SELECT e.col AS missing_booking_column
FROM expected e
LEFT JOIN information_schema.columns c
ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col
WHERE c.column_name IS NULL
ORDER BY e.col;
-- If any rows come back, tell me which columns — I'll give you the exact
-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent).

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds locomotives.overage_tolerance_tons / overage_tolerance_meters: an
* optional per-locomotive deviation allowance above max_pull_weight_tons /
* max_train_length_meters. Nullable, defaults to no tolerance so existing
* strict-cap behavior is unchanged until staff sets a value.
*/
export class AddLocomotiveOverageTolerance2040000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS overage_tolerance_tons NUMERIC(10, 3),
ADD COLUMN IF NOT EXISTS overage_tolerance_meters NUMERIC(10, 3);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS overage_tolerance_tons,
DROP COLUMN IF EXISTS overage_tolerance_meters;
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds customer_truck_containers.loaded_at so an assignment (customer planning
* which containers ride which truck) is distinct from the container actually
* being loaded. Stage LOADED now requires loaded_at; customer assignment alone
* keeps the container at its prior stage (RECEIVED/GRN) with its planned truck
* shown. Backfills containers on already-departed trucks (they left loaded).
*/
export class AddCustomerTruckContainerLoadedAt2050000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_containers
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
`);
await queryRunner.query(`
UPDATE freight.customer_truck_containers ctc
SET loaded_at = a.departed_at
FROM freight.customer_truck_assignments a
WHERE a.id = ctc.assignment_id
AND a.departed_at IS NOT NULL
AND ctc.deleted_at IS NULL
AND ctc.loaded_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drops wagon_types.max_wagons_per_train. Train wagon-count caps are already
* derived from locomotive + wagon length/weight (train-capacity.util.ts) and
* the global train_scheduling_global_rules row — this per-wagon-type override
* was unused by that derivation and only added a confusing "Max / train"
* field to the wagon type form.
*/
export class DropWagonTypeMaxWagonsPerTrain2050000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
DROP COLUMN IF EXISTS max_wagons_per_train;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
ADD COLUMN IF NOT EXISTS max_wagons_per_train INT;
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Upserts the 10 real EDR wagon types (code, name, capacity, length, tare
* weight) by code. Overwrites any existing row with the same code so
* previously-seeded demo values (e.g. NW5/PW2/CW3 from demo-bookings.seeder)
* are replaced with the real spec.
*/
export class SeedRailWagonTypes2060000000000 implements MigrationInterface {
private readonly wagonTypes = [
{ code: 'NW7', name: 'Double deck sedan wagon', capacityTons: 22, lengthMeters: 26.066, tareWeightTons: 37.1 },
{ code: 'NW5', name: 'Flat wagon', capacityTons: 70, lengthMeters: 13.966, tareWeightTons: 22.4 },
{ code: 'PW2', name: 'Box wagon', capacityTons: 70, lengthMeters: 17.066, tareWeightTons: 25.2 },
{ code: 'GW2', name: 'Tank wagon', capacityTons: 70, lengthMeters: 12.228, tareWeightTons: 23 },
{ code: 'CW4', name: 'Gondola covered wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 24.8 },
{ code: 'CW3', name: 'Gondola open wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 23.4 },
{ code: 'KW2', name: 'Hopper covered wagon', capacityTons: 69, lengthMeters: 16.466, tareWeightTons: 25.2 },
{ code: 'KW3', name: 'Hopper wagon open', capacityTons: 70, lengthMeters: 14.4, tareWeightTons: 24 },
{ code: 'NW6', name: 'Flat wagon (long)', capacityTons: 70, lengthMeters: 18.56, tareWeightTons: 25.3 },
{ code: 'BW1', name: 'Refrigerated wagon', capacityTons: 38, lengthMeters: 21.996, tareWeightTons: 32.1 },
];
public async up(queryRunner: QueryRunner): Promise<void> {
for (const wt of this.wagonTypes) {
await queryRunner.query(
`
INSERT INTO freight.wagon_types (code, name, capacity_tons, length_meters, tare_weight_tons, is_active)
VALUES ($1, $2, $3, $4, $5, true)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
tare_weight_tons = EXCLUDED.tare_weight_tons;
`,
[wt.code, wt.name, wt.capacityTons, wt.lengthMeters, wt.tareWeightTons],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.wagon_types WHERE code = ANY($1);`,
[this.wagonTypes.map((wt) => wt.code)],
);
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Tare weight becomes mandatory on a wagon type.
*
* The locomotive's pull limit is a GROSS limit — it drags the wagon as well as
* the cargo — so capacity math cannot run without a tare. A NULL tare silently
* read as zero and let trains overbook by the tare fraction (~27% on a PW2
* consist), so the column is now NOT NULL.
*
* Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which
* upserts the ten real EDR types). Backfill those by code first, and give any
* remaining custom/demo type the NW5 flat-wagon tare rather than fail the
* migration — a wrong-but-plausible tare is recoverable in the admin UI; a
* blocked deploy is not.
*/
export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface {
private readonly tareByCode: Array<[string, number]> = [
['NW7', 37.1],
['NW5', 22.4],
['PW2', 25.2],
['GW2', 23],
['CW4', 24.8],
['CW3', 23.4],
['KW2', 25.2],
['KW3', 24],
['NW6', 25.3],
['BW1', 32.1],
];
/** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */
private readonly fallbackTareTons = 22.4;
public async up(queryRunner: QueryRunner): Promise<void> {
for (const [code, tareWeightTons] of this.tareByCode) {
await queryRunner.query(
`UPDATE freight.wagon_types
SET tare_weight_tons = $2
WHERE code = $1 AND tare_weight_tons IS NULL;`,
[code, tareWeightTons],
);
}
await queryRunner.query(
`UPDATE freight.wagon_types
SET tare_weight_tons = $1
WHERE tare_weight_tons IS NULL;`,
[this.fallbackTareTons],
);
await queryRunner.query(
`ALTER TABLE freight.wagon_types
ALTER COLUMN tare_weight_tons SET NOT NULL;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.wagon_types
ALTER COLUMN tare_weight_tons DROP NOT NULL;`,
);
}
}

View File

@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagon spec belongs to the wagon TYPE, not to each physical wagon.
*
* `wagons.tare_weight` and `wagons.max_payload_weight` duplicated
* `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows,
* with nothing keeping them in step. They had drifted completely: every wagon
* disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a
* third disagreed on payload (NW5 wagons claiming 22T70T against a flat 70T).
* None of those numbers came from the railway.
*
* Nothing reads them for capacity — that math resolves tare and capacity through
* `wagon_type_id` — so dropping them removes a source of fiction rather than a
* source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is
* always reachable.
*
* A wagon re-tared after repair would need a nullable override column on
* `wagons` falling back to the type; deliberately not added, since no such
* per-wagon value exists today.
*/
export class DropWagonSpecColumns2080000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS tare_weight,
DROP COLUMN IF EXISTS max_payload_weight;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Re-add nullable, backfill from the owning type, then restore NOT NULL.
// The pre-drop values were drifted seed data and are not recoverable — the
// type's spec is what they should always have held.
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS tare_weight NUMERIC(10, 2),
ADD COLUMN IF NOT EXISTS max_payload_weight NUMERIC(10, 2);
`);
await queryRunner.query(`
UPDATE freight.wagons w
SET tare_weight = t.tare_weight_tons,
max_payload_weight = t.capacity_tons
FROM freight.wagon_types t
WHERE t.id = w.wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.wagons
ALTER COLUMN tare_weight SET NOT NULL,
ALTER COLUMN max_payload_weight SET NOT NULL;
`);
}
}

View File

@@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) {
}
function makeEvents() {
return { emit: jest.fn() };
// BillingService emits via both emit() and emitAsync() (the post-commit async
// listener path) — the mock must provide both.
return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
}
function generateInput(overrides: Record<string, unknown> = {}) {
@@ -162,7 +164,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
],
},
);
expect(events.emit).toHaveBeenCalledWith(
expect(events.emitAsync).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({
invoiceId: "inv-1",
@@ -197,6 +199,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
});
@@ -257,6 +260,7 @@ describe("BillingService.recordPayment", () => {
}),
);
expect(events.emit).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
@@ -268,7 +272,7 @@ describe("BillingService.recordPayment", () => {
expect(updated.balanceAmount).toBe(0);
expect(updated.paidAt).toBeInstanceOf(Date);
expect(mg.update).toHaveBeenCalled();
expect(events.emit).toHaveBeenCalledWith(
expect(events.emitAsync).toHaveBeenCalledWith(
"warehouse.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
);
@@ -296,3 +300,80 @@ describe("BillingService.recordPayment", () => {
expect(mg.update).not.toHaveBeenCalled();
});
});
/**
* Regression: `expirePayable` (batch settle path, called when a payment window
* lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
* The bug passed `dataSource.manager` (the non-transactional default) into the
* transition, so runTransition skipped opening a transaction and the lock threw
* `An open transaction is required for pessimistic lock` — aborting the whole
* settle pass (the "settle/reserve one booking at a time" symptom). The locked
* write MUST run inside dataSource.transaction.
*/
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
};
const build = (lookupResult: Record<string, unknown> | null) => {
const defaultManager = {
findOne: jest.fn().mockResolvedValue(lookupResult),
update: jest.fn().mockResolvedValue(undefined),
};
const txManager = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const transaction = jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
const events = makeEvents();
const service = new BillingService(
{ manager: defaultManager, transaction } as never,
{} as never,
{} as never,
events as never,
{} as never,
{} as never,
{} as never,
);
return { service, defaultManager, txManager, transaction };
};
it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
const { service, transaction, txManager, defaultManager } = build(openInvoice);
await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
expect(transaction).toHaveBeenCalledTimes(1);
expect(txManager.findOne).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
);
expect(txManager.update).toHaveBeenCalled();
// The default manager only does the initial lock-free lookup, never a locked read.
for (const call of defaultManager.findOne.mock.calls) {
expect(call[1]).not.toHaveProperty("lock");
}
});
it("is a no-op (no transaction) when there is no open invoice", async () => {
const { service, transaction } = build(null);
const result = await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
expect(result).toBeNull();
expect(transaction).not.toHaveBeenCalled();
});
});

View File

@@ -825,6 +825,13 @@ export class BillingService {
type?: string,
manager?: EntityManager,
): Promise<Invoice | null> {
// Lookup can use the default manager (no lock). But the pessimistic-lock write
// inside `transition` NEEDS an open transaction: pass the caller's `manager`
// through untouched (undefined when there is no caller txn) so `runTransition`
// opens its own. Passing `this.dataSource.manager` here made `runTransition`
// treat it as an already-open transaction and skip wrapping — the lock then
// threw `An open transaction is required for pessimistic lock`, aborting the
// whole settle pass (the "reservations settle/reserve one at a time" symptom).
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
@@ -842,7 +849,7 @@ export class BillingService {
Freight.InvoiceStatus.Expired,
"expired",
{},
mg,
manager,
);
}

View File

@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { FirstMileService } from '../first-mile/first-mile.service';
import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -81,6 +83,60 @@ import {
hasFreightPermission,
} from "../../common/freight-permission.util";
interface MileVehicleSummary {
plate: string | null;
code: string | null;
driverName: string | null;
containerNumber: string | null;
distanceKm: number | null;
}
interface MileLegSummary {
status: string;
exactKm: number | null;
remainingPayment: number | null;
currency: string;
invoiced: boolean;
vehicles: MileVehicleSummary[];
}
/** Trim a first/last-mile record down to a customer-safe operational summary. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
if (!rec) return null;
const num = (v: unknown) => (v == null ? null : Number(v));
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
const currency =
rec.vehicle?.currency ??
assignments[0]?.vehicle?.currency ??
rec.booking?.paymentCurrency ??
'ETB';
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
plate: a.vehicle?.plateNumber ?? null,
code: a.vehicle?.code ?? null,
driverName: a.vehicle?.assignedDriverName ?? null,
containerNumber: a.containerNumber ?? null,
distanceKm: num(a.distanceKm),
}));
if (!vehicles.length && rec.vehicle) {
vehicles.push({
plate: rec.vehicle.plateNumber ?? null,
code: rec.vehicle.code ?? null,
driverName: rec.vehicle.assignedDriverName ?? null,
containerNumber: null,
distanceKm: num(rec.exactKm),
});
}
return {
status: rec.status ?? '',
exactKm: num(rec.exactKm),
remainingPayment: num(rec.remainingPayment),
currency,
invoiced: Boolean(rec.invoice),
vehicles,
};
}
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
@@ -94,6 +150,8 @@ export class BookingsController {
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
) {}
@Post()
@@ -290,6 +348,33 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
})
async mileSummary(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// Customers may only see their own booking's mile summary.
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const [first, last] = await Promise.all([
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
]);
return {
firstMile: summarizeMileLeg(first.data[0]),
lastMile: summarizeMileLeg(last.data[0]),
};
}
@Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(

View File

@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentController } from './booking-payment.controller';
@@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),
forwardRef(() => LastMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
forwardRef(() => ContractsModule),

View File

@@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
if (!statuses.length) return [];
return this.repository.find({
where: { status: In(statuses) },
relations: {
company: true,
originYard: true,
destinationYard: true,
},
order: { createdAt: 'DESC' },
});
}
@@ -993,6 +998,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
@@ -1023,6 +1029,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id = :originYardId', { originYardId })
.andWhere('booking.destination_yard_id = :destinationYardId', {
@@ -1061,6 +1068,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
@@ -1125,6 +1133,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
@@ -1138,6 +1147,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.getMany();

View File

@@ -1424,6 +1424,20 @@ export class BookingsService {
schedule?.status ?? null;
}
// 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],
);
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
Boolean(pendingHandover);
return booking;
}

View File

@@ -57,6 +57,15 @@ export class CustomerTruckService {
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
// Never assign more trucks than the booking has containers.
const existingTrucks = await this.dataSource
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
if (existingTrucks + 1 > bookingNumbers.length) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
);
}
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
@@ -303,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) {
@@ -324,12 +340,16 @@ export class CustomerTruckService {
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
// Operator loading the truck: stamp loaded_at so these containers move to
// the LOADED stage (customer assignment alone leaves loaded_at null).
const loadedAt = new Date();
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
loadedAt,
}),
),
);

View File

@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
/**
* When the container was actually loaded onto the truck by the operator.
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
* this to be set, so customer assignment alone does not mark a container loaded.
*/
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
}

View File

@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED';
/**
* Risk assignment is gated on the T1 being closed (catalog order
* T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit.
*/
function makeService(t1Status: Status | 'MISSING') {
const rows = new Map<string, ClearanceMilestone>();
if (t1Status !== 'MISSING') {
rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone);
}
const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone;
rows.set('RISK_ASSIGNED', risk);
const repo = {
findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) =>
Promise.resolve(rows.get(where.milestoneCode) ?? null),
),
save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)),
};
const dataSource = { getRepository: () => repo } as unknown as DataSource;
return { service: new ClearanceMilestoneService(dataSource), repo, risk };
}
describe('ClearanceMilestoneService.assignRisk', () => {
it('rejects the assignment while the T1 is still open', async () => {
const { service, repo } = makeService('PENDING');
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.save).not.toHaveBeenCalled();
});
it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => {
const { service, repo } = makeService('MISSING');
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.save).not.toHaveBeenCalled();
});
it('assigns the risk level once the T1 is closed', async () => {
const { service, risk } = makeService('COMPLETED');
const saved = await service.assignRisk('b-1', 'RED', 'user-1');
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('RED');
expect(risk.triggeredByUserId).toBe('user-1');
});
it('assigns the risk level when the T1 step was skipped', async () => {
const { service } = makeService('SKIPPED');
const saved = await service.assignRisk('b-1', 'YELLOW');
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('YELLOW');
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
@@ -181,6 +181,10 @@ export class ClearanceMilestoneService {
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
* milestone metadata so the timeline shows it.
*
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
* catalog order T1_CLOSED → RISK_ASSIGNED.
*/
async assignRisk(
bookingId: string,
@@ -188,9 +192,22 @@ export class ClearanceMilestoneService {
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
await this.assertT1Closed(bookingId);
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
}
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
private async assertT1Closed(bookingId: string): Promise<void> {
const t1 = await this.repo.findOne({
where: { bookingId, milestoneCode: 'T1_CLOSED' },
});
if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') {
throw new BadRequestException(
'The T1 must be closed before a customs risk level can be assigned.',
);
}
}
/**
* Advise duty & tax (amount + declaration serial) and complete the
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the

View File

@@ -122,6 +122,15 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). DOMESTIC has no border, so no gate.
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
@@ -140,9 +149,10 @@ export class ContractBookingService {
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms && !isIntercity) {
// export: within exportBookingLeadHours of departure. Bookings that enter the
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
// are scheduled later, so they are not gated here.
if (!generalCustoms && !generalSelfClear && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
@@ -174,7 +184,10 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
status:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -259,9 +272,10 @@ export class ContractBookingService {
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
const intendedStatus = generalCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
if (
withContainers &&
freightType === 'CONTAINER' &&

View File

@@ -632,16 +632,15 @@ export class ContractTransitionService {
contract.customsClearingEnabled ?? false,
);
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
// level: there is no contract clearance cycle. The contract just becomes
// active; the customer then files shipment requests and GL books + clears
// each one. ONE_TIME customs and Path A self-clearance keep the contract
// cycle below.
const isGeneralCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
// both paths. Customs (Path B): the customer files shipment requests, GL
// books each one and the booking carries its own clearance. Self-clearance
// (Path A): the customer books, then uploads the clearance docs on that
// booking for Operations to review. Only ONE_TIME contracts keep the
// contract-level cycle below.
const isGeneral = contract.contractKind === 'GENERAL';
if (clearanceCode && !isGeneralCustoms) {
if (clearanceCode && !isGeneral) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
@@ -652,8 +651,8 @@ export class ContractTransitionService {
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
// clears per booking). Ready for shipment requests / direct booking.
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
// (which clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';

View File

@@ -9,14 +9,19 @@ import {
IsOptional,
IsString,
IsUUID,
Matches,
Min,
ValidateNested,
} from 'class-validator';
/** One physical container under a booking line — entered at booking time. */
export class CreateContainerUnitDto {
@ApiProperty()
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
})
containerNumber!: string;
@ApiPropertyOptional()

View File

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

@@ -0,0 +1,37 @@
import { DataSource } from 'typeorm';
import { NotificationsService } from './notifications.service';
/**
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
* company's phone/email and sends the message over both channels, swallowing
* per-channel failures so a missing provider never breaks the caller's flow.
*/
export async function sendCompanyChannels(
dataSource: DataSource,
notifications: NotificationsService,
companyId: string,
message: string,
): Promise<void> {
const [contact]: Array<{ phone: string | null; email: string | null }> =
await dataSource.query(
`SELECT COALESCE(phone, etrade_phone) AS phone, email
FROM freight.companies
WHERE id = $1 AND deleted_at IS NULL`,
[companyId],
);
if (contact?.phone) {
try {
await notifications.directSend('sms', contact.phone, message);
} catch {
/* best-effort: SMS provider unavailable */
}
}
if (contact?.email) {
try {
await notifications.directSend('email', contact.email, message);
} catch {
/* best-effort: email provider unavailable */
}
}
}

View File

@@ -39,12 +39,17 @@ export class Route extends BaseEntity {
milestones?: RouteMilestone[];
}
/**
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
* machine identifier and is only a fallback for a yard missing one.
*/
export function formatRouteLabel(route: {
originYard?: { code?: string; name?: string } | null;
destinationYard?: { code?: string; name?: string } | null;
originYard?: { code?: string; label?: string } | null;
destinationYard?: { code?: string; label?: string } | null;
}): string {
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
return `${origin}${dest}`;
}

View File

@@ -22,7 +22,9 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
return this.repo(manager).findOne({
where: { id },
relations: {
route: true,
// Yards carry the route's display name; without them formatRouteLabel
// degrades to the literal "Origin → Destination".
route: { originYard: true, destinationYard: true },
trainSet: {
locomotive: true,
locomotives: { locomotive: true },

View File

@@ -24,3 +24,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', () => {
@@ -510,4 +630,104 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
});
});
describe('settleDueReservations — expire then promote the waiting list', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const trainId = 'train-a';
// 14m / 70t default wagon → two wagon slots on this locomotive.
const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 };
const booking = (id: string, priority: number, overrides = {}): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
originYardId,
destinationYardId,
trainScheduleId: trainId,
...overrides,
}) as unknown as Booking;
beforeEach(() => {
const scheduleRow = {
id: trainId,
maxWagons: 2,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
direction: 'IMPORT',
trainSetId: `set-${trainId}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
originStationId: originYardId,
destinationStationId: destinationYardId,
};
trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow);
trainSchedulesRepository.findById.mockResolvedValue({
id: trainId,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
scheduledDepartureDate: scheduleRow.scheduledDepartureDate,
originStationId: originYardId,
destinationStationId: destinationYardId,
});
});
it('promotes a waiting booking into the wagons an expired reservation frees', async () => {
// One reservation whose pay window lapsed, and one booking on the waiting list.
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one
.mockResolvedValue([]); // afterwards nothing is reserved
// The day pool the top-up draws from: only the waiting booking is eligible.
bookingsRepository.findBatchPoolByCorridorDay
.mockResolvedValueOnce([waiting])
.mockResolvedValue([]);
await service.settleDueReservations(trainId);
// The lapsed reservation expired...
expect(notifier.expired).toHaveBeenCalledTimes(1);
expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed');
// ...and the waiting booking was promoted in the SAME settle, not next cycle.
expect(notifier.payNow).toHaveBeenCalledTimes(1);
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
});
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
// Both callers read the reservation; the lock must stop the second from
// acting on rows the first already expired. (The PAYMENT transition and the
// tick's overdue backstop do exactly this, in the same second.)
let reads = 0;
bookingsRepository.findReservedForSchedule.mockImplementation(() => {
reads += 1;
return Promise.resolve(reads === 1 ? [lapsed] : []);
});
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
await Promise.all([
service.settleDueReservations(trainId),
service.settleDueReservations(trainId),
]);
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => {
expireUnacceptedForRouteDay: jest.Mock;
settleDueReservations: jest.Mock;
isScheduleFull: jest.Mock;
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => {
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
settleDueReservations: jest.fn().mockResolvedValue(undefined),
isScheduleFull: jest.fn().mockResolvedValue(false),
// No reservation is mid-pay-window by default, so the cycle concludes.
hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => {
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
});
it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => {
// `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each
// booking's own deadline milliseconds later. So the phase deadline always passes
// first, and concluding here would kill customers who still had time to pay — and
// leave no cycle for the waiting-list top-up to run in.
batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
});
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
expect(advanced).toBe(true);
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
// Still PAYMENT — the cycle was NOT concluded and the window did not reopen.
expect(s.windowPhase).toBe('PAYMENT');
expect(batch.isScheduleFull).not.toHaveBeenCalled();
});
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
batch.isScheduleFull.mockResolvedValue(true);
const s = baseSchedule({ windowPhase: 'PAYMENT' });

View File

@@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit {
now,
);
} catch (err) {
// This is THE line to watch when a window freezes mid-phase: the tick
// catches a throw here per-schedule and moves on, so a schedule whose
// transition keeps throwing stays stuck in its phase forever. Log the
// phase + stack so the failing step is obvious.
this.logger.error(
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
`[WINDOW] transition FAILED for schedule ${schedule.id} ` +
`(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` +
`${(err as Error).message}`,
);
this.logger.error(
`[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`,
);
}
}
@@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit {
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
`[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
`(cycle ${schedule.bookingCycleNo})`,
);
return true;
}
@@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'CLOSED';
}
this.logger.log(
`Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`,
`[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` +
`review until ${docReviewEndsAt.toISOString()}`,
);
return true;
}
@@ -277,7 +288,8 @@ export class BookingWindowService implements OnModuleInit {
// is handled inside the fill (all fit → all reserved → all notified).
await this.bookingBatchService.processRouteDay(routeDay);
this.logger.log(
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
`until ${paymentPhaseEndsAt.toISOString()}`,
);
return true;
}
@@ -287,7 +299,43 @@ export class BookingWindowService implements OnModuleInit {
schedule.paymentPhaseEndsAt != null &&
now >= schedule.paymentPhaseEndsAt
) {
this.logger.log(
`[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` +
`(allocate paid / expire unpaid) then concluding the cycle`,
);
await this.bookingBatchService.settleDueReservations(schedule.id);
// The settle expires unpaid reservations and promotes the waiting list into
// the wagons they free. Those promoted customers get a fresh pay window, and
// `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to
// cover it. Concluding here on the STALE in-memory timestamp would end the
// cycle the top-up just extended and expire them before they could pay — so
// re-read, and stay in PAYMENT if the deadline moved.
const settled = await this.trainSchedulesRepository.findById(schedule.id);
if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) {
schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt;
this.logger.log(
`[WINDOW] ${schedule.id} PAYMENT extended to ` +
`${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` +
`promoted into the freed wagons; not concluding this cycle yet`,
);
return true;
}
// `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own
// deadline is set milliseconds later, per booking, so the phase always expires
// a fraction before the reservations it opened. Concluding here would end the
// cycle while customers still had time to pay, and the settle that finally
// expires them (next tick) would have no cycle left to promote the waiting
// list into. Hold in PAYMENT until every reservation has actually resolved.
if (await this.bookingBatchService.hasLiveReservations(schedule.id)) {
this.logger.log(
`[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` +
`are still within their pay windows — holding the cycle open`,
);
return true;
}
await this.concludeCycle(schedule, cfg, now);
return true;
}
@@ -306,9 +354,24 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
await this.setPhase(schedule, { windowPhase: 'DONE' });
await this.tryAutoFinalize(schedule.id);
this.logger.log(
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
);
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.
@@ -324,7 +387,8 @@ export class BookingWindowService implements OnModuleInit {
if (nextOpensAt == null) {
await this.setPhase(schedule, { windowPhase: 'DONE' });
this.logger.log(
`Schedule ${schedule.id} not full but no cycle fits before departure — window done`,
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
`departure — window DONE`,
);
return;
}
@@ -347,7 +411,8 @@ export class BookingWindowService implements OnModuleInit {
});
const sameDay = eatDay(nextOpensAt) === eatDay(now);
this.logger.log(
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
`[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` +
`REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
);
}

View File

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

View File

@@ -4,6 +4,7 @@ import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
containerWagonsForLines,
roundTons,
type WagonPlanSlot,
} from './wagon-plan.util';
@@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
return Math.max(1, Math.ceil(weight / capacity));
}
const lineSlots = (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
0,
);
return Math.max(1, lineSlots);
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
// fraction. Ceiling per line would over-count split 20ft lines.
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
}
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {

View File

@@ -0,0 +1,118 @@
import { TrainSchedulingService } from './train-scheduling.service';
import { Booking } from '../bookings/entities/booking.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
type Row = Pick<ClearanceMilestone, 'bookingId' | 'milestoneCode' | 'status'> & {
metadata?: Record<string, unknown> | null;
triggeredAt?: Date | null;
};
/**
* The gate pass is secured once per train schedule, but each booking only earns
* its GATEPASS_GRANTED milestone after settling freight payment. An unpaid
* booking must not ride a paid neighbour's grant — the train proceeds, that
* booking stays pending.
*/
function makeService(bookings: Array<Partial<Booking>>, rows: Row[]) {
const milestoneRepo = {
find: jest.fn().mockResolvedValue(rows),
save: jest.fn((row: Row) => Promise.resolve(row)),
};
const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) };
const dataSource = {
getRepository: (entity: unknown) =>
entity === Booking ? bookingRepo : milestoneRepo,
};
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
Object.assign(service, {
dataSource,
logger: { warn: jest.fn(), log: jest.fn() },
});
return { service, milestoneRepo };
}
/** Reach the private bridge write under test. */
function grant(service: TrainSchedulingService, at: Date): Promise<void> {
return (
service as unknown as {
completeGatepassMilestoneForSchedule(id: string, at: Date): Promise<void>;
}
).completeGatepassMilestoneForSchedule('sched-1', at);
}
const securedAt = new Date('2026-07-09T08:00:00.000Z');
describe('gate pass is withheld from bookings that have not paid freight', () => {
it('grants the paid booking and leaves the unpaid one pending', async () => {
const rows: Row[] = [
{ bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
{ bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
{ bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
{ bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
];
const { service, milestoneRepo } = makeService(
[
{ id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
{ id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
],
rows,
);
await grant(service, securedAt);
const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r);
expect(saved).toHaveLength(1);
expect(saved[0]!.bookingId).toBe('paid');
expect(saved[0]!.status).toBe('COMPLETED');
expect(saved[0]!.triggeredAt).toBe(securedAt);
const unpaid = rows.find(
(r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED',
);
expect(unpaid!.status).toBe('PENDING');
});
it('treats a booking paid outside the milestone path as paid', async () => {
// Some payment paths settle the invoice without writing the milestone; the
// clearance views self-heal it on read, so the gate pass must not lag.
const rows: Row[] = [
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
];
const { service, milestoneRepo } = makeService(
[{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }],
rows,
);
await grant(service, securedAt);
expect(milestoneRepo.save).toHaveBeenCalledTimes(1);
expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1');
});
it('leaves an already-granted milestone untouched', async () => {
const rows: Row[] = [
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' },
];
const { service, milestoneRepo } = makeService(
[{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }],
rows,
);
await grant(service, securedAt);
expect(milestoneRepo.save).not.toHaveBeenCalled();
});
it('does nothing when the schedule carries no customs bookings', async () => {
const { service, milestoneRepo } = makeService([], []);
await grant(service, securedAt);
expect(milestoneRepo.save).not.toHaveBeenCalled();
});
});

View File

@@ -1,41 +1,188 @@
import {
bookingGrossWeightTons,
bookingTrainLengthMeters,
consistUsage,
consistViolations,
deriveTrainCapacityFromLocomotive,
grossWagonWeightTons,
minLocomotiveLimits,
} from './train-capacity.util';
describe('train-capacity.util', () => {
const nw5 = { lengthMeters: 14, capacityTons: 70 };
// Real EDR wagon specs.
const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 };
const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 };
const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 };
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
const shortLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
[nw5],
);
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
expect(shortLoco.maxWagonSlots).not.toBe(53);
const heavyLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
[nw5],
);
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
const caps = (over = {}) => ({
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonSlots: 54,
...over,
});
it('uses shortest wagon type when mixed types are present', () => {
const longBulk = { lengthMeters: 18, capacityTons: 80 };
const mixed = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5, longBulk],
);
expect(mixed.maxWagonSlots).toBe(
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
);
const slots = (n: number, type: typeof nw5, cargoTons: number) =>
Array.from({ length: n }, () => ({
lengthMeters: type.lengthMeters,
tareWeightTons: type.tareWeightTons,
cargoTons,
}));
describe('deriveTrainCapacityFromLocomotive', () => {
it('derives wagon slots from train length, not a fixed 53', () => {
const shortLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
[nw5],
);
expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966)
expect(shortLoco.maxWagonSlots).not.toBe(53);
});
it('does not shrink slots by assuming every wagon rides at full payload', () => {
// A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a
// LENGTH figure — the cargo that decides weight does not exist yet.
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
[nw5],
);
expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30
expect(derived.maxWeightTons).toBe(2100);
});
it('admits the railway 53-wagon NW5 marshalling figure', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5],
);
expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53);
});
it('uses the shortest wagon type when mixed types are present', () => {
const mixed = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5, pw2, gw2],
);
expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62
});
it('extends weight/length caps by the locomotive overage tolerance', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
[pw2],
);
expect(derived.maxWeightTons).toBe(3590);
});
it('ignores overage tolerance when unset (strict cap)', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
[nw5],
);
expect(derived.maxWeightTons).toBe(3500);
expect(derived.maxLengthMeters).toBe(760);
});
it('floors the locomotive by the global rule caps', () => {
const derived = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: 5000, maxTrainLengthMeters: 900 },
[nw5],
{ maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 },
);
expect(derived.maxWeightTons).toBe(3500);
expect(derived.maxLengthMeters).toBe(760);
});
});
describe('gross weight', () => {
it('counts the wagon as well as its cargo', () => {
expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2);
});
it('charges a booking one tare per wagon it occupies', () => {
// 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel.
expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2);
});
it('is cargo alone when the wagon type has no tare on record', () => {
expect(bookingGrossWeightTons(100, 3, 0)).toBe(100);
});
});
describe('consistUsage', () => {
it('sums each wagon own length and tare rather than averaging a type', () => {
const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)];
const usage = consistUsage(mixed, caps());
expect(usage.wagonCount).toBe(3);
expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066
expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2
expect(usage.usedCargoWeightTons).toBe(40);
expect(usage.usedGrossWeightTons).toBe(110);
expect(usage.remainingGrossWeightTons).toBe(3390);
expect(usage.remainingWagons).toBe(51);
});
it('reports an empty consist as fully available', () => {
const usage = consistUsage([], caps());
expect(usage.usedGrossWeightTons).toBe(0);
expect(usage.remainingLengthMeters).toBe(760);
expect(usage.remainingWagons).toBe(54);
});
});
describe('consistViolations', () => {
it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => {
// 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T.
const consist = slots(37, pw2, 70);
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([
expect.stringContaining('3522.4T'),
]);
expect(
consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })),
).toEqual([]);
});
it('blocks a train the old cargo-only math would have waved through', () => {
// Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T.
const consist = slots(37, pw2, 70);
const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0);
expect(cargoOnly).toBeLessThan(3500);
expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]);
});
it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => {
// 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T.
expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]);
});
it('flags an over-length consist', () => {
const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 }));
expect(violations).toEqual([expect.stringContaining('exceeds max train length')]);
});
it('flags an over-count consist', () => {
const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 }));
expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]);
});
it('reports every broken axis at once', () => {
expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3);
});
});
it('computes booking length by freight type', () => {
expect(
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
).toBe(28);
expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28);
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
});
it('takes the weakest locomotive across a multi-locomotive set', () => {
const limits = minLocomotiveLimits([
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 },
{ maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 },
]);
expect(limits?.maxPullWeightTons).toBe(3500);
expect(limits?.overageToleranceTons).toBe(20);
});
});

View File

@@ -1,73 +1,212 @@
/**
* Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable:
*
* count — how many wagons fit end to end on the longest allowed train
* length — Σ wagonType.lengthMeters over the real consist
* weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist
*
* The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it.
* The old code compared the locomotive's pull limit against cargo payload alone
* and so overbooked every train by roughly the tare fraction (~27% on PW2).
*
* The weight axis is also driven by ACTUAL booked cargo, never by an assumed
* full payload. That is what makes the real EDR numbers fall out:
*
* NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon
* marshalling figure is length-bound, and those trains never carry 53×70T.
* PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T,
* which clears 3500T only via the locomotive's overage tolerance. Weight
* binds first, hence "37 wagons per train".
*
* So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo
* exists. Weight is enforced against the consist as bookings are allocated.
*/
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
export type WagonTypeDimensions = {
lengthMeters: number;
capacityTons: number;
tareWeightTons: number;
};
/** One occupied wagon slot in a real consist. */
export type ConsistSlot = {
lengthMeters: number;
tareWeightTons: number;
/** Actual cargo/container weight riding on this wagon, not its rated capacity. */
cargoTons: number;
};
export type LocomotiveLimits = {
maxPullWeightTons: number;
maxTrainLengthMeters: number;
/** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */
overageToleranceTons?: number | null;
/** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */
overageToleranceMeters?: number | null;
};
export type DerivedTrainCapacity = {
/** Gross (tare + cargo) tons the train may weigh, tolerance included. */
maxWeightTons: number;
maxLengthMeters: number;
/** Length-derived slot count. Weight is enforced separately against real cargo. */
maxWagonSlots: number;
};
/** What a consist currently uses, and what is left on each axis. */
export type ConsistUsage = {
wagonCount: number;
usedLengthMeters: number;
/** Σ (tare + cargo). */
usedGrossWeightTons: number;
usedTareWeightTons: number;
usedCargoWeightTons: number;
remainingLengthMeters: number;
remainingGrossWeightTons: number;
remainingWagons: number;
};
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
const DEFAULT_WAGON_LENGTH_M = 14;
const DEFAULT_WAGON_CAPACITY_T = 70;
/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */
const DEFAULT_WAGON_TARE_T = 22.4;
function num(value: unknown, fallback = 0): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
return num(slot.tareWeightTons) + num(slot.cargoTons);
}
/**
* Derive train capacity from locomotive pull weight and train length.
* Wagon count is NOT a fixed 53 — it is the minimum of:
* - floor(maxLength / shortest wagon type length)
* - floor(maxWeight / lightest wagon type capacity)
* Hard caps for a train: the locomotive's own limits, floored by the global rule
* caps, then widened by the locomotive's overage tolerance.
*/
export function trainHardCaps(
locomotive: LocomotiveLimits,
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): { maxWeightTons: number; maxLengthMeters: number } {
const overageTons = num(locomotive.overageToleranceTons);
const overageMeters = num(locomotive.overageToleranceMeters);
const weight =
Math.min(
num(locomotive.maxPullWeightTons, Infinity) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
) + overageTons;
const length =
Math.min(
num(locomotive.maxTrainLengthMeters, Infinity) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
) + overageMeters;
return {
maxWeightTons: Number.isFinite(weight) ? weight : MAX_FALLBACK_WEIGHT,
maxLengthMeters: Number.isFinite(length) ? length : MAX_FALLBACK_LENGTH,
};
}
/**
* Derive the planning capacity of a train from its locomotive.
*
* `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within
* the train-length cap — the optimistic slot count, since a mixed consist of
* longer wagons will hit the length cap sooner. It is deliberately NOT reduced by
* weight: with no bookings yet there is no cargo, and assuming every wagon rides
* at full rated payload would report 37 NW5 slots where the railway marshals 53.
* Weight is enforced by {@link consistUsage} / {@link consistViolations} against
* the cargo actually allocated.
*/
export function deriveTrainCapacityFromLocomotive(
locomotive: LocomotiveLimits,
wagonTypes: WagonTypeDimensions[],
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
): DerivedTrainCapacity {
const maxWeightTons = Math.min(
Number(locomotive.maxPullWeightTons) || Infinity,
ruleCaps?.maxTrainWeightTons ?? Infinity,
);
const maxLengthMeters = Math.min(
Number(locomotive.maxTrainLengthMeters) || Infinity,
ruleCaps?.maxTrainLengthMeters ?? Infinity,
);
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
const types =
wagonTypes.length > 0
? wagonTypes
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
const lengths = wagonTypes
.map((w) => num(w.lengthMeters))
.filter((l) => l > 0);
const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M;
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
const minCapacity = Math.min(
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
);
const maxWagonSlots =
minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0;
const byLength =
minLength > 0 && Number.isFinite(maxLengthMeters)
? Math.floor(maxLengthMeters / minLength)
: 0;
const byWeight =
minCapacity > 0 && Number.isFinite(maxWeightTons)
? Math.floor(maxWeightTons / minCapacity)
: byLength;
return { maxWeightTons, maxLengthMeters, maxWagonSlots };
}
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
/**
* What a real, mixed-type consist uses on all three axes, and what is left.
* Every wagon contributes its own length and its own tare — no averaging over a
* representative wagon type.
*/
export function consistUsage(
slots: ConsistSlot[],
caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
): ConsistUsage {
let usedLengthMeters = 0;
let usedTareWeightTons = 0;
let usedCargoWeightTons = 0;
for (const slot of slots) {
usedLengthMeters += num(slot.lengthMeters);
usedTareWeightTons += num(slot.tareWeightTons);
usedCargoWeightTons += num(slot.cargoTons);
}
const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons;
return {
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
maxWagonSlots,
wagonCount: slots.length,
usedLengthMeters: round3(usedLengthMeters),
usedGrossWeightTons: round3(usedGrossWeightTons),
usedTareWeightTons: round3(usedTareWeightTons),
usedCargoWeightTons: round3(usedCargoWeightTons),
remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters),
remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons),
remainingWagons: caps.maxWagonSlots - slots.length,
};
}
export const MAX_FALLBACK_WEIGHT = 3500;
export const MAX_FALLBACK_LENGTH = 760;
/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */
export function consistViolations(
slots: ConsistSlot[],
caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number },
): string[] {
const usage = consistUsage(slots, caps);
const violations: string[] = [];
if (usage.usedGrossWeightTons > caps.maxWeightTons) {
violations.push(
`Total train gross weight ${usage.usedGrossWeightTons}T ` +
`(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` +
`exceeds max pull weight ${round3(caps.maxWeightTons)}T`,
);
}
if (usage.usedLengthMeters > caps.maxLengthMeters) {
violations.push(
`Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`,
);
}
if (usage.wagonCount > caps.maxWagonSlots) {
violations.push(
`Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`,
);
}
return violations;
}
function round3(value: number): number {
return Number.isFinite(value) ? Number(value.toFixed(3)) : value;
}
/**
* Effective pull limits for a train set with multiple locomotives: the weakest
@@ -75,16 +214,22 @@ export const MAX_FALLBACK_LENGTH = 760;
* across all assigned locomotives. Returns null when no locomotives are given.
*/
export function minLocomotiveLimits(
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
locomotives: Array<
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
>,
): LocomotiveLimits | null {
if (!locomotives.length) return null;
return {
maxPullWeightTons: Math.min(
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
),
maxTrainLengthMeters: Math.min(
...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity),
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
),
// Weakest locomotive's tolerance governs the set, same as its caps.
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
};
}
@@ -98,12 +243,27 @@ export function bookingTrainLengthMeters(
return wagonCount * perWagon;
}
/**
* Gross weight a booking adds to its train: its cargo plus the tare of every
* wagon it occupies. A booking is never weightless just because it is light —
* the empty wagons still have to be pulled.
*/
export function bookingGrossWeightTons(
cargoTons: number,
wagonCount: number,
tarePerWagonTons: number,
): number {
return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons));
}
export function wagonTypeDimensionsFromEntity(wt: {
lengthMeters?: number | string | null;
capacityTons?: number | string | null;
tareWeightTons?: number | string | null;
}): WagonTypeDimensions {
return {
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T,
};
}

View File

@@ -14,7 +14,6 @@ const nw5 = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
@@ -35,7 +34,6 @@ const cw3 = {
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,

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`,
);
@@ -1648,6 +1655,13 @@ export class TrainSchedulingService {
* clearance views still reading that milestone (older deployed builds) see
* the gate pass as done. Drop once every clearance-api deployment reads
* ImportDjiboutiOperation.gatepassGrantedAt directly.
*
* A booking only earns its gate pass once the customer has settled the freight
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
* train and its paid bookings proceed. Re-securing the gate pass after payment
* settles picks the booking up; so does any later call to this bridge.
*/
private async completeGatepassMilestoneForSchedule(
scheduleId: string,
@@ -1659,20 +1673,49 @@ export class TrainSchedulingService {
if (bookings.length === 0) return;
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
const bookingIds = bookings.map((b) => b.id);
const rows = await milestoneRepo.find({
where: {
bookingId: In(bookings.map((b) => b.id)),
milestoneCode: 'GATEPASS_GRANTED',
bookingId: In(bookingIds),
milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
},
});
const paidBookingIds = new Set(
rows
.filter(
(r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
)
.map((r) => r.bookingId),
);
// A booking whose payment settled through a path that never wrote the
// milestone still counts as paid — the clearance views self-heal the row on
// read, and the gate pass must not lag behind that.
for (const booking of bookings) {
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
paidBookingIds.add(booking.id);
}
}
const skipped: string[] = [];
for (const row of rows) {
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
if (row.status === 'COMPLETED') continue;
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
skipped.push(row.bookingId ?? '(unknown)');
continue;
}
row.status = 'COMPLETED';
row.triggeredAt = securedAt;
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
await milestoneRepo.save(row);
}
if (skipped.length > 0) {
this.logger.warn(
`Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
);
}
}
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
@@ -1857,7 +1900,7 @@ export class TrainSchedulingService {
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td>
@@ -2591,7 +2634,8 @@ export class TrainSchedulingService {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true } },
route: true,
// Yards carry the route's display name used by mapScheduleListItem.
route: { originYard: true, destinationYard: true },
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
@@ -2947,8 +2991,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 +3012,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 +3070,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 +3100,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 +3165,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 +3495,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 +3752,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 +4255,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 +4278,17 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
// Soft (customer availability preview): no days if unresolved, never throws.
const requiredType = await this.resolveWagonTypeForPreview(
input.freightType,
input.cargoTypeCode ?? null,
);
if (!requiredType) return { days: [] };
// How many wagons of that type the cargo needs.
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
void slotsNeeded; // TEMP: unused while the wagon-availability filter is off.
// TEMP (per request): wagon-availability filtering is DISABLED. A day is now
// offered whenever a bookable schedule that day has remaining train capacity
// — regardless of whether matching wagons are actually available at the
// origin / boarding yard. This surfaces days even when no wagon is on hand.
// Restore the block below to bring back the "enough matching wagons" gate.
//
// // AVAILABLE wagons of the required type, counted once per origin yard.
// const availableByYard = new Map<string, number>();
// const availableAt = async (yardId: string): Promise<number> => {
// const cached = availableByYard.get(yardId);
// if (cached !== undefined) return cached;
// const counts = await this.countFleetAvailability(yardId);
// const n =
// counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
// availableByYard.set(yardId, n);
// return n;
// };
const days = new Set<string>();
for (const s of schedules) {
const hasCapacity =
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
if (!hasCapacity) continue;
// TEMP (per request): wagon-availability check commented out — see note
// above. Dynamic consist: wagons may ride from the train's origin OR
// already sit at the booking's own boarding yard and attach when the train
// arrives — either pool can serve a sub-corridor booking.
// let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
// if (
// !enoughWagons &&
// input.originYardId &&
// input.originYardId !== s.originStationId
// ) {
// enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded;
// }
// if (!enoughWagons) continue;
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
/**
* Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight /
* capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per
* wagon. Mirrors wagon-plan.util without fabricating Booking entities.
*/
private wagonsNeededForCargo(
input: {
freightType: 'CONTAINER' | 'BULK';
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
},
wagonType: WagonType,
): number {
if (input.freightType === 'BULK') {
const capacity = Number(wagonType.capacityTons) || 1;
const weight = Number(input.totalWeightTons ?? 0);
return Math.max(1, Math.ceil(weight / capacity));
}
const teu = (input.containers ?? []).reduce((sum, c) => {
const per = c.containerSize === '40ft' ? 2 : 1;
return sum + per * Math.max(0, Number(c.quantity ?? 0));
}, 0);
return Math.max(1, Math.ceil(teu / 2));
}
/**
* Ordered stop yards of a schedule's route: origin → milestones → destination,
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule

View File

@@ -6,6 +6,7 @@ import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
containerWagonsForLines,
expandBookingContainerUnits,
expandContainerItems,
roundTons,
@@ -20,7 +21,6 @@ const nw5: WagonType = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
@@ -32,7 +32,6 @@ const cw3: WagonType = {
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
@@ -200,3 +199,60 @@ describe('wagon-plan.util', () => {
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
});
});
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
quantity,
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
});
it('20×20ft = 10 wagons (not 20)', () => {
expect(containerWagonsForLines([line(20, 0.5)])).toBe(10);
});
it('38×20ft = 19 wagons', () => {
expect(containerWagonsForLines([line(38, 0.5)])).toBe(19);
});
it('2×20ft = 1 wagon', () => {
expect(containerWagonsForLines([line(2, 0.5)])).toBe(1);
});
it('odd 3×20ft = 2 wagons (single line ceils)', () => {
expect(containerWagonsForLines([line(3, 0.5)])).toBe(2);
});
it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => {
// per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3.
expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3);
});
it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => {
// per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2.
expect(
containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]),
).toBe(2);
});
it('5×20ft + 2×40ft = 5 wagons', () => {
expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5);
});
it('21×40ft = 21 wagons', () => {
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
});
it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => {
// No containerType relation loaded → use the stored (0.5-aware) fraction.
expect(
containerWagonsForLines([
{ quantity: 20, wagonsRequired: 10 } as never,
]),
).toBe(10);
});
it('empty line set = 0 wagons', () => {
expect(containerWagonsForLines([])).toBe(0);
});
});

View File

@@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { consistViolations } from './train-capacity.util';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;
@@ -35,6 +36,9 @@ export type WagonPlanSlot = {
wagonTypeCode: string;
capacityTons: number;
lengthMeters: number;
/** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */
tareWeightTons: number;
/** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */
assignedWeightTons: number;
allocations: WagonAllocationRecord[];
slotLoadType?: SlotLoadType;
@@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number {
return Number(numericValue.toFixed(3));
}
/**
* Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill;
* a missing tare must read as 0 rather than silently inventing dead weight.
*/
export function tareTonsOf(wagonType: Pick<WagonType, 'tareWeightTons'>): number {
return roundTons(wagonType.tareWeightTons ?? 0);
}
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
export function teuSlotsForSizeFt(sizeFt: number): number {
return sizeFt >= 40 ? 2 : 1;
@@ -89,18 +101,38 @@ export function containersPerWagonFromType(wagonsPerUnit: number): number {
return Math.max(1, Math.round(1 / wpu));
}
function lineWagonsRequired(line: {
type ContainerLine = {
quantity?: number | null;
wagonsRequired?: number | null;
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
}): number {
};
/**
* RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4).
*/
function lineWagonsRaw(line: ContainerLine): number {
const qty = Number(line.quantity ?? 0);
if (qty <= 0) return 0;
const wpu = Number(line.containerType?.wagonsPerUnit);
if (Number.isFinite(wpu) && wpu > 0) {
return Math.ceil(qty * wpu);
return qty * wpu;
}
return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1)));
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else
// treat the whole line as one wagon.
const stored = Number(line.wagonsRequired);
return Number.isFinite(stored) && stored > 0 ? stored : 1;
}
/**
* Whole wagons a set of container lines needs: ceil the summed RAW fraction so a
* half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0.
*/
export function containerWagonsForLines(lines: ContainerLine[]): number {
const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0);
return raw > 0 ? Math.ceil(raw) : 0;
}
/**
@@ -110,21 +142,23 @@ export function buildContainerWagonPlan(
bookings: Booking[],
wagonType: WagonType,
): WagonPlanSlot[] {
// Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit
// can share a wagon with another 20ft of the SAME booking, never across
// bookings), then sum. Ceiling per line instead would over-count a booking
// that splits its 20ft units across several lines.
const totalSlots = bookings.reduce((sum, booking) => {
const lineSlots = (booking.bookingContainers ?? []).reduce(
(lineSum, line) => lineSum + lineWagonsRequired(line),
0,
);
return sum + Math.max(lineSlots, 1);
const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []);
return sum + Math.max(bookingSlots, 1);
}, 0);
const slots = Math.max(1, Math.ceil(totalSlots));
const slots = Math.max(1, totalSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
wagonTypeId: wagonType.id,
wagonTypeCode: wagonType.code,
capacityTons: Number(wagonType.capacityTons),
lengthMeters: Number(wagonType.lengthMeters),
tareWeightTons: tareTonsOf(wagonType),
assignedWeightTons: 0,
allocations: [],
}));
@@ -154,6 +188,7 @@ export function buildBulkWagonPlan(
wagonTypeCode: wagonType.code,
capacityTons: capacity,
lengthMeters: Number(wagonType.lengthMeters),
tareWeightTons: tareTonsOf(wagonType),
assignedWeightTons: 0,
allocations: [],
}));
@@ -193,6 +228,7 @@ export function buildMixedWagonPlan(
wagonTypeCode: containerWagonType.code,
capacityTons: Number(containerWagonType.capacityTons),
lengthMeters: Number(containerWagonType.lengthMeters),
tareWeightTons: tareTonsOf(containerWagonType),
assignedWeightTons: 0,
allocations: [],
slotLoadType: 'CONTAINER',
@@ -422,47 +458,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string
return violations;
}
/**
* Check a consist against its train's three limits. Weight is GROSS — every slot
* contributes its own tare plus the cargo assigned to it — because the locomotive
* pull limit governs what it drags, not what was sold. Length and tare are summed
* per slot, so a mixed consist is measured as it actually stands rather than
* through one representative wagon type.
*
* `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain`
* is absent; slot dimensions always win over it.
*/
export function validateTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonType: WagonType,
wagonType: Pick<WagonType, 'lengthMeters'>,
limits?: TrainLimitConfig,
): string[] {
const violations: string[] = [];
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
const wagonLength = Number(wagonType.lengthMeters) || 14;
const maxWagonsPerTrain =
limits?.maxWagonsPerTrain ??
Math.floor(maxLengthMeters / wagonLength);
const maxWagonSlots =
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength);
const totalWeightTons = roundTons(
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
const violations = consistViolations(
wagonPlan.map((slot) => ({
lengthMeters: Number(slot.lengthMeters),
tareWeightTons: Number(slot.tareWeightTons ?? 0),
cargoTons: Number(slot.assignedWeightTons),
})),
{ maxWeightTons, maxLengthMeters, maxWagonSlots },
);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (totalWeightTons > maxWeightTons) {
violations.push(
`Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`,
);
}
if (totalLengthMeters > maxLengthMeters) {
violations.push(
`Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`,
);
}
if (wagonPlan.length > maxWagonsPerTrain) {
violations.push(
`Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`,
);
}
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
return violations;
}
/**
* Mixed consist: the wagon-count fallback uses the shortest type present, since
* that is the most wagons that could ever fit. Weight and length still come from
* the slots themselves.
*/
export function validateMixedTrainLimits(
wagonPlan: WagonPlanSlot[],
wagonTypes: WagonType[],
@@ -478,7 +513,7 @@ export function validateMixedTrainLimits(
return validateTrainLimits(
wagonPlan,
{ maxWagonsPerTrain } as WagonType,
{ lengthMeters: minWagonLength },
{ ...limits, maxWagonsPerTrain },
);
}

View File

@@ -3,7 +3,6 @@ import { Transform } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
@@ -14,9 +13,6 @@ import {
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
const toOptionalNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
@@ -60,12 +56,16 @@ export class CreateWagonTypeDto {
@Min(0.001)
lengthMeters!: number;
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
@IsOptional()
@Transform(toOptionalNumber)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
@ApiProperty({
description:
'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' +
'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.',
example: 22.4,
})
@Transform(toNumber)
@IsNumber()
@Min(0.001)
tareWeightTons!: number;
@ApiPropertyOptional({
description: 'Supported load types, e.g. CONTAINER,BULK',

View File

@@ -19,9 +19,6 @@ export class WagonType extends BaseEntity {
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
lengthMeters!: number;
@Column({ name: 'max_wagons_per_train', type: 'int', nullable: true })
maxWagonsPerTrain?: number | null;
@Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' })
supportedLoadTypes!: string[];
@@ -31,8 +28,9 @@ export class WagonType extends BaseEntity {
@Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true })
equatedLengthM?: number | null;
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
tareWeightTons?: number | null;
/** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 })
tareWeightTons!: number;
@Column({ name: 'supports_container', type: 'boolean', default: false })
supportsContainer!: boolean;

View File

@@ -80,7 +80,7 @@ export class WagonTypesService {
name: dto.name.trim(),
capacityTons: dto.capacityTons,
lengthMeters: dto.lengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
tareWeightTons: dto.tareWeightTons ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? [],
isActive: dto.isActive ?? true,
});
@@ -101,8 +101,6 @@ export class WagonTypesService {
...dto,
...(nextCode ? { code: nextCode } : {}),
...(dto.name ? { name: dto.name.trim() } : {}),
maxWagonsPerTrain:
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
});

View File

@@ -1,5 +1,5 @@
import { WagonStatus } from '@edr/types';
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator';
import { IsString, IsUUID, IsOptional, IsInt, Min, IsEnum } from 'class-validator';
export class CreateWagonDto {
@IsString()
@@ -17,13 +17,8 @@ export class CreateWagonDto {
@Min(1)
sequenceNumber?: number;
@IsNumber()
@Min(0)
tareWeight!: number;
@IsNumber()
@Min(0)
maxPayloadWeight!: number;
// Tare weight and payload capacity are not accepted here: they belong to the
// wagon type and are resolved through wagonTypeId.
@IsOptional()
@IsEnum(WagonStatus)

View File

@@ -7,6 +7,7 @@ import { TrainSchedule } from '../../train-schedules/entities/train-schedule.ent
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { Container } from '../../container-management/entities/container.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
export const WAGON_STATUSES = [
WagonStatus.Available,
@@ -28,17 +29,19 @@ export class Wagon extends BaseEntity {
@Column({ name: 'wagon_type_id', type: 'uuid' })
wagonTypeId!: string;
/** Owns this wagon's spec: tare weight, payload capacity, length. */
@ManyToOne(() => WagonType)
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType;
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId!: string | null;
@Column({ name: 'sequence_number', type: 'int', nullable: true })
sequenceNumber!: number | null;
@Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
tareWeight!: number;
@Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
maxPayloadWeight!: number;
// Tare weight and payload capacity are properties of the wagon TYPE — read them
// through `wagonType`, never off the individual wagon.
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
status!: WagonStatusType;

View File

@@ -52,14 +52,23 @@ export class WagonsService {
});
}
const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '')
// Spec columns (tare, payload) are no longer sortable here — they live on the
// wagon type, so sorting by them is sorting by wagonTypeId.
const sortable: Array<keyof Wagon> = [
'wagonNumber',
'status',
'currentYardId',
'sequenceNumber',
'wagonTypeId',
];
const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon)
? (query.sortBy as keyof Wagon)
: 'wagonNumber';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.wagonRepo.find({
where: search ? where : filters,
relations: { currentYard: true },
relations: { currentYard: true, wagonType: true },
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? Number(query.limit) : undefined,
@@ -69,7 +78,7 @@ export class WagonsService {
async findById(id: string): Promise<Wagon> {
const wagon = await this.wagonRepo.findOne({
where: { id },
relations: { currentYard: true },
relations: { currentYard: true, wagonType: true },
});
if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
return wagon;

View File

@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
export class CreateWarehouseDto {
@ApiProperty()
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
@IsNumber()
@Min(0)
maxVolume?: number;
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
@IsOptional()
@IsEnum(WAREHOUSE_STATUSES)
status?: WarehouseStatus;
}

View File

@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
/**
* Optional explicit storage location. When warehouse/yard/zone are all provided,
* the item is stored there directly; otherwise store() falls back to the
* allocation-rule / capacity-balanced auto pick.
*/
export class StoreInventoryDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
yardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
UNLOADED_AT_DJIBOUTI_PORT: [],
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
STORED: ['RESERVED'],
// Reserve is retired from the operator flow — a stored export item advances
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
STORED: ['RESERVED', 'READY_FOR_LOADING'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],

View File

@@ -4,6 +4,8 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
/**
* Import handover records. A booking has one handover per truck (single truck ⇒
@@ -18,6 +20,7 @@ export class HandoverService {
constructor(
private readonly dataSource: DataSource,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
/** Tell the customer a handover is ready and needs their signature. */
@@ -28,15 +31,17 @@ export class HandoverService {
[bookingId],
);
if (!b?.companyId) return;
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
await this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Handover — signature needed',
body: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`,
body,
link: `/bookings/${bookingId}`,
data: { bookingId, reference },
});
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
} catch (err) {
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
}
@@ -49,6 +54,32 @@ export class HandoverService {
});
}
/**
* Ask the customer to sign the booking's handover. Ensures a handover exists
* (creates a booking-level self-haul one if none yet), then fires the
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
*/
async requestSignature(
bookingId: string,
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
const repo = this.dataSource.getRepository(BookingHandover);
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
if (existing.length === 0) {
// No handover yet (truck not arrived): create a booking-level one so the
// customer has something to sign. ensureForArrivedTruck notifies on create.
const created = await this.ensureForArrivedTruck(bookingId, {});
return { notified: true, reference: created.reference, alreadySigned: false };
}
const unsigned = existing.find((h) => !h.signedAt);
if (!unsigned) {
return { notified: false, reference: existing[0].reference, alreadySigned: true };
}
await this.notifySignNeeded(bookingId, unsigned.reference);
return { notified: true, reference: unsigned.reference, alreadySigned: false };
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction

View File

@@ -1,8 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { FilesService } from '../files/files.service';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
@Injectable()
export class WarehouseInspectionService {
private readonly logger = new Logger(WarehouseInspectionService.name);
constructor(
private readonly dataSource: DataSource,
private readonly inspectionRepository: WarehouseInspectionRepository,
private readonly filesService: FilesService,
private readonly lastMileService: LastMileService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
const [row] = await this.dataSource.query(
`SELECT inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
b.company_id AS "companyId",
b.trade_direction AS "tradeDirection",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
if (row.bookingReference && hasLastMile) {
await this.lastMileService.acceptBooking(row.bookingReference);
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
// Self-haul import: goods are pickup-ready but no collection truck is
// assigned yet — nudge the customer to assign one from the portal.
void this.notifyTruckAssignmentNeeded(row);
}
}
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
private async notifyTruckAssignmentNeeded(row: {
bookingId?: string | null;
bookingReference?: string | null;
companyId?: string | null;
}): Promise<void> {
if (!row.companyId || !row.bookingId) return;
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
try {
await this.inbox.notify({
recipients: { companyId: row.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Assign a truck for pickup',
body,
link: `/bookings/${row.bookingId}`,
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
});
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
} catch (err) {
this.logger.warn(
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
);
}
}

View File

@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
}
@Post(':id/store')
@ApiOperation({ summary: 'Mark received inventory as STORED' })
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.store(id, performedBy);
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
return this.inventoryService.store(id, dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@@ -354,12 +355,34 @@ export class WarehouseInventoryController {
return this.handoverService.list(bookingId);
}
@Post('bookings/:bookingId/request-handover-signature')
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
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) {
return this.inventoryService.containerItems(bookingId);
}
@Get('bookings/:bookingId/container-weights')
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingContainerWeights(bookingId);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
import { LastMileService } from '../last-mile/last-mile.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { SignaturesService } from '../signatures/signatures.service';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
@@ -358,12 +359,14 @@ export interface ImportUnloadedRow {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
hasAssignedTruck: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
notes: string | null;
}
@Injectable()
@@ -403,16 +406,18 @@ export class WarehouseInventoryService {
if (!booking.companyId) return;
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
if (booking.customerTruckAssignedAt) return; // already assigned
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
try {
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Assign a truck for pickup',
body: `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`,
body,
link: `/bookings/${bookingId}`,
data: { bookingId, action: 'ASSIGN_TRUCK' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
}
@@ -1338,12 +1343,18 @@ export class WarehouseInventoryService {
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
(b.customer_truck_assigned_at IS NOT NULL
OR EXISTS (SELECT 1 FROM freight.last_mile lm
WHERE lm.booking_id = b.id
AND lm.vehicle_id IS NOT NULL
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
inv.delivered_at AS "deliveredAt",
inv.notes AS "notes",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
@@ -2141,13 +2152,30 @@ export class WarehouseInventoryService {
// ── Lifecycle transitions ────────────────────────────────────────────────
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
async store(
id: string,
performedBy?: string,
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'STORED');
// Explicit location wins when the operator picked warehouse + yard + zone;
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
const manualLocation =
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
? {
warehouseId: chosen.warehouseId,
yardId: chosen.yardId,
zoneId: chosen.zoneId,
path: undefined as string | undefined,
}
: null;
const criteria = await this.getInventoryAllocationCriteria(item);
const ruleLocation = await this.allocation.resolveLocation(criteria);
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
const location =
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
if (!location) {
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
@@ -2191,18 +2219,19 @@ export class WarehouseInventoryService {
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
}
const storedReason = manualLocation
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
: ruleLocation?.rule
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
await manager.getRepository(WarehouseInventory).update(id, {
status: 'STORED',
storedAt: new Date(),
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
notes: this.appendNote(
locked.notes,
ruleLocation?.rule
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
),
notes: this.appendNote(locked.notes, storedReason),
});
await this.activityLog.record(
@@ -2210,9 +2239,7 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_STORED',
inventoryId: id,
warehouseId: location.warehouseId,
description: ruleLocation?.rule
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
description: storedReason.replace(/^Stored/, 'Inventory stored'),
performedBy,
},
manager,
@@ -2331,6 +2358,26 @@ export class WarehouseInventoryService {
'Customer must sign the handover before the exit paper can be generated',
);
}
// Authoritative weight match: the truck's net (gross tare) must equal the
// total VGM cargo weight of the containers selected as loaded on it.
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
const selected = dto.containerNumber
.split(/[,;\n]+/)
.map((n) => n.trim())
.filter(Boolean);
if (selected.length) {
const weights = await this.bookingContainerWeights(item.bookingId);
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
throw new BadRequestException(
`Weight mismatch: gross tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
);
}
}
}
}
}
const releaseDate = isTruckLeaving
@@ -2556,15 +2603,17 @@ 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;
handoverSigned: boolean;
}>
> {
const rows: Array<{
@@ -2576,6 +2625,7 @@ export class WarehouseInventoryService {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
@@ -2589,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",
@@ -2611,28 +2662,64 @@ export class WarehouseInventoryService {
[bookingId],
);
// Booking-level gate: the per-truck exit paper is blocked until the handover
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
const handoverSigned = await this.handover.isFullySigned(bookingId);
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,
handoverSigned,
}));
}
/**
* The booking's containers with their VGM cargo weight (tonnes), keyed by
* container number. Drives the truck-leaving exit weighing: the selected
* containers' total cargo weight must match (gross tare).
*/
async bookingContainerWeights(
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
const rows: Array<{ containerNumber: string; weightTons: string }> =
await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number`,
[bookingId],
);
return rows.map((r) => ({
containerNumber: r.containerNumber,
weightTons: Number(r.weightTons) || 0,
}));
}
@@ -2938,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,
@@ -3164,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

@@ -64,8 +64,8 @@ export class WarehousesService {
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
status: dto.status ?? 'ACTIVE',
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
});
} catch (error) {
this.mapDbError(error);

View File

@@ -227,7 +227,6 @@ async function ensureReferences(manager: any) {
name: 'Gate Pass Demo Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
equatedLengthM: 14,
@@ -420,8 +419,6 @@ async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: nu
wagonTypeId,
trainId: null,
sequenceNumber: sequenceNo,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Assigned,
currentYardId: yardId,
currentTrainScheduleId: scheduleId,

View File

@@ -102,7 +102,6 @@ async function main() {
name: 'Negad Demo Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
equatedLengthM: 14,
@@ -307,8 +306,6 @@ async function main() {
wagonTypeId: wagonType.id,
trainId: null,
sequenceNumber: sequenceNo,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Assigned,
currentYardId: indode.id,
notes: 'Demo wagon for Negad to Indode marshalling',

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
@@ -91,12 +92,26 @@ export class Batch5TestDataSeeder {
return;
}
// bookings.company_id AND bookings.company_profile_id are both NOT NULL, so a
// seed booking needs an owning company profile. Resolve the profile and take
// its company from it, so the two columns can never disagree. Without this the
// seeder aborted on its first insert.
const companyProfile = await this.dataSource
.getRepository(CompanyProfile)
.findOne({ where: {} });
if (!companyProfile) {
this.logger.warn('No company profile found; skipping Batch 5 seed');
return;
}
const now = new Date();
for (const seed of SEEDS) {
const booking = await bookingRepo.save(
bookingRepo.create({
reference: seed.ref,
companyId: companyProfile.companyId,
companyProfileId: companyProfile.id,
originYardId: originYard.id,
destinationYardId: destYard.id,
serviceTypeId: serviceType.id,

View File

@@ -211,7 +211,6 @@ export class DemoBookingsSeeder {
name: "Flat Wagon",
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ["CONTAINER"],
isActive: true,
equatedLengthM: 14,
@@ -224,7 +223,6 @@ export class DemoBookingsSeeder {
name: "Covered Hopper",
capacityTons: 60,
lengthMeters: 12,
maxWagonsPerTrain: 55,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 12,
@@ -236,7 +234,6 @@ export class DemoBookingsSeeder {
name: "Powder Wagon",
capacityTons: 55,
lengthMeters: 12,
maxWagonsPerTrain: 55,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 12,
@@ -248,7 +245,6 @@ export class DemoBookingsSeeder {
name: "Open Wagon",
capacityTons: 65,
lengthMeters: 13,
maxWagonsPerTrain: 53,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 13,
@@ -508,8 +504,6 @@ export class DemoBookingsSeeder {
wagonTypeId: nw5.id,
trainId: null,
sequenceNumber: null,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Available,
currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
notes: "Demo wagon for train scheduling",

View File

@@ -76,15 +76,11 @@ export class DemoFreightDataSeeder {
}
const toCreate = MIN_WAGONS_PER_TYPE - existing;
const tare = Number(type.tareWeightTons ?? 20);
const maxPayload = Number(type.capacityTons ?? 60);
const rows = Array.from({ length: toCreate }, (_, i) => {
const seq = existing + i + 1;
return wagonRepo.create({
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
wagonTypeId: type.id,
tareWeight: tare,
maxPayloadWeight: maxPayload,
status: WagonStatus.Available,
});
});

View File

@@ -205,6 +205,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'),
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
@@ -479,6 +480,7 @@ export const FREIGHT_PERMS = {
},
tracking: {
view: 'edr_freight_app:tracking:view',
manage: 'edr_freight_app:tracking:manage',
},
fuel: {
view: 'edr_freight_app:fuel:view',
@@ -662,9 +664,13 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
// Path A (no customs): Operations reviews the customer's self-clearance docs
// on the contract before the customer may create a shipment booking.
// on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL
// contracts (booking-level document review → finalize → CLEARANCE_READY).
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.opsClearanceReview,
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance,
...allRuleEngineViewKeys(),
],
director: [

View File

@@ -203,7 +203,6 @@ export class MarshallingDemoTrainsSeeder {
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
const tareWeight = Number(refs.wagonType.tareWeightTons) || 14;
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
@@ -275,8 +274,6 @@ export class MarshallingDemoTrainsSeeder {
wagonTypeId: refs.wagonType.id,
yardId: originYard.id,
trainScheduleId: schedule.id,
tareWeight,
capacityTons: wagonCapacity,
dispatched: hasDeparted,
});
@@ -420,8 +417,6 @@ export class MarshallingDemoTrainsSeeder {
wagonTypeId: string;
yardId: string;
trainScheduleId: string;
tareWeight: number;
capacityTons: number;
dispatched: boolean;
}): Promise<Wagon> {
const repo = this.dataSource.getRepository(Wagon);
@@ -433,8 +428,6 @@ export class MarshallingDemoTrainsSeeder {
wagonTypeId: input.wagonTypeId,
currentYardId: input.yardId,
currentTrainScheduleId: input.trainScheduleId,
tareWeight: input.tareWeight,
maxPayloadWeight: input.capacityTons,
status: WagonStatus.Assigned,
notes: 'Marshalling demo seed wagon',
}),

View File

@@ -12,6 +12,7 @@ import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-ru
import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
import { Yard } from "../modules/rule-engine/entities/yard.entity";
import { deriveTradeDirection } from "../common/derive-trade-direction.util";
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
@@ -295,6 +296,7 @@ export class PricingDataSeeder {
originYardId: addis.id,
destinationYardId: direDawa.id,
status: 'AVAILABLE',
direction: deriveTradeDirection(addis, direDawa),
}),
);
await milestoneRepo.save([

View File

@@ -3,6 +3,7 @@ import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
@@ -60,10 +61,16 @@ export class WarehouseDemoSeeder {
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
// bookings.company_id AND company_profile_id are both NOT NULL — a demo booking
// still needs an owner. Take the company from the profile so they always agree.
const companyProfile = await this.dataSource
.getRepository(CompanyProfile)
.findOne({ where: {} });
if (!djibYard || !ethYard || !serviceType) {
if (!djibYard || !ethYard || !serviceType || !companyProfile) {
this.logger.warn(
`Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`,
`Missing yards/service type/company profile (djib=${djibYard?.code}, eth=${ethYard?.code}, ` +
`svc=${serviceType?.code}, companyProfile=${companyProfile?.id ?? 'none'}); skipping`,
);
return;
}
@@ -89,7 +96,7 @@ export class WarehouseDemoSeeder {
): Promise<Booking> =>
bookingRepo.save(
bookingRepo.create({
...this.demoBookingDefaults(),
...this.demoBookingDefaults(companyProfile),
reference,
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
@@ -182,7 +189,15 @@ export class WarehouseDemoSeeder {
}
// 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet.
await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360));
await this.seedArrivedImportTrain(
djibYard,
ethYard,
serviceType,
cargoType,
companyProfile,
ago(60),
ago(360),
);
created += 1;
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
@@ -199,6 +214,7 @@ export class WarehouseDemoSeeder {
ethYard: Yard,
serviceType: ServiceType,
cargoType: CargoType | null,
owner: CompanyProfile,
arrival: Date,
departure: Date,
): Promise<void> {
@@ -238,7 +254,7 @@ export class WarehouseDemoSeeder {
for (let i = 1; i <= 3; i++) {
const b = await bookingRepo.save(
bookingRepo.create({
...this.demoBookingDefaults(),
...this.demoBookingDefaults(owner),
reference: `WH-DEMO-ARR-${i}`,
originYardId: djibYard.id,
destinationYardId: ethYard.id,
@@ -258,8 +274,10 @@ export class WarehouseDemoSeeder {
}
}
private demoBookingDefaults(): Partial<Booking> {
private demoBookingDefaults(owner: CompanyProfile): Partial<Booking> {
return {
companyId: owner.companyId,
companyProfileId: owner.id,
scheduledDate: new Date(),
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',

View File

@@ -53,11 +53,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
@@ -187,13 +187,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
FREIGHT_PERMS.contracts.clearanceEtActions,
],
},
// Hidden for now — Shipment Requests nav item disabled.
// {
// label: "Shipment Requests",
// href: "/dashboard/shipment-requests",
// icon: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
permission: FREIGHT_PERMS.contracts.createBooking,
},
{
label: "Self-Clearance Review",
href: "/dashboard/contracts/ops-clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "GL Djibouti Clearance",
href: "/dashboard/gl-djibouti/clearance",
@@ -752,7 +757,6 @@ const App = () => {
</RequirePermission>
}
/>
{/* Hidden for now — Shipment Requests pages disabled.
<Route
path="shipment-requests"
element={
@@ -773,7 +777,6 @@ const App = () => {
</RequirePermission>
}
/>
*/}
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"
@@ -829,10 +832,17 @@ const App = () => {
</RequirePermission>
}
/>
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
{/* Path A — Operations reviews per-booking self-clearance documents
(GENERAL contracts without customs). */}
<Route
path="contracts/ops-clearance"
element={<Navigate to="/dashboard/contracts/clearance" replace />}
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<DocumentClearanceListPage opsMode />
</RequirePermission>
}
/>
<Route
path="contracts/:id/create-booking"

View File

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

View File

@@ -50,6 +50,13 @@ import {
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
function todayISODate(): string {
const now = new Date();
const tz = now.getTimezoneOffset() * 60000;
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
}
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
@@ -937,6 +944,7 @@ export function ReleaseOrderCard({
);
const [loading, setLoading] = useState(false);
const [amendLoading, setAmendLoading] = useState(false);
const minVesselDate = useMemo(todayISODate, []);
return (
<Paper withBorder radius="md" p="md">
@@ -949,6 +957,7 @@ export function ReleaseOrderCard({
label="Vessel departure date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={minVesselDate}
size="sm"
/>
<Group>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { Ship, Upload } from "lucide-react";
@@ -40,6 +40,13 @@ export function GlClearanceUploadModal({
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
const [loading, setLoading] = useState(false);
// Earliest selectable vessel date (today, local) — refreshed on each open.
const todayISODate = useMemo(() => {
if (!opened) return undefined;
const now = new Date();
const tz = now.getTimezoneOffset() * 60000;
return new Date(now.getTime() - tz).toISOString().slice(0, 10);
}, [opened]);
const isDo = kind === "do";
const isRo = kind === "ro";
@@ -115,6 +122,7 @@ export function GlClearanceUploadModal({
label="Vessel departure date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={todayISODate}
size="sm"
required
/>
@@ -123,6 +131,7 @@ export function GlClearanceUploadModal({
label="Vessel arrival date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={todayISODate}
size="sm"
clearable
/>

View File

@@ -64,6 +64,21 @@ import {
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
// ISO 6346: 4-letter owner/category code + 6-digit serial + check digit.
// Same rule the customer portal shipment form enforces.
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
interface UnitErrors {
containerNumber?: string;
vgmTons?: string;
}
interface BulkErrors {
quantity?: string;
hazardous?: string;
reefer?: string;
}
function fmtWindowOpensAt(iso: string): string {
const date = new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
@@ -372,6 +387,72 @@ export default function GlCreateBookingForm() {
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
// Same client-side validation as the customer portal shipment form: ISO
// container numbers (unique within the shipment) and a positive VGM per unit;
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
const [showErrors, setShowErrors] = useState(false);
const unitErrors = useMemo<UnitErrors[][]>(() => {
if (!isContainer) return [];
const numberCounts = new Map<string, number>();
containerLines.forEach((line) =>
line.units.forEach((u) => {
const key = u.containerNumber.trim().toUpperCase();
if (!key) return;
numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1);
}),
);
return containerLines.map((line) =>
line.units.map((u) => {
const errs: UnitErrors = {};
const key = u.containerNumber.trim().toUpperCase();
if (!key) {
errs.containerNumber = "Container number is required.";
} else if (!ISO_CONTAINER_NUMBER_REGEX.test(key)) {
errs.containerNumber =
"Enter a valid ISO container number (e.g. ABCD1234567).";
} else if ((numberCounts.get(key) ?? 0) > 1) {
errs.containerNumber = "Duplicate container number in this shipment.";
}
const vgm = Number(u.vgmTons);
if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
errs.vgmTons = "Enter a valid VGM.";
}
return errs;
}),
);
}, [isContainer, containerLines]);
const bulkErrors = useMemo<BulkErrors[]>(() => {
if (isContainer) return [];
return bulkLines.map((line) => {
const errs: BulkErrors = {};
const qty = Number(line.cargoWeightTons || line.itemCount || 0);
if (Number.isNaN(qty) || qty <= 0) {
errs.quantity = "Enter a quantity greater than 0.";
}
const h = Number(line.hazardousQuantity || 0);
if (Number.isNaN(h) || h < 0) {
errs.hazardous = "Enter a valid hazardous quantity.";
} else if (qty > 0 && h > qty) {
errs.hazardous = `Can't exceed the cargo quantity (${qty}).`;
}
const r = Number(line.reeferQuantity || 0);
if (Number.isNaN(r) || r < 0) {
errs.reefer = "Enter a valid refrigerated quantity.";
} else if (qty > 0 && r > qty) {
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
}
return errs;
});
}, [isContainer, bulkLines]);
const cargoValid = isContainer
? unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
)
: bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer);
const canSubmit =
windowOpen &&
Boolean(scheduledDate) &&
@@ -401,7 +482,7 @@ export default function GlCreateBookingForm() {
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
})),
@@ -458,6 +539,13 @@ export default function GlCreateBookingForm() {
const overweightLines = validation?.overweightLines ?? [];
const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of
// sending an invalid payload to the price preview.
if (!cargoValid) {
setShowErrors(true);
return;
}
setShowErrors(false);
setPriceOpen(true);
const payload = buildPayload();
if (payload) {
@@ -467,7 +555,7 @@ export default function GlCreateBookingForm() {
};
const handleSubmit = () => {
if (!contract || !windowOpen) return;
if (!contract || !windowOpen || !cargoValid) return;
// Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book.
@@ -483,8 +571,13 @@ export default function GlCreateBookingForm() {
} catch {
// Non-fatal
}
navigate(`/dashboard/bookings/${booking.id}/clearance`);
}
if (contract.contractKind === "GENERAL") {
// GENERAL per-booking clearance: land on the booking's clearance
// detail — the same page the Shipments tab on the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
} else {
// ONE_TIME customs keeps its clearance on the contract.
navigate(`/dashboard/contracts/clearance/${contract.id}`);
}
},
@@ -692,6 +785,11 @@ export default function GlCreateBookingForm() {
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.containerNumber
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value,
@@ -720,6 +818,11 @@ export default function GlCreateBookingForm() {
min={0}
decimalScale={2}
value={unit.vgmTons}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.vgmTons
: undefined
}
onChange={(v) =>
patchUnit(lineIdx, unitIdx, { vgmTons: v })
}
@@ -808,6 +911,7 @@ export default function GlCreateBookingForm() {
min={0}
decimalScale={2}
value={line.cargoWeightTons}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { cargoWeightTons: v })}
radius={10}
styles={fieldStyles}
@@ -818,6 +922,7 @@ export default function GlCreateBookingForm() {
placeholder="e.g. 500"
min={0}
value={line.itemCount}
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
onChange={(v) => patchBulk(idx, { itemCount: v })}
radius={10}
styles={fieldStyles}
@@ -828,6 +933,7 @@ export default function GlCreateBookingForm() {
label="Hazardous quantity"
min={0}
value={line.hazardousQuantity}
error={showErrors ? bulkErrors[idx]?.hazardous : undefined}
onChange={(v) => patchBulk(idx, { hazardousQuantity: v })}
radius={10}
styles={fieldStyles}
@@ -838,6 +944,7 @@ export default function GlCreateBookingForm() {
label="Refrigerated quantity"
min={0}
value={line.reeferQuantity}
error={showErrors ? bulkErrors[idx]?.reefer : undefined}
onChange={(v) => patchBulk(idx, { reeferQuantity: v })}
radius={10}
styles={fieldStyles}
@@ -905,26 +1012,39 @@ export default function GlCreateBookingForm() {
marginTop: 24,
}}
>
<Group justify="flex-end" maw={896} mx="auto">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
<Box maw={896} mx="auto">
{showErrors && !cargoValid ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted cargo fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Button
variant="default"
radius="md"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={openPriceModal}
>
Review price &amp; book
</Button>
</Group>
</Box>
</Box>
<Modal

View File

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

View File

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

View File

@@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({
}
min={1}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
) : (
@@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({
}
min={0}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
)}

View File

@@ -98,6 +98,7 @@ export default function DurationField({
emitNative(v === "" ? "" : Number(v), unit)
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={min != null ? convert(min, nativeUnit, unit) : 0}
disabled={disabled}

View File

@@ -67,9 +67,13 @@ export default function EditScheduleDateModal({
);
const [value, setValue] = useState("");
// Earliest selectable departure, refreshed each time the modal opens.
const [minValue, setMinValue] = useState("");
useEffect(() => {
if (opened) setValue(toLocalInputValue(currentDate));
if (!opened) return;
setValue(toLocalInputValue(currentDate));
setMinValue(toLocalInputValue(new Date().toISOString()));
}, [opened, currentDate]);
const handleSave = async () => {
@@ -77,6 +81,13 @@ export default function EditScheduleDateModal({
toast({ title: "Pick a departure date", variant: "destructive" });
return;
}
if (new Date(value).getTime() < Date.now()) {
toast({
title: "Departure date must be in the future",
variant: "destructive",
});
return;
}
try {
await save.mutateAsync({
id: scheduleId,
@@ -124,6 +135,7 @@ export default function EditScheduleDateModal({
<TextInput
label="Departure date"
type="datetime-local"
min={minValue}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>

View File

@@ -0,0 +1,404 @@
import { useMemo } from "react";
import {
Alert,
Badge,
Box,
Group,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
Crown,
Container,
Boxes,
FlaskConical,
Layers,
Ruler,
Scale,
Sparkles,
TrainFront,
Trophy,
XCircle,
} from "lucide-react";
import type { BatchBoardScheduleDetail } from "@/types/trainScheduling";
import {
simulateBatch,
limitsFromDetail,
type BlockingAxis,
type ForecastRow,
} from "./batchForecast";
type Props = {
data: BatchBoardScheduleDetail;
bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"];
};
const cardVar = (color: string, shade: number) =>
`var(--mantine-color-${color}-${shade})`;
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const AXIS_LABEL: Record<BlockingAxis, string> = {
wagons: "wagon slots full",
weight: "over max pull weight",
length: "over train length",
};
/** One capacity axis as a labelled meter (used vs cap). */
function AxisMeter({
icon: Icon,
label,
used,
cap,
fmt,
color,
}: {
icon: typeof Scale;
label: string;
used: number;
cap: number | null;
fmt: (n: number) => string;
color: string;
}) {
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
const near = pct >= 90;
return (
<Box style={{ flex: 1, minWidth: 150 }}>
<Group justify="space-between" mb={4} wrap="nowrap">
<Group gap={5} wrap="nowrap">
<Icon size={13} color={cardVar(color, 6)} />
<Text size="xs" c="dimmed" fw={600}>
{label}
</Text>
</Group>
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
{fmt(used)}
{cap != null ? ` / ${fmt(cap)}` : ""}
</Text>
</Group>
<Progress
value={pct}
size="md"
radius="xl"
color={near ? color : "edr-green"}
/>
</Box>
);
}
function FreightIcon({ type }: { type: string | null }) {
const Icon = type === "BULK" ? Boxes : Container;
return (
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
</Tooltip>
);
}
/** A single forecast row: rank, booking, capacity contribution, projected verdict. */
function ForecastCard({ row }: { row: ForecastRow }) {
const { booking, rank, selected, blockedBy } = row;
const gov = booking.isGovernment;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: selected
? cardVar("edr-green", 3)
: cardVar("gray", 2),
background: selected
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
: "var(--mantine-color-white)",
opacity: selected ? 1 : 0.92,
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
<ThemeIcon
size={32}
radius="xl"
variant={selected && rank <= 3 ? "filled" : "light"}
color={gov ? "grape" : selected ? "edr-green" : "gray"}
style={{ flexShrink: 0, fontWeight: 800 }}
>
{gov ? (
<Crown size={15} />
) : (
<Text fw={800} size="sm">
{rank}
</Text>
)}
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={700} size="sm" truncate>
{booking.reference}
</Text>
<FreightIcon type={booking.freightType} />
{gov ? (
<Tooltip label="Government — boards first" withArrow>
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
<Crown size={10} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Stack>
</Group>
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
{/* score */}
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
<Trophy size={12} color={cardVar("edr-green", 6)} />
<Text fw={800} size="sm" c="edr-green.7">
{booking.priorityScore}
</Text>
</Group>
{/* wagons + weight this booking adds */}
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Group>
<Text size="xs" c="dimmed" w={64} ta="right">
{fmtTons(booking.weightTons)}
</Text>
{/* verdict */}
<Box w={150} style={{ textAlign: "right" }}>
{selected ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<Sparkles size={11} />}
>
Would board
</Badge>
) : (
<Tooltip
label={
blockedBy
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
: "Below the capacity line"
}
withArrow
>
<Badge variant="light" color="gray" radius="sm">
Waiting list
</Badge>
</Tooltip>
)}
</Box>
</Group>
</Group>
</Paper>
);
}
/** Cut line between the simulated batch and the simulated waiting list. */
function CutLine({ full }: { full: boolean }) {
return (
<Group gap="xs" my={2} wrap="nowrap">
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
<Group gap={6} wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
<Layers size={12} />
</ThemeIcon>
<Text size="xs" fw={700} c="orange.7">
Forecast capacity line{full ? " · TRAIN FULL" : ""}
</Text>
</Group>
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
</Group>
);
}
/**
* Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the
* current pool and shows the projected winners + waiting list BEFORE document
* review closes. Not the real selection — the engine commits that when staff run
* the batch after the review window ends.
*/
export function ForecastPanel({ data, bookings }: Props) {
const limits = useMemo(() => limitsFromDetail(data), [data]);
const sim = useMemo(
() => simulateBatch(bookings, limits),
[bookings, limits],
);
const noCaps =
limits.maxWagons == null &&
limits.maxWeightTons == null &&
limits.maxLengthMeters == null;
return (
<Stack gap="lg">
{/* Header + explainer */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
<Group gap="sm">
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
<FlaskConical size={18} />
</ThemeIcon>
<Stack gap={2}>
<Group gap={8}>
<Text fw={700}>Forecast batch (simulated)</Text>
<Badge variant="light" color="violet" radius="sm" size="sm">
Preview
</Badge>
</Group>
<Text size="xs" c="dimmed" maw={520}>
What the batch engine would pick if it ran now greedy fill by
priority until the train is full. The real selection happens when
document review ends and staff run the batch.
</Text>
</Stack>
</Group>
<Group gap="lg">
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="edr-green.7">
{sim.selected.length}
</Text>
<Text size="xs" c="dimmed">
would board
</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="gray.7">
{sim.waiting.length}
</Text>
<Text size="xs" c="dimmed">
waiting list
</Text>
</Stack>
</Group>
</Group>
{/* Three capacity axes */}
<Group gap="lg" align="flex-end" wrap="wrap">
<AxisMeter
icon={TrainFront}
label="Wagon slots"
used={sim.usedWagons}
cap={limits.maxWagons}
fmt={(n) => `${n}`}
color="edr-green"
/>
<AxisMeter
icon={Scale}
label="Max pull weight"
used={sim.usedWeightTons}
cap={limits.maxWeightTons}
fmt={fmtTons}
color="orange"
/>
<AxisMeter
icon={Ruler}
label="Train length"
used={sim.usedLengthMeters}
cap={limits.maxLengthMeters}
fmt={fmtMeters}
color="blue"
/>
</Group>
{noCaps ? (
<Alert
color="yellow"
mt="md"
radius="md"
icon={<XCircle size={16} />}
>
No locomotive / capacity limits on this schedule yet forecast can't
draw the capacity line. Assign a locomotive to simulate the fill.
</Alert>
) : null}
</Paper>
{sim.rows.length === 0 ? (
<Paper radius="lg" withBorder p="xl">
<Text c="dimmed" ta="center">
No eligible bookings to forecast yet.
</Text>
</Paper>
) : (
<Stack gap={6}>
{/* WOULD BOARD */}
{sim.selected.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon
size="sm"
radius="sm"
variant="light"
color="edr-green"
>
<Sparkles size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected batch{" "}
<Text span c="dimmed" fw={500}>
({sim.selected.length}) top priority, fits capacity
</Text>
</Text>
</Group>
{sim.selected.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
<CutLine full={sim.full} />
{/* WAITING LIST */}
{sim.waiting.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Layers size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected waiting list{" "}
<Text span c="dimmed" fw={500}>
({sim.waiting.length}) boards only if a slot frees up
</Text>
</Text>
</Group>
{sim.waiting.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
{/* INELIGIBLE (expired / pending contract) */}
{sim.ineligible.length > 0 ? (
<Text size="xs" c="dimmed" mt={4}>
{sim.ineligible.length} booking
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
(expired or contract not signed).
</Text>
) : null}
</Stack>
)}
</Stack>
);
}
export default ForecastPanel;

View File

@@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
Box,
Group,
Paper,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
@@ -15,9 +16,11 @@ import {
Clock,
Container,
Crown,
FlaskConical,
Hourglass,
Layers,
ListOrdered,
Radio,
TrainFront,
Trophy,
XCircle,
@@ -30,6 +33,8 @@ import type {
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast";
/**
* Priority Tracking tab — live, glanceable ranking of every booking on this
@@ -266,6 +271,15 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
const phase = data.windowPhase;
const isPayPhase = phase === "PAYMENT";
// Before the batch is committed (pre-window / open / doc-review) the real
// selection doesn't exist yet — offer a simulated forecast of who WOULD board.
// Default to it while it's live; let staff flip to the current live state.
const forecastAvailable = forecastIsLive(phase);
const [view, setView] = useState<"forecast" | "live">(
forecastAvailable ? "forecast" : "live",
);
const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
// backend uses). The board already returns them in this order, but re-sort
@@ -320,8 +334,51 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
let rankNo = 0;
const viewToggle = forecastAvailable ? (
<SegmentedControl
value={view}
onChange={(v) => setView(v as "forecast" | "live")}
size="sm"
radius="md"
data={[
{
value: "forecast",
label: (
<Group gap={6} wrap="nowrap">
<FlaskConical size={13} />
<Text size="xs" fw={600}>
Forecast
</Text>
</Group>
),
},
{
value: "live",
label: (
<Group gap={6} wrap="nowrap">
<Radio size={13} />
<Text size="xs" fw={600}>
Live state
</Text>
</Group>
),
},
]}
/>
) : null;
if (showForecast) {
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
<ForecastPanel data={data} bookings={ranked} />
</Stack>
);
}
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
{/* Header: phase + capacity meter */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md">

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

@@ -0,0 +1,189 @@
import type {
BatchBoardBookingDetail,
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
/**
* Client-side forecast of what the batch engine WOULD select if it ran right now.
*
* The real selection only happens once the document-review window closes and staff
* hit "run batch". Before that, operations can only see the *current* per-booking
* state (READY / SELECTED / …). This module simulates the engine's greedy fill so
* the board can show the likely winners + waiting list live, during OPEN and
* DOC_REVIEW, before anything is committed.
*
* It mirrors the engine (booking-batch.service): rank government-first, then
* priority score desc, then oldest booked; greedily board each booking while it
* fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and
* train length (metres). The first booking that busts any axis, and everyone after
* it, drops to the waiting list. Purely a projection; the server stays the source
* of truth for the real run.
*/
export interface ForecastLimits {
/** Wagon-slot cap (schedule.maxWagons), or null if unknown. */
maxWagons: number | null;
/** Locomotive max pull weight in tons, or null. */
maxWeightTons: number | null;
/** Max train length in metres, or null. */
maxLengthMeters: number | null;
}
/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */
export type BlockingAxis = "wagons" | "weight" | "length";
export interface ForecastRow {
booking: BatchBoardBookingDetail;
/** 1-based rank across the whole eligible pool. */
rank: number;
/** True → boards in the simulated batch; false → simulated waiting list. */
selected: boolean;
/** Cumulative wagons/weight/length AFTER this booking (only when selected). */
cumulativeWagons: number;
cumulativeWeightTons: number;
cumulativeLengthMeters: number;
/** If not selected, the first axis that would have overflowed. */
blockedBy: BlockingAxis | null;
}
export interface ForecastResult {
rows: ForecastRow[];
selected: ForecastRow[];
waiting: ForecastRow[];
/** Bookings excluded from the sim entirely (expired / no signed contract). */
ineligible: BatchBoardBookingDetail[];
limits: ForecastLimits;
/** Totals of the simulated batch. */
usedWagons: number;
usedWeightTons: number;
usedLengthMeters: number;
/** True once any axis is at/over its cap — train is "full" in the sim. */
full: boolean;
}
/** Engine rank order: government first, then priority desc, then oldest booked. */
export function rankBookings(
bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}
/**
* A booking can compete in the batch only once its contract is signed. Expired
* bookings and pending-contract bookings never board, so they're pulled out of the
* sim (surfaced separately so they don't vanish from the board).
*/
function isEligible(b: BatchBoardBookingDetail): boolean {
return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT";
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */
function overflows(used: number, add: number, cap: number | null): boolean {
return cap != null && used + add > cap;
}
export function simulateBatch(
bookings: BatchBoardBookingDetail[],
limits: ForecastLimits,
): ForecastResult {
const ranked = rankBookings(bookings);
const eligible = ranked.filter(isEligible);
const ineligible = ranked.filter((b) => !isEligible(b));
const rows: ForecastRow[] = [];
let wagons = 0;
let weight = 0;
let length = 0;
// Once the train is full we stop boarding, but keep ranking the rest as waiting.
let full = false;
eligible.forEach((booking, i) => {
let blockedBy: BlockingAxis | null = null;
if (!full) {
if (overflows(wagons, booking.wagons, limits.maxWagons))
blockedBy = "wagons";
else if (overflows(weight, booking.weightTons, limits.maxWeightTons))
blockedBy = "weight";
else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters))
blockedBy = "length";
}
// Strict fill: the first booking that doesn't fit closes the train, so lower-
// priority bookings can't leapfrog it even if they'd individually fit. Matches
// the engine's greedy pass.
const selected = !full && blockedBy === null;
if (selected) {
wagons += booking.wagons;
weight = round2(weight + booking.weightTons);
length = round2(length + booking.lengthMeters);
} else {
full = true;
}
rows.push({
booking,
rank: i + 1,
selected,
cumulativeWagons: selected ? wagons : 0,
cumulativeWeightTons: selected ? weight : 0,
cumulativeLengthMeters: selected ? length : 0,
blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)),
});
});
return {
rows,
selected: rows.filter((r) => r.selected),
waiting: rows.filter((r) => !r.selected),
ineligible,
limits,
usedWagons: wagons,
usedWeightTons: weight,
usedLengthMeters: length,
full,
};
}
/** When the train closed on an earlier booking, name the tightest axis for the hint. */
function firstBindingAxis(limits: ForecastLimits): BlockingAxis {
if (limits.maxWagons != null) return "wagons";
if (limits.maxWeightTons != null) return "weight";
return "length";
}
/** Pull the three capacity caps off the board detail response. */
export function limitsFromDetail(
data: BatchBoardScheduleDetail,
): ForecastLimits {
return {
maxWagons: data.capacity.maxWagons ?? null,
maxWeightTons:
data.capacity.maxWeightTons ??
data.locomotive?.maxPullWeightTons ??
null,
maxLengthMeters:
data.capacity.maxLengthMeters ??
data.locomotive?.maxTrainLengthMeters ??
null,
};
}
/**
* The forecast is meaningful before the batch is committed — i.e. while bookings
* are still being taken or reviewed. Once the engine has run (PAYMENT onward) the
* real per-booking state is the truth, so we stop showing the projection.
*/
export function forecastIsLive(
phase: BatchBoardScheduleDetail["windowPhase"],
): boolean {
return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW";
}

View File

@@ -11,6 +11,7 @@ import {
Table,
Tabs,
Text,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
@@ -22,7 +23,7 @@ import {
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
@@ -36,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' },
@@ -45,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();
@@ -76,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({
@@ -90,12 +99,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const requestSign = async () => {
try {
const res = await warehouseService.requestHandoverSignature(bookingId as string);
queryClient.invalidateQueries({ queryKey: itemsKey });
if (res.alreadySigned) {
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
} else {
toast({
title: 'Handover not signed',
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
});
}
} catch (e) {
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
}
};
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
}
};
@@ -163,16 +189,28 @@ 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 && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
{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}
withArrow
multiline
w={240}
>
Exit Paper
</Button>
<Button
size="compact-xs"
variant="light"
color={i.handoverSigned ? 'orange' : 'gray'}
leftSection={<FileText size={13} />}
onClick={() =>
i.handoverSigned
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
: requestSign()
}
>
Exit Paper
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>

View File

@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
toast({ title: 'Warehouse updated' });
} else {
await createMutation.mutateAsync(payload);
await createMutation.mutateAsync({ ...payload, status: form.status });
toast({ title: 'Warehouse created' });
}
onClose();
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
allowDeselect={false}
/>
{isEdit && (
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
)}
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
</Group>
<TextInput

View File

@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
/** Inventory table + all lifecycle actions (advance / move / history). */
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
const storeInventory = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
const stored = await storeMutation.mutateAsync(item.id);
const stored = await storeMutation.mutateAsync({ id: item.id });
toast({
title: 'Inventory stored',
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
switch (action) {
case 'store':
return storeInventory(item);
case 'reserve':
setReserveItem(item);
return;
case 'ready-for-loading':
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
case 'load':
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
opened={Boolean(reserveItem)}
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
<InventoryHistoryModal
opened={Boolean(historyItem)}

View File

@@ -7,6 +7,7 @@ import {
Checkbox,
Group,
Loader,
Menu,
Modal,
NumberInput,
ScrollArea,
@@ -20,6 +21,7 @@ import {
Tooltip,
} from '@mantine/core';
import {
ArrowRightLeft,
ChevronDown,
ChevronRight,
ClipboardCheck,
@@ -27,6 +29,8 @@ import {
FileText,
History,
Info,
MapPin,
MoreHorizontal,
PackageCheck,
PackageOpen,
PackageSearch,
@@ -61,7 +65,6 @@ import type {
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { InventoryWorkbench } from './InventoryWorkbench';
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Eye size={16} />
</ActionIcon>
</Tooltip>
{r.currentStatus === 'UNLOADED' && (
{/* Primary stage action stays visible; the rest live under the kebab. */}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
color="yellow"
leftSection={<Truck size={14} />}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
Store
</Button>
)}
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="orange"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
>
Ready Pickup
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
<>
<Button
size="compact-xs"
variant="light"
color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
</>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"
variant="light"
color="green"
loading={busyId === r.id}
onClick={() => setLoadTruckItem(toInventoryItem(r))}
>
Truck_dispatch
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Exit Paper
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={() => setDeliverItem(toInventoryItem(r))}
>
Deliver
</Button>
)}
{r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)}
>
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
</Button>
)}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
<Tooltip label="Storage / fee preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
<PackageCheck size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
<History size={16} />
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{r.currentStatus === 'UNLOADED' && (
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
Store
</Menu.Item>
)}
{r.currentStatus !== 'UNLOADED' && (
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
Move
</Menu.Item>
)}
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
Ready for pickup
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!r.hasAssignedTruck}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.hasAssignedTruck
? r.releaseOrderReference
? 'Truck leaving'
: 'Truck arrival'
: 'Truck arrival — assign a truck first'}
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
Exit paper
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
)}
{r.inspectionStatus === 'PASSED' && (
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
</Menu.Item>
)}
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
Storage / fee preview
</Menu.Item>
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
History
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
inventoryId={feeItem?.id ?? null}
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
<TruckDispatchModal
opened={Boolean(loadTruckItem)}
onClose={() => setLoadTruckItem(null)}
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
<ContainerItemsModal
opened={Boolean(containerItemsItem)}
onClose={() => setContainerItemsItem(null)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
containerNumber?: string | null;
}
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
].map(([powerPlate, trailerPlate], index) => ({
value: powerPlate,
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
trailerPlate,
}));
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
// Per-container cargo weights — the truck's net (gross tare) must equal the
// total cargo weight of the containers selected as loaded on it.
const { data: containerWeights = [] } = useQuery({
queryKey: ['release-container-weights', bookingId],
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: t.truckType,
})),
];
const truckSelectOptions = [
...assignedTruckOptions,
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
value: t.value,
label: t.label,
trailerPlate: t.trailerPlate,
driverName: '',
driverPhone: '',
truckType: '',
})),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
// portal) are selectable. No global fleet list — if nothing is assigned, the
// operator types the plate manually in the field below.
const truckSelectOptions = assignedTruckOptions;
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
// Which containers ride this truck, and their combined cargo weight. When the
// booking has container weights, that sum is the authoritative net; the
// operator selects the containers loaded on the truck at exit.
const hasContainerWeights = containerWeights.length > 0;
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
const containerSelectData = containerWeights.map((c) => ({
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
}));
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedCargoWeight = Number(
selectedContainerNumbers
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
.toFixed(3),
);
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
const systemNetWeight = useContainerNet
? selectedCargoWeight
: item?.weight == null
? netWeight
: Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
@@ -336,23 +341,27 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
Truck is not assigned yet assign a last-mile or customer truck, or enter the plate manually below.
</Alert>
)}
<Select
label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below"
searchable
clearable
data={truckSelectOptions}
disabled={isTruckIdentityLocked}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}}
/>
{truckSelectOptions.length > 0 && (
<Select
label="Assigned first / last-mile truck"
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}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}}
/>
)}
<Group grow>
<TextInput
label="Truck plate number"
@@ -376,30 +385,51 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group>
<Group grow>
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>
</Stack>
<Group grow align="flex-start">
{hasContainerWeights ? (
<MultiSelect
label="Containers on this truck"
description={
isExitStep
? 'Select the containers loaded on this truck — their cargo weight must match gross tare.'
: 'Containers this truck will carry.'
}
placeholder="Select containers"
searchable
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
/>
) : (
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>
</Stack>
)}
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Recorded net weight (system t)" min={0} value={systemNetWeight} readOnly />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}
value={systemNetWeight}
readOnly
/>
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>

View File

@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
import { Info } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface StoreInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
/**
* Store an unloaded import item. The operator may pick warehouse → yard → zone
* explicitly; leaving them blank falls back to the backend auto allocation.
*/
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
const { toast } = useToast();
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
}
}, [opened]);
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
);
const yardsQuery = useQuery(
api.warehouses.listYards.queryOptions({
input: { warehouseId },
enabled: Boolean(warehouseId),
}),
);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId },
enabled: Boolean(yardId),
}),
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const yardOptions = useMemo(
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
);
const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const isManual = Boolean(warehouseId || yardId || zoneId);
const manualComplete = Boolean(warehouseId && yardId && zoneId);
const handleSubmit = async () => {
if (!item) return;
if (isManual && !manualComplete) {
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
return;
}
try {
await storeMutation.mutateAsync({
id: item.id,
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
});
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
blank to let the system auto-allocate by rule / available capacity.
</Text>
</Alert>
<Select
label="Warehouse"
placeholder="Auto-allocate"
searchable
clearable
data={warehouseOptions}
value={warehouseId || null}
onChange={(v) => {
setWarehouseId(v ?? '');
setYardId('');
setZoneId('');
}}
/>
<Select
label="Yard"
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
searchable
clearable
disabled={!warehouseId}
data={yardOptions}
value={yardId || null}
onChange={(v) => {
setYardId(v ?? '');
setZoneId('');
}}
/>
<Select
label="Zone"
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
searchable
clearable
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
{manualComplete ? 'Store here' : 'Store (auto)'}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',

View File

@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
};
/**
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
*/
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
if (responseData instanceof Blob) {
try {
const text = await responseData.text();
const parsed = JSON.parse(text) as Record<string, unknown>;
const raw = parsed?.message ?? parsed?.error;
if (Array.isArray(raw)) return raw.join(', ');
if (raw) return String(raw);
} catch {
/* not JSON — fall through */
}
}
return extractErrorMessage(error, fallback);
};

View File

@@ -146,6 +146,7 @@ export const FREIGHT_PERMS = {
},
tracking: {
view: "edr_freight_app:tracking:view",
manage: "edr_freight_app:tracking:manage",
},
fuel: {
view: "edr_freight_app:fuel:view",

View File

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

View File

@@ -128,7 +128,16 @@ function DirectionIcon({ direction }: { direction: string }) {
);
}
export default function DocumentClearanceListPage() {
export default function DocumentClearanceListPage({
opsMode = false,
}: {
/**
* true → Operations self-clearance queue: NON-customs bookings whose
* per-booking clearance docs the operations team reviews (GENERAL Path A).
* false → legacy GL queue: customs bookings only.
*/
opsMode?: boolean;
}) {
const navigate = useNavigate();
const [pageTab, setPageTab] = useState<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
@@ -139,7 +148,7 @@ export default function DocumentClearanceListPage() {
const isHistory = pageTab === "history";
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["clearance", "list", isHistory],
queryKey: ["clearance", "list", isHistory, opsMode],
queryFn: () =>
bookingsService.list({
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
@@ -148,8 +157,10 @@ export default function DocumentClearanceListPage() {
});
const allRows = useMemo(() => {
// GL clearance queue: customs bookings only
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
// opsMode: self-clearance (non-customs) bookings; else customs bookings only.
const rows = (data?.items ?? [])
.map(toClearanceRow)
.filter((r) => (opsMode ? !r.hasCustoms : r.hasCustoms));
if (isHistory) {
return [...rows].sort((a, b) => {
@@ -159,7 +170,7 @@ export default function DocumentClearanceListPage() {
});
}
return rows;
}, [data?.items, isHistory]);
}, [data?.items, isHistory, opsMode]);
const tabCounts = useMemo(
() => ({
@@ -310,8 +321,12 @@ export default function DocumentClearanceListPage() {
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
title={opsMode ? "Self-Clearance Review" : "Document Clearance"}
subtitle={
opsMode
? "Review the customer's own clearance documents per shipment booking, raise queries, and finalize."
: "Review customer documents, raise queries, and finalize clearance for each booking."
}
meta={statusBadge}
action={
<ActionIcon

View File

@@ -25,6 +25,7 @@ import {
PackagePlus,
RefreshCw,
Search,
Send,
ShieldCheck,
ShipWheel,
Table as TableIcon,
@@ -49,11 +50,13 @@ import {
useContractClearanceQueue,
useEtClearanceQueue,
} from "@/hooks/contracts/useContracts";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
type ViewMode = "table" | "cards";
type QueueTab = "all" | "et";
type QueueTab = "all" | "et" | "shipments";
interface ClearanceRow {
id: string;
@@ -194,6 +197,10 @@ export default function ContractClearanceListPage() {
const { user } = useAuth();
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canCreateBooking = hasPermission(
user,
FREIGHT_PERMS.contracts.createBooking,
);
const defaultQueue: QueueTab = canReview ? "all" : "et";
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
@@ -202,16 +209,29 @@ export default function ContractClearanceListPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
useContractClearanceQueue(queueTab === "all");
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments");
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
useEtClearanceQueue(queueTab === "et");
const {
data: bookingQueue,
isLoading: bookingsLoading,
isError: bookingsError,
isFetching: bookingsFetching,
refetch: refetchBookings,
} = useBookingEtClearanceQueue(queueTab === "shipments");
const data = queueTab === "et" ? etData : allData;
const isLoading = queueTab === "et" ? etLoading : allLoading;
const isError = queueTab === "et" ? etError : allError;
const isFetching = queueTab === "et" ? etFetching : allFetching;
const isFetching =
queueTab === "et"
? etFetching
: queueTab === "shipments"
? bookingsFetching
: allFetching;
const refetch = () => {
if (queueTab === "et") void refetchEt();
else if (queueTab === "shipments") void refetchBookings();
else void refetchAll();
};
@@ -239,9 +259,43 @@ export default function ContractClearanceListPage() {
),
});
}
if (canReview || canEt) {
opts.push({
value: "shipments",
label: (
<Group gap={6} wrap="nowrap">
<PackageCheck size={15} />
<Box visibleFrom="sm">Shipments</Box>
</Group>
),
});
}
return opts;
}, [canReview, canEt]);
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
const bookingRows = useMemo(() => {
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
id: b.id,
reference: b.reference,
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
originLabel: b.originYard?.name ?? "—",
destinationLabel: b.destinationYard?.name ?? "—",
tradeDirection: b.tradeDirection ?? "—",
freightType: b.freightType ?? "—",
status: b.status,
}));
const q = query.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q),
);
}, [bookingQueue, query]);
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow),
[data?.items],
@@ -269,7 +323,7 @@ export default function ContractClearanceListPage() {
);
}, [allRows, query]);
const total = rows.length;
const total = queueTab === "shipments" ? bookingRows.length : rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
@@ -410,16 +464,29 @@ export default function ContractClearanceListPage() {
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
<Group gap="sm" wrap="nowrap">
{canCreateBooking ? (
<Button
variant="filled"
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
onClick={() => navigate("/dashboard/shipment-requests")}
>
Shipment requests
</Button>
) : null}
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
}
/>
@@ -528,7 +595,14 @@ export default function ContractClearanceListPage() {
</Group>
</Box>
{view === "table" ? (
{queueTab === "shipments" ? (
<ShipmentBookingsTable
rows={bookingRows}
loading={bookingsLoading}
error={bookingsError}
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
/>
) : view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ClearanceRow, unknown>
columns={columns}
@@ -567,6 +641,143 @@ export default function ContractClearanceListPage() {
);
}
interface ShipmentBookingRow {
id: string;
reference: string;
customerLabel: string;
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
status: string;
}
const prettyStatus = (s: string) =>
s
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
return "gray";
};
/** GENERAL-contract shipment bookings currently in per-booking clearance. */
function ShipmentBookingsTable({
rows,
loading,
error,
onOpen,
}: {
rows: ShipmentBookingRow[];
loading: boolean;
error: boolean;
onOpen: (id: string) => void;
}) {
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<PackageCheck className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{row.original.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{row.original.customerLabel}
</p>
</div>
</div>
),
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Text size="sm" className="truncate">
{row.original.originLabel}
</Text>
<ArrowRight size={13} className="shrink-0 text-muted-foreground" />
<Text size="sm" className="truncate">
{row.original.destinationLabel}
</Text>
</Group>
),
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="gray" radius="sm">
{prettyStatus(row.original.tradeDirection)}
</Badge>
<Badge variant="outline" color="gray" radius="sm">
{prettyStatus(row.original.freightType)}
</Badge>
</Group>
),
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<Badge
variant="light"
color={shipmentStatusColor(row.original.status)}
radius="sm"
>
{prettyStatus(row.original.status)}
</Badge>
),
},
{
id: "chevron",
header: "",
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
if (!loading && !error && rows.length === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No shipment bookings in clearance.</Text>
</Stack>
);
}
return (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ShipmentBookingRow, unknown>
columns={columns}
data={rows}
status={loading ? "loading" : error ? "error" : "success"}
onRowClick={(row) => onOpen(row.id)}
containerClassName="border-0 shadow-none bg-transparent"
/>
</Box>
);
}
function ClearanceCardGrid({
rows,
loading,

View File

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

View File

@@ -1,62 +1,99 @@
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { ChevronRight, Ship } from "lucide-react";
import { ChevronRight, PackageCheck, Ship } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
const { data: bookingQueue, isLoading: bookingsLoading } =
useBookingDjClearanceQueue();
const contractItems = contractQueue?.items ?? [];
const bookingItems = bookingQueue ?? [];
return (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs contracts handed off to Djibouti GL."
subtitle="Customs contracts and shipment bookings handed off to Djibouti GL."
/>
{contractsLoading ? (
{contractsLoading || bookingsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{contractItems.length === 0 ? (
{contractItems.length === 0 && bookingItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet.
No Djibouti customs work yet.
</Text>
) : (
contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
<>
{contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Card>
))}
{bookingItems.map((b) => (
<Card
key={b.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/clearance/${b.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<PackageCheck
size={18}
className="text-[color:var(--freight-brand)]"
/>
<div>
<Text fw={700}>{b.reference}</Text>
<Text size="sm" c="dimmed">
{b.tradeDirection} · {b.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="blue">
Shipment
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Group>
</Card>
))
</Card>
))}
</>
)}
</Stack>
)}

View File

@@ -107,6 +107,10 @@ const normalizePayload = (values: Record<string, FormValue>) =>
.filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)),
);
/** Render a spec value inherited from the wagon type; em dash when the type isn't loaded. */
const fmtTypeSpec = (value: number | undefined | null, unit: string) =>
value == null ? '—' : `${Number(value)} ${unit}`;
const extractBackendErrors = (error: unknown) => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? responseData as Record<string, unknown> : undefined;
@@ -536,7 +540,7 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
tareWeightTons: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -587,7 +591,7 @@ export function WagonTypesCrudPage() {
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
tareWeightTons: '',
supportedLoadTypes: '',
isActive: true,
});
@@ -601,7 +605,7 @@ export function WagonTypesCrudPage() {
name: type.name ?? '',
capacityTons: type.capacityTons ?? 0,
lengthMeters: type.lengthMeters ?? 0,
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
tareWeightTons: type.tareWeightTons ?? '',
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
isActive: type.isActive,
});
@@ -610,10 +614,17 @@ export function WagonTypesCrudPage() {
const validateWagonType = () => {
const errors: Record<string, string> = {};
// normalizePayload strips empty strings, so a blank numeric field would be
// dropped from the payload rather than rejected. Each must be a positive
// number here — the API's @Min(0.001) agrees.
const positive = (value: FormValue) => Number.isFinite(Number(value)) && Number(value) > 0;
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
if (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0';
if (!positive(form.lengthMeters)) errors.lengthMeters = 'Length must be greater than 0';
if (!positive(form.tareWeightTons))
errors.tareWeightTons = 'Tare weight is required and must be greater than 0';
return errors;
};
@@ -710,6 +721,7 @@ export function WagonTypesCrudPage() {
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>Length (m)</MantineTable.Th>
<MantineTable.Th>Tare weight (tons)</MantineTable.Th>
<MantineTable.Th>Load types</MantineTable.Th>
<MantineTable.Th>Status</MantineTable.Th>
<MantineTable.Th ta="right">Actions</MantineTable.Th>
@@ -722,6 +734,7 @@ export function WagonTypesCrudPage() {
<MantineTable.Td>{type.name}</MantineTable.Td>
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
<MantineTable.Td>{type.tareWeightTons ?? '-'}</MantineTable.Td>
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
<MantineTable.Td>
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
@@ -750,7 +763,7 @@ export function WagonTypesCrudPage() {
))}
{!query.isLoading && filtered.length === 0 ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<MantineTable.Td colSpan={8}>
<Text ta="center" c="dimmed" py="xl">
No wagon types found.
</Text>
@@ -759,7 +772,7 @@ export function WagonTypesCrudPage() {
) : null}
{query.isLoading ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<MantineTable.Td colSpan={8}>
<Text ta="center" c="dimmed" py="xl">
Loading...
</Text>
@@ -815,10 +828,13 @@ export function WagonTypesCrudPage() {
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
/>
<NumberInput
label="Max wagons per train"
label="Tare weight (tons)"
description="Empty wagon weight — counts against the locomotive's pull limit alongside the cargo"
required
min={0}
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
value={form.tareWeightTons === '' || form.tareWeightTons == null ? '' : Number(form.tareWeightTons)}
error={fieldErrors.tareWeightTons}
onChange={(value) => setForm((current) => ({ ...current, tareWeightTons: value }))}
/>
<MantineSelect
label="Status"
@@ -910,7 +926,17 @@ export function WagonsCrudPage() {
? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})`
: '-',
},
{ key: 'maxPayloadWeight', label: 'Max payload' },
{
// Read-only: the spec lives on the wagon type, so it is displayed, never edited here.
key: 'tareWeight',
label: 'Tare weight',
render: (wagon) => fmtTypeSpec(wagon.wagonType?.tareWeightTons, 't'),
},
{
key: 'maxPayloadWeight',
label: 'Max payload',
render: (wagon) => fmtTypeSpec(wagon.wagonType?.capacityTons, 't'),
},
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
]}
fields={[
@@ -921,11 +947,6 @@ export function WagonsCrudPage() {
type: 'select',
required: true,
options: wagonTypeOptions,
onValueChange: (value, current) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
if (!selectedType || Number(current.maxPayloadWeight) > 0) return {};
return { maxPayloadWeight: Number(selectedType.capacityTons) };
},
},
{
key: 'currentLocationYardId',
@@ -934,8 +955,6 @@ export function WagonsCrudPage() {
required: true,
options: yardOptions,
},
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
{
key: 'status',
label: 'Status',
@@ -951,7 +970,7 @@ export function WagonsCrudPage() {
},
{ key: 'notes', label: 'Notes' },
]}
emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
emptyValues={{ wagonNumber: '', wagonTypeId: '', currentLocationYardId: '', status: 'AVAILABLE', notes: '' }}
/>
);
}
@@ -1118,6 +1137,16 @@ export function LocomotivesCrudPage() {
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
{
key: 'overageToleranceTons',
label: 'Weight tolerance (t)',
render: (locomotive) => locomotive.overageToleranceTons ?? '-',
},
{
key: 'overageToleranceMeters',
label: 'Length tolerance (m)',
render: (locomotive) => locomotive.overageToleranceMeters ?? '-',
},
]}
fields={[
{ key: 'code', label: 'Code', required: true },
@@ -1146,6 +1175,11 @@ export function LocomotivesCrudPage() {
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
// of the weight tolerance.
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
@@ -1157,6 +1191,8 @@ export function LocomotivesCrudPage() {
status: 'AVAILABLE',
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
overageToleranceTons: '',
overageToleranceMeters: '',
powerKw: '',
tractionForceKn: '',
maxSpeedKmh: '',

View File

@@ -27,6 +27,8 @@ import {
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { vehiclesService } from "@/services/vehicles.service";
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
import { freightBrand } from "@/theme/freight-brand";
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
export function TrackingPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
const [mapsReady, setMapsReady] = useState(false);
@@ -288,9 +292,11 @@ export function TrackingPage() {
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
</div>
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
{canManage && (
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
Register tracker
</Button>
)}
</Group>
<Grid>
@@ -358,9 +364,11 @@ export function TrackingPage() {
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
{selected.online ? "Live" : "Offline"}
</Badge>
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
{canManage && (
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
<Trash2 size={16} />
</ActionIcon>
)}
</Group>
</Group>
@@ -393,6 +401,7 @@ export function TrackingPage() {
data={vehicleOptions}
value={selected.vehicleId ?? null}
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
disabled={!canManage}
searchable
clearable
/>
@@ -421,14 +430,16 @@ export function TrackingPage() {
<Table.Td align="right">
<Group gap={6} justify="flex-end" wrap="nowrap">
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
{canManage && (
<ActionIcon
variant="subtle"
size="sm"
aria-label="Edit tracker"
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
>
<Pencil size={15} />
</ActionIcon>
)}
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
{ id: "overageToleranceTons", header: "Weight tolerance (t)", accessorKey: "overageToleranceTons", format: "number" },
{ id: "overageToleranceMeters", header: "Length tolerance (m)", accessorKey: "overageToleranceMeters", format: "number" },
],
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
formFields: [
@@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
// Scheduling accepts a consist up to (max + tolerance) on each axis: 37 PW2
// wagons gross 3,522.4T against a 3,500T pull limit and only board because
// of the weight tolerance.
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
@@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
currentYardId: "",
maxPullWeightTons: 2500,
maxTrainLengthMeters: 760,
overageToleranceTons: "",
overageToleranceMeters: "",
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
@@ -254,17 +263,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
cardSubtitleKey: "currentYard",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
columns: [
// Tare weight and payload capacity are not wagon columns — they belong to the
// wagon type and are shown through it (see WagonsCrudPage in FleetCrudPages).
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
@@ -272,8 +280,6 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
emptyValues: {
wagonNumber: "",
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
currentYardId: "",
status: Freight.WagonStatus.Available,
notes: "",

View File

@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
// details captured at arrival (plate, driver, tare, gate-in).
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,

View File

@@ -279,7 +279,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
{ id: "tareWeightTons", header: "Tare (t)", accessorKey: "tareWeightTons", format: "number" },
{
id: "supportedLoadTypes",
header: "Load types",
@@ -291,11 +291,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
// The locomotive's pull limit is a GROSS limit, so capacity planning charges
// `cargo + wagons × tare` against it. The API rejects a create without this.
{
name: "maxWagonsPerTrain",
label: "Max wagons per train",
name: "tareWeightTons",
label: "Tare weight (tons)",
type: "number",
optional: true,
required: true,
description: "Empty wagon weight — counts against the locomotive's pull limit alongside the cargo",
},
{
name: "supportedLoadTypes",

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

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