mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
74
apps/edr-freight-api/docs/priority-batch-window-flow.md
Normal file
74
apps/edr-freight-api/docs/priority-batch-window-flow.md
Normal 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`).
|
||||||
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal 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).
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon spec belongs to the wagon TYPE, not to each physical wagon.
|
||||||
|
*
|
||||||
|
* `wagons.tare_weight` and `wagons.max_payload_weight` duplicated
|
||||||
|
* `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows,
|
||||||
|
* with nothing keeping them in step. They had drifted completely: every wagon
|
||||||
|
* disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a
|
||||||
|
* third disagreed on payload (NW5 wagons claiming 22T–70T against a flat 70T).
|
||||||
|
* None of those numbers came from the railway.
|
||||||
|
*
|
||||||
|
* Nothing reads them for capacity — that math resolves tare and capacity through
|
||||||
|
* `wagon_type_id` — so dropping them removes a source of fiction rather than a
|
||||||
|
* source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is
|
||||||
|
* always reachable.
|
||||||
|
*
|
||||||
|
* A wagon re-tared after repair would need a nullable override column on
|
||||||
|
* `wagons` falling back to the type; deliberately not added, since no such
|
||||||
|
* per-wagon value exists today.
|
||||||
|
*/
|
||||||
|
export class DropWagonSpecColumns2080000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeEvents() {
|
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> = {}) {
|
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -162,7 +164,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(events.emit).toHaveBeenCalledWith(
|
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||||
"booking.invoice.paid",
|
"booking.invoice.paid",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
invoiceId: "inv-1",
|
invoiceId: "inv-1",
|
||||||
@@ -197,6 +199,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
|
|
||||||
expect(mg.update).not.toHaveBeenCalled();
|
expect(mg.update).not.toHaveBeenCalled();
|
||||||
expect(events.emit).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.emit).not.toHaveBeenCalled();
|
||||||
|
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
|
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.balanceAmount).toBe(0);
|
||||||
expect(updated.paidAt).toBeInstanceOf(Date);
|
expect(updated.paidAt).toBeInstanceOf(Date);
|
||||||
expect(mg.update).toHaveBeenCalled();
|
expect(mg.update).toHaveBeenCalled();
|
||||||
expect(events.emit).toHaveBeenCalledWith(
|
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||||
"warehouse.invoice.paid",
|
"warehouse.invoice.paid",
|
||||||
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
||||||
);
|
);
|
||||||
@@ -296,3 +300,80 @@ describe("BillingService.recordPayment", () => {
|
|||||||
expect(mg.update).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -825,6 +825,13 @@ export class BillingService {
|
|||||||
type?: string,
|
type?: string,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<Invoice | null> {
|
): 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 mg = manager ?? this.dataSource.manager;
|
||||||
const invoice = await mg.findOne(Invoice, {
|
const invoice = await mg.findOne(Invoice, {
|
||||||
where: {
|
where: {
|
||||||
@@ -842,7 +849,7 @@ export class BillingService {
|
|||||||
Freight.InvoiceStatus.Expired,
|
Freight.InvoiceStatus.Expired,
|
||||||
"expired",
|
"expired",
|
||||||
{},
|
{},
|
||||||
mg,
|
manager,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
|||||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
||||||
import { CustomerTruckService } from './customer-truck.service';
|
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 { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||||
import { ContainerReceiptService } from './container-receipt.service';
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
@@ -81,6 +83,60 @@ import {
|
|||||||
hasFreightPermission,
|
hasFreightPermission,
|
||||||
} from "../../common/freight-permission.util";
|
} 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")
|
@ApiTags("bookings")
|
||||||
@Controller("bookings")
|
@Controller("bookings")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -94,6 +150,8 @@ export class BookingsController {
|
|||||||
private readonly bookingClearanceService: BookingClearanceService,
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
private readonly customerTruckService: CustomerTruckService,
|
private readonly customerTruckService: CustomerTruckService,
|
||||||
private readonly containerReceiptService: ContainerReceiptService,
|
private readonly containerReceiptService: ContainerReceiptService,
|
||||||
|
private readonly firstMileService: FirstMileService,
|
||||||
|
private readonly lastMileService: LastMileService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -290,6 +348,33 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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')
|
@Post(':id/customer-truck-assignment')
|
||||||
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
|
||||||
async assignCustomerTruck(
|
async assignCustomerTruck(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
|
|||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||||
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingInvoiceService } from './booking-invoice.service';
|
import { BookingInvoiceService } from './booking-invoice.service';
|
||||||
// import { BookingPaymentController } from './booking-payment.controller';
|
// import { BookingPaymentController } from './booking-payment.controller';
|
||||||
@@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
|
forwardRef(() => LastMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
|
|||||||
@@ -546,6 +546,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
if (!statuses.length) return [];
|
if (!statuses.length) return [];
|
||||||
return this.repository.find({
|
return this.repository.find({
|
||||||
where: { status: In(statuses) },
|
where: { status: In(statuses) },
|
||||||
|
relations: {
|
||||||
|
company: true,
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
},
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -993,6 +998,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
@@ -1023,6 +1029,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||||
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||||
@@ -1061,6 +1068,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||||
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||||
@@ -1125,6 +1133,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
@@ -1138,6 +1147,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|||||||
@@ -1424,6 +1424,20 @@ export class BookingsService {
|
|||||||
schedule?.status ?? null;
|
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;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export class CustomerTruckService {
|
|||||||
|
|
||||||
if (requested.length) {
|
if (requested.length) {
|
||||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
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) {
|
for (const n of requested) {
|
||||||
if (!bookingNumbers.includes(n)) {
|
if (!bookingNumbers.includes(n)) {
|
||||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
@@ -303,6 +312,13 @@ export class CustomerTruckService {
|
|||||||
if (assignment.departedAt) {
|
if (assignment.departedAt) {
|
||||||
throw new ConflictException('This truck has already left — its load is locked');
|
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());
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
if (!requested.length) {
|
if (!requested.length) {
|
||||||
@@ -324,12 +340,16 @@ export class CustomerTruckService {
|
|||||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
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(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
requested.map((containerNumber) =>
|
requested.map((containerNumber) =>
|
||||||
manager.getRepository(CustomerTruckContainer).create({
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
bookingId,
|
bookingId,
|
||||||
containerNumber,
|
containerNumber,
|
||||||
|
loadedAt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||||
containerNumber!: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -181,6 +181,10 @@ export class ClearanceMilestoneService {
|
|||||||
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
|
* 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 on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
|
||||||
* milestone metadata so the timeline shows it.
|
* 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(
|
async assignRisk(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
@@ -188,9 +192,22 @@ export class ClearanceMilestoneService {
|
|||||||
userId?: string,
|
userId?: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
): Promise<ClearanceMilestone> {
|
): Promise<ClearanceMilestone> {
|
||||||
|
await this.assertT1Closed(bookingId);
|
||||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
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
|
* Advise duty & tax (amount + declaration serial) and complete the
|
||||||
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
|
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ export class ContractBookingService {
|
|||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
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:
|
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||||
// there is no window and no date — staff accept them onto a train at
|
// 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.
|
// 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
|
// 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
|
// created while the route's booking window is open — import: the day's window
|
||||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||||
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
// export: within exportBookingLeadHours of departure. Bookings that enter the
|
||||||
// enter clearance first and are scheduled later, so they are not gated here.
|
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
|
||||||
if (!generalCustoms && !isIntercity) {
|
// are scheduled later, so they are not gated here.
|
||||||
|
if (!generalCustoms && !generalSelfClear && !isIntercity) {
|
||||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
@@ -174,7 +184,10 @@ export class ContractBookingService {
|
|||||||
companyProfileId: contract.companyProfileId ?? null,
|
companyProfileId: contract.companyProfileId ?? null,
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
governmentInstitution: contract.governmentInstitution ?? null,
|
governmentInstitution: contract.governmentInstitution ?? null,
|
||||||
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
status:
|
||||||
|
generalCustoms || generalSelfClear
|
||||||
|
? 'AWAITING_DOCUMENTS'
|
||||||
|
: 'OPERATION_REQUEST_PENDING',
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -259,9 +272,10 @@ export class ContractBookingService {
|
|||||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||||
booking.id,
|
booking.id,
|
||||||
);
|
);
|
||||||
const intendedStatus = generalCustoms
|
const intendedStatus =
|
||||||
? 'AWAITING_DOCUMENTS'
|
generalCustoms || generalSelfClear
|
||||||
: 'OPERATION_REQUEST_PENDING';
|
? 'AWAITING_DOCUMENTS'
|
||||||
|
: 'OPERATION_REQUEST_PENDING';
|
||||||
if (
|
if (
|
||||||
withContainers &&
|
withContainers &&
|
||||||
freightType === 'CONTAINER' &&
|
freightType === 'CONTAINER' &&
|
||||||
|
|||||||
@@ -632,16 +632,15 @@ export class ContractTransitionService {
|
|||||||
contract.customsClearingEnabled ?? false,
|
contract.customsClearingEnabled ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
|
||||||
// level: there is no contract clearance cycle. The contract just becomes
|
// both paths. Customs (Path B): the customer files shipment requests, GL
|
||||||
// active; the customer then files shipment requests and GL books + clears
|
// books each one and the booking carries its own clearance. Self-clearance
|
||||||
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
// (Path A): the customer books, then uploads the clearance docs on that
|
||||||
// cycle below.
|
// booking for Operations to review. Only ONE_TIME contracts keep the
|
||||||
const isGeneralCustoms =
|
// contract-level cycle below.
|
||||||
contract.contractKind === 'GENERAL' &&
|
const isGeneral = contract.contractKind === 'GENERAL';
|
||||||
Boolean(contract.customsClearingEnabled);
|
|
||||||
|
|
||||||
if (clearanceCode && !isGeneralCustoms) {
|
if (clearanceCode && !isGeneral) {
|
||||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
// 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
|
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||||
// distinction is enforced at the review/finalize endpoints, not here.
|
// distinction is enforced at the review/finalize endpoints, not here.
|
||||||
@@ -652,8 +651,8 @@ export class ContractTransitionService {
|
|||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} else {
|
||||||
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||||
// clears per booking). Ready for shipment requests / direct booking.
|
// (which clears per booking). Ready for shipment requests / direct booking.
|
||||||
updates.status =
|
updates.status =
|
||||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/** One physical container under a booking line — entered at booking time. */
|
/** One physical container under a booking line — entered at booking time. */
|
||||||
export class CreateContainerUnitDto {
|
export class CreateContainerUnitDto {
|
||||||
@ApiProperty()
|
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||||
@IsString()
|
@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;
|
containerNumber!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
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 { GpsTrackingService } from './gps-tracking.service';
|
||||||
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
|
||||||
|
|
||||||
@ApiTags('gps-tracking')
|
@ApiTags('gps-tracking')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('gps')
|
@Controller('gps')
|
||||||
@FleetView()
|
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||||
export class GpsTrackingController {
|
export class GpsTrackingController {
|
||||||
constructor(private readonly gps: GpsTrackingService) {}
|
constructor(private readonly gps: GpsTrackingService) {}
|
||||||
|
|
||||||
@@ -44,21 +45,21 @@ export class GpsTrackingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('devices')
|
@Post('devices')
|
||||||
@FleetManage()
|
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||||
@ApiOperation({ summary: 'Register a GPS tracker' })
|
@ApiOperation({ summary: 'Register a GPS tracker' })
|
||||||
register(@Body() dto: RegisterDeviceDto) {
|
register(@Body() dto: RegisterDeviceDto) {
|
||||||
return this.gps.registerDevice(dto);
|
return this.gps.registerDevice(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('devices/:id')
|
@Patch('devices/:id')
|
||||||
@FleetManage()
|
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||||
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
|
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
|
||||||
return this.gps.updateDevice(id, dto);
|
return this.gps.updateDevice(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('devices/:id')
|
@Delete('devices/:id')
|
||||||
@FleetManage()
|
@BookingStaff(FREIGHT_PERMS.tracking.manage)
|
||||||
@ApiOperation({ summary: 'Delete a GPS tracker' })
|
@ApiOperation({ summary: 'Delete a GPS tracker' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.gps.removeDevice(id);
|
return this.gps.removeDevice(id);
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ export class CreateLocomotiveDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
maxTrainLengthMeters!: number;
|
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 })
|
@ApiPropertyOptional({ example: 4200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ export class Locomotive extends BaseEntity {
|
|||||||
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
|
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
|
||||||
maxTrainLengthMeters!: number;
|
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' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||||
status!: LocomotiveStatus;
|
status!: LocomotiveStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export class LocomotivesService {
|
|||||||
maxPullWeightTons:
|
maxPullWeightTons:
|
||||||
dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
|
dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
|
||||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||||
|
overageToleranceTons: dto.overageToleranceTons ?? null,
|
||||||
|
overageToleranceMeters: dto.overageToleranceMeters ?? null,
|
||||||
powerKw: dto.powerKw ?? null,
|
powerKw: dto.powerKw ?? null,
|
||||||
tractionForceKn: dto.tractionForceKn ?? null,
|
tractionForceKn: dto.tractionForceKn ?? null,
|
||||||
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,12 +39,17 @@ export class Route extends BaseEntity {
|
|||||||
milestones?: RouteMilestone[];
|
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: {
|
export function formatRouteLabel(route: {
|
||||||
originYard?: { code?: string; name?: string } | null;
|
originYard?: { code?: string; label?: string } | null;
|
||||||
destinationYard?: { code?: string; name?: string } | null;
|
destinationYard?: { code?: string; label?: string } | null;
|
||||||
}): string {
|
}): string {
|
||||||
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
|
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||||
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
|
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||||
return `${origin} → ${dest}`;
|
return `${origin} → ${dest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
return this.repo(manager).findOne({
|
return this.repo(manager).findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: {
|
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: {
|
trainSet: {
|
||||||
locomotive: true,
|
locomotive: true,
|
||||||
locomotives: { locomotive: true },
|
locomotives: { locomotive: true },
|
||||||
|
|||||||
@@ -24,3 +24,13 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
|
|||||||
|
|
||||||
/** Default CW3 covered wagon length for bulk bookings (m). */
|
/** Default CW3 covered wagon length for bulk bookings (m). */
|
||||||
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
|
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;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
createMany: jest.Mock;
|
createMany: jest.Mock;
|
||||||
};
|
};
|
||||||
let trainSchedulesRepository: {
|
let trainSchedulesRepository: {
|
||||||
|
findById: jest.Mock;
|
||||||
findByIdWithFullGraph: jest.Mock;
|
findByIdWithFullGraph: jest.Mock;
|
||||||
findAll: jest.Mock;
|
findAll: jest.Mock;
|
||||||
};
|
};
|
||||||
@@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
createMany: jest.fn().mockResolvedValue(undefined),
|
createMany: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
trainSchedulesRepository = {
|
trainSchedulesRepository = {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
id: scheduleId,
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
windowPhase: null,
|
||||||
|
}),
|
||||||
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
maxWagons: 10,
|
maxWagons: 10,
|
||||||
@@ -163,7 +169,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
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 settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||||
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
|
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -181,6 +187,77 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
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', () => {
|
describe('fillRouteDay — day-level distribution', () => {
|
||||||
const originYardId = 'yard-origin';
|
const originYardId = 'yard-origin';
|
||||||
const destinationYardId = 'yard-dest';
|
const destinationYardId = 'yard-dest';
|
||||||
@@ -336,6 +413,49 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
// Never reserved — waits for its partner in a later cycle.
|
// Never reserved — waits for its partner in a later cycle.
|
||||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
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', () => {
|
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
|
||||||
@@ -510,4 +630,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expireUnacceptedForRouteDay: jest.Mock;
|
expireUnacceptedForRouteDay: jest.Mock;
|
||||||
settleDueReservations: jest.Mock;
|
settleDueReservations: jest.Mock;
|
||||||
isScheduleFull: jest.Mock;
|
isScheduleFull: jest.Mock;
|
||||||
|
hasLiveReservations: jest.Mock;
|
||||||
|
refreshWindowStatus: jest.Mock;
|
||||||
};
|
};
|
||||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||||
@@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||||
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
||||||
isScheduleFull: jest.fn().mockResolvedValue(false),
|
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 = {
|
trainSchedulesRepository = {
|
||||||
findById: jest.fn().mockResolvedValue(null),
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
@@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
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 () => {
|
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
||||||
batch.isScheduleFull.mockResolvedValue(true);
|
batch.isScheduleFull.mockResolvedValue(true);
|
||||||
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
||||||
|
|||||||
@@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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(
|
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.
|
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
|
||||||
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
|
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
|
||||||
this.logger.log(
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
schedule.bookingWindowStatus = 'CLOSED';
|
schedule.bookingWindowStatus = 'CLOSED';
|
||||||
}
|
}
|
||||||
this.logger.log(
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -277,7 +288,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
// is handled inside the fill (all fit → all reserved → all notified).
|
// is handled inside the fill (all fit → all reserved → all notified).
|
||||||
await this.bookingBatchService.processRouteDay(routeDay);
|
await this.bookingBatchService.processRouteDay(routeDay);
|
||||||
this.logger.log(
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -287,7 +299,43 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
schedule.paymentPhaseEndsAt != null &&
|
schedule.paymentPhaseEndsAt != null &&
|
||||||
now >= schedule.paymentPhaseEndsAt
|
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);
|
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);
|
await this.concludeCycle(schedule, cfg, now);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -306,9 +354,24 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
||||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||||
await this.tryAutoFinalize(schedule.id);
|
await this.tryAutoFinalize(schedule.id);
|
||||||
|
this.logger.log(
|
||||||
|
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
||||||
|
);
|
||||||
return;
|
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 —
|
// 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
|
// office hours decide whether that is this afternoon or tomorrow morning. Past
|
||||||
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
|
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
|
||||||
@@ -324,7 +387,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
if (nextOpensAt == null) {
|
if (nextOpensAt == null) {
|
||||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||||
this.logger.log(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -347,7 +411,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
const sameDay = eatDay(nextOpensAt) === eatDay(now);
|
const sameDay = eatDay(nextOpensAt) === eatDay(now);
|
||||||
this.logger.log(
|
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()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const nw5: WagonType = {
|
|||||||
name: 'Flat Wagon',
|
name: 'Flat Wagon',
|
||||||
capacityTons: 70,
|
capacityTons: 70,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ['CONTAINER'],
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
supportsContainer: true,
|
supportsContainer: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildBulkWagonPlan,
|
buildBulkWagonPlan,
|
||||||
buildContainerWagonPlan,
|
buildContainerWagonPlan,
|
||||||
buildMixedWagonPlan,
|
buildMixedWagonPlan,
|
||||||
|
containerWagonsForLines,
|
||||||
roundTons,
|
roundTons,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from './wagon-plan.util';
|
} from './wagon-plan.util';
|
||||||
@@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
|
|||||||
return Math.max(1, Math.ceil(weight / capacity));
|
return Math.max(1, Math.ceil(weight / capacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineSlots = (booking.bookingContainers ?? []).reduce(
|
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
|
||||||
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
|
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
|
||||||
0,
|
// fraction. Ceiling per line would over-count split 20ft lines.
|
||||||
);
|
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||||
return Math.max(1, lineSlots);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {
|
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,188 @@
|
|||||||
import {
|
import {
|
||||||
|
bookingGrossWeightTons,
|
||||||
bookingTrainLengthMeters,
|
bookingTrainLengthMeters,
|
||||||
|
consistUsage,
|
||||||
|
consistViolations,
|
||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
|
grossWagonWeightTons,
|
||||||
|
minLocomotiveLimits,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
|
|
||||||
describe('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 caps = (over = {}) => ({
|
||||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
maxWeightTons: 3500,
|
||||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
maxLengthMeters: 760,
|
||||||
[nw5],
|
maxWagonSlots: 54,
|
||||||
);
|
...over,
|
||||||
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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses shortest wagon type when mixed types are present', () => {
|
const slots = (n: number, type: typeof nw5, cargoTons: number) =>
|
||||||
const longBulk = { lengthMeters: 18, capacityTons: 80 };
|
Array.from({ length: n }, () => ({
|
||||||
const mixed = deriveTrainCapacityFromLocomotive(
|
lengthMeters: type.lengthMeters,
|
||||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
tareWeightTons: type.tareWeightTons,
|
||||||
[nw5, longBulk],
|
cargoTons,
|
||||||
);
|
}));
|
||||||
expect(mixed.maxWagonSlots).toBe(
|
|
||||||
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
|
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', () => {
|
it('computes booking length by freight type', () => {
|
||||||
expect(
|
expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28);
|
||||||
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
|
|
||||||
).toBe(28);
|
|
||||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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. */
|
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
|
||||||
export type WagonTypeDimensions = {
|
export type WagonTypeDimensions = {
|
||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
capacityTons: 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 = {
|
export type LocomotiveLimits = {
|
||||||
maxPullWeightTons: number;
|
maxPullWeightTons: number;
|
||||||
maxTrainLengthMeters: 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 = {
|
export type DerivedTrainCapacity = {
|
||||||
|
/** Gross (tare + cargo) tons the train may weigh, tolerance included. */
|
||||||
maxWeightTons: number;
|
maxWeightTons: number;
|
||||||
maxLengthMeters: number;
|
maxLengthMeters: number;
|
||||||
|
/** Length-derived slot count. Weight is enforced separately against real cargo. */
|
||||||
maxWagonSlots: number;
|
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_LENGTH_M = 14;
|
||||||
const DEFAULT_WAGON_CAPACITY_T = 70;
|
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.
|
* Hard caps for a train: the locomotive's own limits, floored by the global rule
|
||||||
* Wagon count is NOT a fixed 53 — it is the minimum of:
|
* caps, then widened by the locomotive's overage tolerance.
|
||||||
* - floor(maxLength / shortest wagon type length)
|
*/
|
||||||
* - floor(maxWeight / lightest wagon type capacity)
|
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(
|
export function deriveTrainCapacityFromLocomotive(
|
||||||
locomotive: LocomotiveLimits,
|
locomotive: LocomotiveLimits,
|
||||||
wagonTypes: WagonTypeDimensions[],
|
wagonTypes: WagonTypeDimensions[],
|
||||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||||
): DerivedTrainCapacity {
|
): DerivedTrainCapacity {
|
||||||
const maxWeightTons = Math.min(
|
const { maxWeightTons, maxLengthMeters } = trainHardCaps(locomotive, ruleCaps);
|
||||||
Number(locomotive.maxPullWeightTons) || Infinity,
|
|
||||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
|
||||||
);
|
|
||||||
const maxLengthMeters = Math.min(
|
|
||||||
Number(locomotive.maxTrainLengthMeters) || Infinity,
|
|
||||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
|
||||||
);
|
|
||||||
|
|
||||||
const types =
|
const lengths = wagonTypes
|
||||||
wagonTypes.length > 0
|
.map((w) => num(w.lengthMeters))
|
||||||
? wagonTypes
|
.filter((l) => l > 0);
|
||||||
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
|
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 maxWagonSlots =
|
||||||
const minCapacity = Math.min(
|
minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0;
|
||||||
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
|
|
||||||
);
|
|
||||||
|
|
||||||
const byLength =
|
return { maxWeightTons, maxLengthMeters, maxWagonSlots };
|
||||||
minLength > 0 && Number.isFinite(maxLengthMeters)
|
}
|
||||||
? Math.floor(maxLengthMeters / minLength)
|
|
||||||
: 0;
|
|
||||||
const byWeight =
|
|
||||||
minCapacity > 0 && Number.isFinite(maxWeightTons)
|
|
||||||
? Math.floor(maxWeightTons / minCapacity)
|
|
||||||
: byLength;
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
|
wagonCount: slots.length,
|
||||||
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
|
usedLengthMeters: round3(usedLengthMeters),
|
||||||
maxWagonSlots,
|
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;
|
/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */
|
||||||
export const MAX_FALLBACK_LENGTH = 760;
|
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
|
* 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.
|
* across all assigned locomotives. Returns null when no locomotives are given.
|
||||||
*/
|
*/
|
||||||
export function minLocomotiveLimits(
|
export function minLocomotiveLimits(
|
||||||
locomotives: Array<Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'>>,
|
locomotives: Array<
|
||||||
|
Pick<LocomotiveLimits, 'maxPullWeightTons' | 'maxTrainLengthMeters'> &
|
||||||
|
Partial<Pick<LocomotiveLimits, 'overageToleranceTons' | 'overageToleranceMeters'>>
|
||||||
|
>,
|
||||||
): LocomotiveLimits | null {
|
): LocomotiveLimits | null {
|
||||||
if (!locomotives.length) return null;
|
if (!locomotives.length) return null;
|
||||||
return {
|
return {
|
||||||
maxPullWeightTons: Math.min(
|
maxPullWeightTons: Math.min(
|
||||||
...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity),
|
...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity),
|
||||||
),
|
),
|
||||||
maxTrainLengthMeters: Math.min(
|
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;
|
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: {
|
export function wagonTypeDimensionsFromEntity(wt: {
|
||||||
lengthMeters?: number | string | null;
|
lengthMeters?: number | string | null;
|
||||||
capacityTons?: number | string | null;
|
capacityTons?: number | string | null;
|
||||||
|
tareWeightTons?: number | string | null;
|
||||||
}): WagonTypeDimensions {
|
}): WagonTypeDimensions {
|
||||||
return {
|
return {
|
||||||
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||||
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||||
|
tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const nw5 = {
|
|||||||
name: 'Flat Wagon',
|
name: 'Flat Wagon',
|
||||||
capacityTons: 70,
|
capacityTons: 70,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ['CONTAINER'],
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
supportsContainer: true,
|
supportsContainer: true,
|
||||||
@@ -35,7 +34,6 @@ const cw3 = {
|
|||||||
name: 'Covered Wagon',
|
name: 'Covered Wagon',
|
||||||
capacityTons: 60,
|
capacityTons: 60,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ['BULK'],
|
supportedLoadTypes: ['BULK'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
supportsContainer: false,
|
supportsContainer: false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
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 { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
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 { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
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 { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||||
@@ -104,10 +102,13 @@ import {
|
|||||||
deriveTrainCapacityFromLocomotive,
|
deriveTrainCapacityFromLocomotive,
|
||||||
minLocomotiveLimits,
|
minLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
|
WagonTypeDimensions,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
|
DEFAULT_BULK_WAGON_TARE_TONS,
|
||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
|
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||||
} from './booking-batch.constants';
|
} from './booking-batch.constants';
|
||||||
import {
|
import {
|
||||||
computeExportWindowTimes,
|
computeExportWindowTimes,
|
||||||
@@ -1004,13 +1005,19 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Schedule train set has no locomotives');
|
throw new BadRequestException('Schedule train set has no locomotives');
|
||||||
}
|
}
|
||||||
// forceAssign lets staff overload the locomotive set knowingly — the
|
// forceAssign lets staff overload the locomotive set knowingly — the
|
||||||
// validator has already surfaced it as a warning in that case.
|
// validator has already surfaced it as a warning in that case. Each
|
||||||
if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) {
|
// 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(
|
throw new BadRequestException(
|
||||||
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
`Train set locomotives cannot pull ${totalWeightTons}T`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
|
if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
`Train set locomotives cannot support ${totalLengthMeters}m`,
|
||||||
);
|
);
|
||||||
@@ -1648,6 +1655,13 @@ export class TrainSchedulingService {
|
|||||||
* clearance views still reading that milestone (older deployed builds) see
|
* clearance views still reading that milestone (older deployed builds) see
|
||||||
* the gate pass as done. Drop once every clearance-api deployment reads
|
* the gate pass as done. Drop once every clearance-api deployment reads
|
||||||
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
* 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(
|
private async completeGatepassMilestoneForSchedule(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
@@ -1659,20 +1673,49 @@ export class TrainSchedulingService {
|
|||||||
if (bookings.length === 0) return;
|
if (bookings.length === 0) return;
|
||||||
|
|
||||||
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
||||||
|
const bookingIds = bookings.map((b) => b.id);
|
||||||
const rows = await milestoneRepo.find({
|
const rows = await milestoneRepo.find({
|
||||||
where: {
|
where: {
|
||||||
bookingId: In(bookings.map((b) => b.id)),
|
bookingId: In(bookingIds),
|
||||||
milestoneCode: 'GATEPASS_GRANTED',
|
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) {
|
for (const row of rows) {
|
||||||
|
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
|
||||||
if (row.status === 'COMPLETED') continue;
|
if (row.status === 'COMPLETED') continue;
|
||||||
|
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
|
||||||
|
skipped.push(row.bookingId ?? '(unknown)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
row.status = 'COMPLETED';
|
row.status = 'COMPLETED';
|
||||||
row.triggeredAt = securedAt;
|
row.triggeredAt = securedAt;
|
||||||
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
||||||
await milestoneRepo.save(row);
|
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 = {}) {
|
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||||
@@ -1857,7 +1900,7 @@ export class TrainSchedulingService {
|
|||||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</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.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 class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||||
<td>${esc(booking?.companyId)}</td>
|
<td>${esc(booking?.companyId)}</td>
|
||||||
@@ -2591,7 +2634,8 @@ export class TrainSchedulingService {
|
|||||||
const schedules = await this.trainSchedulesRepository.findAll({
|
const schedules = await this.trainSchedulesRepository.findAll({
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
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,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
scheduleBookings: { booking: true },
|
scheduleBookings: { booking: true },
|
||||||
@@ -2947,8 +2991,10 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
setLimits &&
|
setLimits &&
|
||||||
(setLimits.maxPullWeightTons < totalWeightTons ||
|
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||||
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
totalWeightTons ||
|
||||||
|
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||||
|
totalLengthMeters)
|
||||||
) {
|
) {
|
||||||
pushLimit([
|
pushLimit([
|
||||||
'Assigned locomotives cannot support the total train weight and length',
|
'Assigned locomotives cannot support the total train weight and length',
|
||||||
@@ -2966,8 +3012,10 @@ export class TrainSchedulingService {
|
|||||||
if (
|
if (
|
||||||
!inServiceLocomotives.some(
|
!inServiceLocomotives.some(
|
||||||
(l) =>
|
(l) =>
|
||||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
totalWeightTons &&
|
||||||
|
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||||
|
totalLengthMeters,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
pushLimit(['No locomotive can support the total train weight and length']);
|
pushLimit(['No locomotive can support the total train weight and length']);
|
||||||
@@ -3022,7 +3070,10 @@ export class TrainSchedulingService {
|
|||||||
maxTrainLengthMeters?: number;
|
maxTrainLengthMeters?: number;
|
||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
},
|
},
|
||||||
locomotive?: Pick<Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
|
locomotive?: Pick<
|
||||||
|
Locomotive,
|
||||||
|
'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters'
|
||||||
|
>,
|
||||||
): Promise<Required<TrainLimitConfig>> {
|
): Promise<Required<TrainLimitConfig>> {
|
||||||
const row = await this.loadGlobalRulesRow();
|
const row = await this.loadGlobalRulesRow();
|
||||||
const configured = this.configService?.get<{
|
const configured = this.configService?.get<{
|
||||||
@@ -3049,6 +3100,8 @@ export class TrainSchedulingService {
|
|||||||
{
|
{
|
||||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||||
|
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
|
||||||
|
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||||||
},
|
},
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
{
|
{
|
||||||
@@ -3112,16 +3165,27 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSchedulingWagonTypeDimensions(): Promise<
|
/**
|
||||||
Array<{ lengthMeters: number; capacityTons: number }>
|
* 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
|
||||||
const types = await this.dataSource.getRepository(WagonType).find({
|
* 12.228m) and under-report how many wagons the train length allows.
|
||||||
where: [{ code: 'NW5' }, { code: 'CW3' }],
|
*/
|
||||||
});
|
private async loadSchedulingWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
|
||||||
|
const types = await this.dataSource
|
||||||
|
.getRepository(WagonType)
|
||||||
|
.find({ where: { isActive: true } });
|
||||||
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
|
||||||
return [
|
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;
|
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
|
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||||||
* boarding/alighting yards of the bookings it carries. Null means the
|
* boarding/alighting yards of the bookings it carries. Null means the
|
||||||
@@ -3719,10 +3752,17 @@ export class TrainSchedulingService {
|
|||||||
if (locomotive.status !== 'AVAILABLE') {
|
if (locomotive.status !== 'AVAILABLE') {
|
||||||
throw new BadRequestException(`Locomotive ${locomotive.code} is not 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`);
|
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(
|
throw new BadRequestException(
|
||||||
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
`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-aware day pool: the EAT days a customer may pick for this cargo. A day
|
||||||
* cargo. A day is selectable only when ≥1 OPEN schedule on the route that day
|
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
||||||
* has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that
|
* train capacity (not fully allocated). Wagon availability is deliberately NOT
|
||||||
* schedule's origin yard, and (b) remaining train capacity (not fully
|
* checked here: whether a matching wagon currently sits in the right yard is an
|
||||||
* allocated). Days with trains but not enough matching wagons are excluded.
|
* operational question staff resolve when they approve or reject the booking,
|
||||||
* Same `{ days: string[] }` shape as getAvailableDays — the customer still
|
* not something the customer can act on while choosing a date. Same
|
||||||
* picks a DAY, not a train.
|
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||||||
|
* not a train.
|
||||||
*/
|
*/
|
||||||
async getAvailableDaysForCargo(input: {
|
async getAvailableDaysForCargo(input: {
|
||||||
originYardId?: string;
|
originYardId?: string;
|
||||||
@@ -4237,85 +4278,17 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (schedules.length === 0) return { days: [] };
|
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>();
|
const days = new Set<string>();
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
const hasCapacity =
|
const hasCapacity =
|
||||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||||
if (!hasCapacity) continue;
|
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)
|
if (s.scheduledDepartureDate)
|
||||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||||
}
|
}
|
||||||
return { days: [...days].sort() };
|
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,
|
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildBulkWagonPlan,
|
buildBulkWagonPlan,
|
||||||
buildContainerWagonPlan,
|
buildContainerWagonPlan,
|
||||||
buildMixedWagonPlan,
|
buildMixedWagonPlan,
|
||||||
|
containerWagonsForLines,
|
||||||
expandBookingContainerUnits,
|
expandBookingContainerUnits,
|
||||||
expandContainerItems,
|
expandContainerItems,
|
||||||
roundTons,
|
roundTons,
|
||||||
@@ -20,7 +21,6 @@ const nw5: WagonType = {
|
|||||||
name: 'Flat Wagon',
|
name: 'Flat Wagon',
|
||||||
capacityTons: 70,
|
capacityTons: 70,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ['CONTAINER'],
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
supportsContainer: true,
|
supportsContainer: true,
|
||||||
@@ -32,7 +32,6 @@ const cw3: WagonType = {
|
|||||||
name: 'Covered Wagon',
|
name: 'Covered Wagon',
|
||||||
capacityTons: 60,
|
capacityTons: 60,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ['BULK'],
|
supportedLoadTypes: ['BULK'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
supportsContainer: false,
|
supportsContainer: false,
|
||||||
@@ -200,3 +199,60 @@ describe('wagon-plan.util', () => {
|
|||||||
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types';
|
|||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.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_WEIGHT_TONS = 3500;
|
||||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||||
@@ -35,6 +36,9 @@ export type WagonPlanSlot = {
|
|||||||
wagonTypeCode: string;
|
wagonTypeCode: string;
|
||||||
capacityTons: number;
|
capacityTons: number;
|
||||||
lengthMeters: 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;
|
assignedWeightTons: number;
|
||||||
allocations: WagonAllocationRecord[];
|
allocations: WagonAllocationRecord[];
|
||||||
slotLoadType?: SlotLoadType;
|
slotLoadType?: SlotLoadType;
|
||||||
@@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number {
|
|||||||
return Number(numericValue.toFixed(3));
|
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). */
|
/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */
|
||||||
export function teuSlotsForSizeFt(sizeFt: number): number {
|
export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||||
return sizeFt >= 40 ? 2 : 1;
|
return sizeFt >= 40 ? 2 : 1;
|
||||||
@@ -89,18 +101,38 @@ export function containersPerWagonFromType(wagonsPerUnit: number): number {
|
|||||||
return Math.max(1, Math.round(1 / wpu));
|
return Math.max(1, Math.round(1 / wpu));
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineWagonsRequired(line: {
|
type ContainerLine = {
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
wagonsRequired?: number | null;
|
wagonsRequired?: number | null;
|
||||||
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | 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);
|
const qty = Number(line.quantity ?? 0);
|
||||||
if (qty <= 0) return 0;
|
if (qty <= 0) return 0;
|
||||||
const wpu = Number(line.containerType?.wagonsPerUnit);
|
const wpu = Number(line.containerType?.wagonsPerUnit);
|
||||||
if (Number.isFinite(wpu) && wpu > 0) {
|
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[],
|
bookings: Booking[],
|
||||||
wagonType: WagonType,
|
wagonType: WagonType,
|
||||||
): WagonPlanSlot[] {
|
): 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 totalSlots = bookings.reduce((sum, booking) => {
|
||||||
const lineSlots = (booking.bookingContainers ?? []).reduce(
|
const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||||
(lineSum, line) => lineSum + lineWagonsRequired(line),
|
return sum + Math.max(bookingSlots, 1);
|
||||||
0,
|
|
||||||
);
|
|
||||||
return sum + Math.max(lineSlots, 1);
|
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const slots = Math.max(1, Math.ceil(totalSlots));
|
const slots = Math.max(1, totalSlots);
|
||||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||||
sequenceNo: index + 1,
|
sequenceNo: index + 1,
|
||||||
wagonTypeId: wagonType.id,
|
wagonTypeId: wagonType.id,
|
||||||
wagonTypeCode: wagonType.code,
|
wagonTypeCode: wagonType.code,
|
||||||
capacityTons: Number(wagonType.capacityTons),
|
capacityTons: Number(wagonType.capacityTons),
|
||||||
lengthMeters: Number(wagonType.lengthMeters),
|
lengthMeters: Number(wagonType.lengthMeters),
|
||||||
|
tareWeightTons: tareTonsOf(wagonType),
|
||||||
assignedWeightTons: 0,
|
assignedWeightTons: 0,
|
||||||
allocations: [],
|
allocations: [],
|
||||||
}));
|
}));
|
||||||
@@ -154,6 +188,7 @@ export function buildBulkWagonPlan(
|
|||||||
wagonTypeCode: wagonType.code,
|
wagonTypeCode: wagonType.code,
|
||||||
capacityTons: capacity,
|
capacityTons: capacity,
|
||||||
lengthMeters: Number(wagonType.lengthMeters),
|
lengthMeters: Number(wagonType.lengthMeters),
|
||||||
|
tareWeightTons: tareTonsOf(wagonType),
|
||||||
assignedWeightTons: 0,
|
assignedWeightTons: 0,
|
||||||
allocations: [],
|
allocations: [],
|
||||||
}));
|
}));
|
||||||
@@ -193,6 +228,7 @@ export function buildMixedWagonPlan(
|
|||||||
wagonTypeCode: containerWagonType.code,
|
wagonTypeCode: containerWagonType.code,
|
||||||
capacityTons: Number(containerWagonType.capacityTons),
|
capacityTons: Number(containerWagonType.capacityTons),
|
||||||
lengthMeters: Number(containerWagonType.lengthMeters),
|
lengthMeters: Number(containerWagonType.lengthMeters),
|
||||||
|
tareWeightTons: tareTonsOf(containerWagonType),
|
||||||
assignedWeightTons: 0,
|
assignedWeightTons: 0,
|
||||||
allocations: [],
|
allocations: [],
|
||||||
slotLoadType: 'CONTAINER',
|
slotLoadType: 'CONTAINER',
|
||||||
@@ -422,47 +458,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string
|
|||||||
return violations;
|
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(
|
export function validateTrainLimits(
|
||||||
wagonPlan: WagonPlanSlot[],
|
wagonPlan: WagonPlanSlot[],
|
||||||
wagonType: WagonType,
|
wagonType: Pick<WagonType, 'lengthMeters'>,
|
||||||
limits?: TrainLimitConfig,
|
limits?: TrainLimitConfig,
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
|
||||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||||
const maxWagonsPerTrain =
|
const maxWagonSlots =
|
||||||
limits?.maxWagonsPerTrain ??
|
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength);
|
||||||
Math.floor(maxLengthMeters / wagonLength);
|
|
||||||
|
|
||||||
const totalWeightTons = roundTons(
|
const violations = consistViolations(
|
||||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
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));
|
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||||||
|
|
||||||
return violations;
|
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(
|
export function validateMixedTrainLimits(
|
||||||
wagonPlan: WagonPlanSlot[],
|
wagonPlan: WagonPlanSlot[],
|
||||||
wagonTypes: WagonType[],
|
wagonTypes: WagonType[],
|
||||||
@@ -478,7 +513,7 @@ export function validateMixedTrainLimits(
|
|||||||
|
|
||||||
return validateTrainLimits(
|
return validateTrainLimits(
|
||||||
wagonPlan,
|
wagonPlan,
|
||||||
{ maxWagonsPerTrain } as WagonType,
|
{ lengthMeters: minWagonLength },
|
||||||
{ ...limits, maxWagonsPerTrain },
|
{ ...limits, maxWagonsPerTrain },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Transform } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -14,9 +13,6 @@ import {
|
|||||||
const toNumber = ({ value }: { value: unknown }) =>
|
const toNumber = ({ value }: { value: unknown }) =>
|
||||||
value === '' || value == null ? value : Number(value);
|
value === '' || value == null ? value : Number(value);
|
||||||
|
|
||||||
const toOptionalNumber = ({ value }: { value: unknown }) =>
|
|
||||||
value === '' || value == null ? undefined : Number(value);
|
|
||||||
|
|
||||||
const toBoolean = ({ value }: { value: unknown }) => {
|
const toBoolean = ({ value }: { value: unknown }) => {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (value === 'true') return true;
|
if (value === 'true') return true;
|
||||||
@@ -60,12 +56,16 @@ export class CreateWagonTypeDto {
|
|||||||
@Min(0.001)
|
@Min(0.001)
|
||||||
lengthMeters!: number;
|
lengthMeters!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
|
@ApiProperty({
|
||||||
@IsOptional()
|
description:
|
||||||
@Transform(toOptionalNumber)
|
'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' +
|
||||||
@IsInt()
|
'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.',
|
||||||
@Min(1)
|
example: 22.4,
|
||||||
maxWagonsPerTrain?: number;
|
})
|
||||||
|
@Transform(toNumber)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
tareWeightTons!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Supported load types, e.g. CONTAINER,BULK',
|
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ export class WagonType extends BaseEntity {
|
|||||||
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
|
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
|
||||||
lengthMeters!: number;
|
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: '{}' })
|
@Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' })
|
||||||
supportedLoadTypes!: string[];
|
supportedLoadTypes!: string[];
|
||||||
|
|
||||||
@@ -31,8 +28,9 @@ export class WagonType extends BaseEntity {
|
|||||||
@Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
@Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
equatedLengthM?: number | null;
|
equatedLengthM?: number | null;
|
||||||
|
|
||||||
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
/** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */
|
||||||
tareWeightTons?: number | null;
|
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
|
tareWeightTons!: number;
|
||||||
|
|
||||||
@Column({ name: 'supports_container', type: 'boolean', default: false })
|
@Column({ name: 'supports_container', type: 'boolean', default: false })
|
||||||
supportsContainer!: boolean;
|
supportsContainer!: boolean;
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class WagonTypesService {
|
|||||||
name: dto.name.trim(),
|
name: dto.name.trim(),
|
||||||
capacityTons: dto.capacityTons,
|
capacityTons: dto.capacityTons,
|
||||||
lengthMeters: dto.lengthMeters,
|
lengthMeters: dto.lengthMeters,
|
||||||
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
|
tareWeightTons: dto.tareWeightTons ?? null,
|
||||||
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
});
|
});
|
||||||
@@ -101,8 +101,6 @@ export class WagonTypesService {
|
|||||||
...dto,
|
...dto,
|
||||||
...(nextCode ? { code: nextCode } : {}),
|
...(nextCode ? { code: nextCode } : {}),
|
||||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||||
maxWagonsPerTrain:
|
|
||||||
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
|
|
||||||
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
|
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { WagonStatus } from '@edr/types';
|
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 {
|
export class CreateWagonDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -17,13 +17,8 @@ export class CreateWagonDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
sequenceNumber?: number;
|
sequenceNumber?: number;
|
||||||
|
|
||||||
@IsNumber()
|
// Tare weight and payload capacity are not accepted here: they belong to the
|
||||||
@Min(0)
|
// wagon type and are resolved through wagonTypeId.
|
||||||
tareWeight!: number;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
maxPayloadWeight!: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(WagonStatus)
|
@IsEnum(WagonStatus)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { TrainSchedule } from '../../train-schedules/entities/train-schedule.ent
|
|||||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { Container } from '../../container-management/entities/container.entity';
|
import { Container } from '../../container-management/entities/container.entity';
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
export const WAGON_STATUSES = [
|
export const WAGON_STATUSES = [
|
||||||
WagonStatus.Available,
|
WagonStatus.Available,
|
||||||
@@ -28,17 +29,19 @@ export class Wagon extends BaseEntity {
|
|||||||
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
||||||
wagonTypeId!: string;
|
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 })
|
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||||
trainId!: string | null;
|
trainId!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'sequence_number', type: 'int', nullable: true })
|
@Column({ name: 'sequence_number', type: 'int', nullable: true })
|
||||||
sequenceNumber!: number | null;
|
sequenceNumber!: number | null;
|
||||||
|
|
||||||
@Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
|
// Tare weight and payload capacity are properties of the wagon TYPE — read them
|
||||||
tareWeight!: number;
|
// through `wagonType`, never off the individual wagon.
|
||||||
|
|
||||||
@Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
|
|
||||||
maxPayloadWeight!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
||||||
status!: WagonStatusType;
|
status!: WagonStatusType;
|
||||||
|
|||||||
@@ -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)
|
? (query.sortBy as keyof Wagon)
|
||||||
: 'wagonNumber';
|
: 'wagonNumber';
|
||||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
return this.wagonRepo.find({
|
return this.wagonRepo.find({
|
||||||
where: search ? where : filters,
|
where: search ? where : filters,
|
||||||
relations: { currentYard: true },
|
relations: { currentYard: true, wagonType: true },
|
||||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||||
take: query.limit ? Number(query.limit) : undefined,
|
take: query.limit ? Number(query.limit) : undefined,
|
||||||
@@ -69,7 +78,7 @@ export class WagonsService {
|
|||||||
async findById(id: string): Promise<Wagon> {
|
async findById(id: string): Promise<Wagon> {
|
||||||
const wagon = await this.wagonRepo.findOne({
|
const wagon = await this.wagonRepo.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: { currentYard: true },
|
relations: { currentYard: true, wagonType: true },
|
||||||
});
|
});
|
||||||
if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
|
if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
|
||||||
return wagon;
|
return wagon;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
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 {
|
export class CreateWarehouseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
maxVolume?: number;
|
maxVolume?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WAREHOUSE_STATUSES)
|
||||||
|
status?: WarehouseStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
|||||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
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'],
|
RESERVED: ['READY_FOR_LOADING'],
|
||||||
READY_FOR_LOADING: ['LOADED'],
|
READY_FOR_LOADING: ['LOADED'],
|
||||||
LOADED: ['DISPATCHED'],
|
LOADED: ['DISPATCHED'],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
|
|||||||
|
|
||||||
import { BookingHandover } from './entities/booking-handover.entity';
|
import { BookingHandover } from './entities/booking-handover.entity';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
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 ⇒
|
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||||
@@ -18,6 +20,7 @@ export class HandoverService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Tell the customer a handover is ready and needs their signature. */
|
/** Tell the customer a handover is ready and needs their signature. */
|
||||||
@@ -28,15 +31,17 @@ export class HandoverService {
|
|||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
if (!b?.companyId) return;
|
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({
|
await this.inbox.notify({
|
||||||
recipients: { companyId: b.companyId },
|
recipients: { companyId: b.companyId },
|
||||||
audience: NotificationAudience.PORTAL,
|
audience: NotificationAudience.PORTAL,
|
||||||
type: NotificationType.DOCUMENT_ACTION,
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
title: 'Handover — signature needed',
|
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}`,
|
link: `/bookings/${bookingId}`,
|
||||||
data: { bookingId, reference },
|
data: { bookingId, reference },
|
||||||
});
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
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.
|
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||||
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { LastMileService } from '../last-mile/last-mile.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 { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseInspectionService {
|
export class WarehouseInspectionService {
|
||||||
|
private readonly logger = new Logger(WarehouseInspectionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly lastMileService: LastMileService,
|
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. */
|
/** 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(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.booking_id AS "bookingId",
|
`SELECT inv.booking_id AS "bookingId",
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
|
b.company_id AS "companyId",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||||
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
|
|||||||
|
|
||||||
if (row.bookingReference && hasLastMile) {
|
if (row.bookingReference && hasLastMile) {
|
||||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
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}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
|||||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
|
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/store')
|
@Post(':id/store')
|
||||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||||
return this.inventoryService.store(id, performedBy);
|
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/ready-for-loading')
|
@Post(':id/ready-for-loading')
|
||||||
@@ -354,12 +355,34 @@ export class WarehouseInventoryController {
|
|||||||
return this.handoverService.list(bookingId);
|
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')
|
@Get('bookings/:bookingId/container-items')
|
||||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.inventoryService.containerItems(bookingId);
|
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')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
|||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||||
@@ -358,12 +359,14 @@ export interface ImportUnloadedRow {
|
|||||||
customerTruckType: string | null;
|
customerTruckType: string | null;
|
||||||
customerTruckContainerNumber: string | null;
|
customerTruckContainerNumber: string | null;
|
||||||
customerTruckAssignedAt: string | null;
|
customerTruckAssignedAt: string | null;
|
||||||
|
hasAssignedTruck: boolean;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
handoverDocumentReference: string | null;
|
handoverDocumentReference: string | null;
|
||||||
handoverDocumentDate: string | null;
|
handoverDocumentDate: string | null;
|
||||||
deliveredAt: string | null;
|
deliveredAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -403,16 +406,18 @@ export class WarehouseInventoryService {
|
|||||||
if (!booking.companyId) return;
|
if (!booking.companyId) return;
|
||||||
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||||
if (booking.customerTruckAssignedAt) return; // already assigned
|
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 {
|
try {
|
||||||
await this.inbox.notify({
|
await this.inbox.notify({
|
||||||
recipients: { companyId: booking.companyId },
|
recipients: { companyId: booking.companyId },
|
||||||
audience: NotificationAudience.PORTAL,
|
audience: NotificationAudience.PORTAL,
|
||||||
type: NotificationType.BOOKING_STATUS,
|
type: NotificationType.BOOKING_STATUS,
|
||||||
title: 'Assign a truck for pickup',
|
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}`,
|
link: `/bookings/${bookingId}`,
|
||||||
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||||
});
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
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_type AS "customerTruckType",
|
||||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
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.status AS "currentStatus",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
inv.release_order_reference AS "releaseOrderReference",
|
inv.release_order_reference AS "releaseOrderReference",
|
||||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||||
inv.delivered_at AS "deliveredAt",
|
inv.delivered_at AS "deliveredAt",
|
||||||
|
inv.notes AS "notes",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry"
|
dy.country AS "destinationCountry"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
@@ -2141,13 +2152,30 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
// ── 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);
|
const item = await this.findById(id);
|
||||||
this.assertTransition(item.status, 'STORED');
|
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 criteria = await this.getInventoryAllocationCriteria(item);
|
||||||
const ruleLocation = await this.allocation.resolveLocation(criteria);
|
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
|
||||||
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
const location =
|
||||||
|
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||||
|
|
||||||
if (!location) {
|
if (!location) {
|
||||||
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
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);
|
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, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
status: 'STORED',
|
status: 'STORED',
|
||||||
storedAt: new Date(),
|
storedAt: new Date(),
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
yardId: location.yardId,
|
yardId: location.yardId,
|
||||||
zoneId: location.zoneId,
|
zoneId: location.zoneId,
|
||||||
notes: this.appendNote(
|
notes: this.appendNote(locked.notes, storedReason),
|
||||||
locked.notes,
|
|
||||||
ruleLocation?.rule
|
|
||||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
|
||||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
@@ -2210,9 +2239,7 @@ export class WarehouseInventoryService {
|
|||||||
activityType: 'INVENTORY_STORED',
|
activityType: 'INVENTORY_STORED',
|
||||||
inventoryId: id,
|
inventoryId: id,
|
||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
description: ruleLocation?.rule
|
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||||||
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
|
|
||||||
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
|
|
||||||
performedBy,
|
performedBy,
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
@@ -2331,6 +2358,26 @@ export class WarehouseInventoryService {
|
|||||||
'Customer must sign the handover before the exit paper can be generated',
|
'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
|
const releaseDate = isTruckLeaving
|
||||||
@@ -2556,15 +2603,17 @@ export class WarehouseInventoryService {
|
|||||||
Array<{
|
Array<{
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
goods: string | null;
|
goods: string | null;
|
||||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
grnNumber: string | null;
|
grnNumber: string | null;
|
||||||
truckAssignmentId: string | null;
|
truckAssignmentId: string | null;
|
||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
|
handoverSigned: boolean;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const rows: Array<{
|
const rows: Array<{
|
||||||
@@ -2576,6 +2625,7 @@ export class WarehouseInventoryService {
|
|||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
@@ -2589,6 +2639,7 @@ export class WarehouseInventoryService {
|
|||||||
a.plate_number AS "truckPlate",
|
a.plate_number AS "truckPlate",
|
||||||
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||||||
(a.departed_at IS NOT NULL) AS "truckLeft",
|
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||||||
|
(ctc.loaded_at IS NOT NULL) AS loaded,
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
b.contract_id AS "contractId",
|
b.contract_id AS "contractId",
|
||||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||||
@@ -2611,28 +2662,64 @@ export class WarehouseInventoryService {
|
|||||||
[bookingId],
|
[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) => ({
|
return rows.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
goods: r.goods,
|
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
|
stage: r.delivered
|
||||||
? 'DELIVERED'
|
? 'DELIVERED'
|
||||||
: r.truckLeft
|
: r.truckLeft
|
||||||
? 'LEFT'
|
? 'LEFT'
|
||||||
: r.truckAssignmentId
|
: r.loaded
|
||||||
? 'LOADED'
|
? 'LOADED'
|
||||||
: r.grnNumber
|
: r.truckAssignmentId
|
||||||
? 'GRN'
|
? 'ASSIGNED'
|
||||||
: r.received
|
: r.grnNumber
|
||||||
? 'RECEIVED'
|
? 'GRN'
|
||||||
: 'PENDING',
|
: r.received
|
||||||
|
? 'RECEIVED'
|
||||||
|
: 'PENDING',
|
||||||
grnNumber: r.grnNumber,
|
grnNumber: r.grnNumber,
|
||||||
truckAssignmentId: r.truckAssignmentId,
|
truckAssignmentId: r.truckAssignmentId,
|
||||||
truckPlate: r.truckPlate,
|
truckPlate: r.truckPlate,
|
||||||
truckArrived: r.truckArrived,
|
truckArrived: r.truckArrived,
|
||||||
truckLeft: r.truckLeft,
|
truckLeft: r.truckLeft,
|
||||||
|
loaded: r.loaded,
|
||||||
bookingReference: r.bookingReference,
|
bookingReference: r.bookingReference,
|
||||||
contractId: r.contractId,
|
contractId: r.contractId,
|
||||||
hasLastMile: r.hasLastMile,
|
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 }> {
|
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
@@ -3164,7 +3266,22 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
} else {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export class WarehousesService {
|
|||||||
currentWeight: 0,
|
currentWeight: 0,
|
||||||
currentContainers: 0,
|
currentContainers: 0,
|
||||||
currentVolume: 0,
|
currentVolume: 0,
|
||||||
status: 'ACTIVE',
|
status: dto.status ?? 'ACTIVE',
|
||||||
isActive: true,
|
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.mapDbError(error);
|
this.mapDbError(error);
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ async function ensureReferences(manager: any) {
|
|||||||
name: 'Gate Pass Demo Flat Wagon',
|
name: 'Gate Pass Demo Flat Wagon',
|
||||||
capacityTons: 70,
|
capacityTons: 70,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ['CONTAINER'],
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
equatedLengthM: 14,
|
equatedLengthM: 14,
|
||||||
@@ -420,8 +419,6 @@ async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: nu
|
|||||||
wagonTypeId,
|
wagonTypeId,
|
||||||
trainId: null,
|
trainId: null,
|
||||||
sequenceNumber: sequenceNo,
|
sequenceNumber: sequenceNo,
|
||||||
tareWeight: 20,
|
|
||||||
maxPayloadWeight: 70,
|
|
||||||
status: WagonStatus.Assigned,
|
status: WagonStatus.Assigned,
|
||||||
currentYardId: yardId,
|
currentYardId: yardId,
|
||||||
currentTrainScheduleId: scheduleId,
|
currentTrainScheduleId: scheduleId,
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ async function main() {
|
|||||||
name: 'Negad Demo Flat Wagon',
|
name: 'Negad Demo Flat Wagon',
|
||||||
capacityTons: 70,
|
capacityTons: 70,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ['CONTAINER'],
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
equatedLengthM: 14,
|
equatedLengthM: 14,
|
||||||
@@ -307,8 +306,6 @@ async function main() {
|
|||||||
wagonTypeId: wagonType.id,
|
wagonTypeId: wagonType.id,
|
||||||
trainId: null,
|
trainId: null,
|
||||||
sequenceNumber: sequenceNo,
|
sequenceNumber: sequenceNo,
|
||||||
tareWeight: 20,
|
|
||||||
maxPayloadWeight: 70,
|
|
||||||
status: WagonStatus.Assigned,
|
status: WagonStatus.Assigned,
|
||||||
currentYardId: indode.id,
|
currentYardId: indode.id,
|
||||||
notes: 'Demo wagon for Negad to Indode marshalling',
|
notes: 'Demo wagon for Negad to Indode marshalling',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
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 { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||||
@@ -91,12 +92,26 @@ export class Batch5TestDataSeeder {
|
|||||||
return;
|
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();
|
const now = new Date();
|
||||||
|
|
||||||
for (const seed of SEEDS) {
|
for (const seed of SEEDS) {
|
||||||
const booking = await bookingRepo.save(
|
const booking = await bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
reference: seed.ref,
|
reference: seed.ref,
|
||||||
|
companyId: companyProfile.companyId,
|
||||||
|
companyProfileId: companyProfile.id,
|
||||||
originYardId: originYard.id,
|
originYardId: originYard.id,
|
||||||
destinationYardId: destYard.id,
|
destinationYardId: destYard.id,
|
||||||
serviceTypeId: serviceType.id,
|
serviceTypeId: serviceType.id,
|
||||||
|
|||||||
@@ -211,7 +211,6 @@ export class DemoBookingsSeeder {
|
|||||||
name: "Flat Wagon",
|
name: "Flat Wagon",
|
||||||
capacityTons: 70,
|
capacityTons: 70,
|
||||||
lengthMeters: 14,
|
lengthMeters: 14,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ["CONTAINER"],
|
supportedLoadTypes: ["CONTAINER"],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
equatedLengthM: 14,
|
equatedLengthM: 14,
|
||||||
@@ -224,7 +223,6 @@ export class DemoBookingsSeeder {
|
|||||||
name: "Covered Hopper",
|
name: "Covered Hopper",
|
||||||
capacityTons: 60,
|
capacityTons: 60,
|
||||||
lengthMeters: 12,
|
lengthMeters: 12,
|
||||||
maxWagonsPerTrain: 55,
|
|
||||||
supportedLoadTypes: ["BULK"],
|
supportedLoadTypes: ["BULK"],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
equatedLengthM: 12,
|
equatedLengthM: 12,
|
||||||
@@ -236,7 +234,6 @@ export class DemoBookingsSeeder {
|
|||||||
name: "Powder Wagon",
|
name: "Powder Wagon",
|
||||||
capacityTons: 55,
|
capacityTons: 55,
|
||||||
lengthMeters: 12,
|
lengthMeters: 12,
|
||||||
maxWagonsPerTrain: 55,
|
|
||||||
supportedLoadTypes: ["BULK"],
|
supportedLoadTypes: ["BULK"],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
equatedLengthM: 12,
|
equatedLengthM: 12,
|
||||||
@@ -248,7 +245,6 @@ export class DemoBookingsSeeder {
|
|||||||
name: "Open Wagon",
|
name: "Open Wagon",
|
||||||
capacityTons: 65,
|
capacityTons: 65,
|
||||||
lengthMeters: 13,
|
lengthMeters: 13,
|
||||||
maxWagonsPerTrain: 53,
|
|
||||||
supportedLoadTypes: ["BULK"],
|
supportedLoadTypes: ["BULK"],
|
||||||
isActive: true,
|
isActive: true,
|
||||||
equatedLengthM: 13,
|
equatedLengthM: 13,
|
||||||
@@ -508,8 +504,6 @@ export class DemoBookingsSeeder {
|
|||||||
wagonTypeId: nw5.id,
|
wagonTypeId: nw5.id,
|
||||||
trainId: null,
|
trainId: null,
|
||||||
sequenceNumber: null,
|
sequenceNumber: null,
|
||||||
tareWeight: 20,
|
|
||||||
maxPayloadWeight: 70,
|
|
||||||
status: WagonStatus.Available,
|
status: WagonStatus.Available,
|
||||||
currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
|
currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
|
||||||
notes: "Demo wagon for train scheduling",
|
notes: "Demo wagon for train scheduling",
|
||||||
|
|||||||
@@ -76,15 +76,11 @@ export class DemoFreightDataSeeder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toCreate = MIN_WAGONS_PER_TYPE - existing;
|
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 rows = Array.from({ length: toCreate }, (_, i) => {
|
||||||
const seq = existing + i + 1;
|
const seq = existing + i + 1;
|
||||||
return wagonRepo.create({
|
return wagonRepo.create({
|
||||||
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
|
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
|
||||||
wagonTypeId: type.id,
|
wagonTypeId: type.id,
|
||||||
tareWeight: tare,
|
|
||||||
maxPayloadWeight: maxPayload,
|
|
||||||
status: WagonStatus.Available,
|
status: WagonStatus.Available,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
|
||||||
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete 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-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-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-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
|
||||||
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update 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: {
|
tracking: {
|
||||||
view: 'edr_freight_app:tracking:view',
|
view: 'edr_freight_app:tracking:view',
|
||||||
|
manage: 'edr_freight_app:tracking:manage',
|
||||||
},
|
},
|
||||||
fuel: {
|
fuel: {
|
||||||
view: 'edr_freight_app:fuel:view',
|
view: 'edr_freight_app:fuel:view',
|
||||||
@@ -662,9 +664,13 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.fleet.view,
|
FREIGHT_PERMS.fleet.view,
|
||||||
FREIGHT_PERMS.fleet.manage,
|
FREIGHT_PERMS.fleet.manage,
|
||||||
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
// 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.view,
|
||||||
FREIGHT_PERMS.contracts.opsClearanceReview,
|
FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
|
FREIGHT_PERMS.bookings.clearanceView,
|
||||||
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
|
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
director: [
|
director: [
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ export class MarshallingDemoTrainsSeeder {
|
|||||||
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
|
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
|
||||||
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
|
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
|
||||||
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
|
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
|
||||||
const tareWeight = Number(refs.wagonType.tareWeightTons) || 14;
|
|
||||||
|
|
||||||
const trainSet = await trainSetRepo.save(
|
const trainSet = await trainSetRepo.save(
|
||||||
trainSetRepo.create({
|
trainSetRepo.create({
|
||||||
@@ -275,8 +274,6 @@ export class MarshallingDemoTrainsSeeder {
|
|||||||
wagonTypeId: refs.wagonType.id,
|
wagonTypeId: refs.wagonType.id,
|
||||||
yardId: originYard.id,
|
yardId: originYard.id,
|
||||||
trainScheduleId: schedule.id,
|
trainScheduleId: schedule.id,
|
||||||
tareWeight,
|
|
||||||
capacityTons: wagonCapacity,
|
|
||||||
dispatched: hasDeparted,
|
dispatched: hasDeparted,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,8 +417,6 @@ export class MarshallingDemoTrainsSeeder {
|
|||||||
wagonTypeId: string;
|
wagonTypeId: string;
|
||||||
yardId: string;
|
yardId: string;
|
||||||
trainScheduleId: string;
|
trainScheduleId: string;
|
||||||
tareWeight: number;
|
|
||||||
capacityTons: number;
|
|
||||||
dispatched: boolean;
|
dispatched: boolean;
|
||||||
}): Promise<Wagon> {
|
}): Promise<Wagon> {
|
||||||
const repo = this.dataSource.getRepository(Wagon);
|
const repo = this.dataSource.getRepository(Wagon);
|
||||||
@@ -433,8 +428,6 @@ export class MarshallingDemoTrainsSeeder {
|
|||||||
wagonTypeId: input.wagonTypeId,
|
wagonTypeId: input.wagonTypeId,
|
||||||
currentYardId: input.yardId,
|
currentYardId: input.yardId,
|
||||||
currentTrainScheduleId: input.trainScheduleId,
|
currentTrainScheduleId: input.trainScheduleId,
|
||||||
tareWeight: input.tareWeight,
|
|
||||||
maxPayloadWeight: input.capacityTons,
|
|
||||||
status: WagonStatus.Assigned,
|
status: WagonStatus.Assigned,
|
||||||
notes: 'Marshalling demo seed wagon',
|
notes: 'Marshalling demo seed wagon',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-ru
|
|||||||
import { Route } from "../modules/routes/entities/route.entity";
|
import { Route } from "../modules/routes/entities/route.entity";
|
||||||
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||||
import { Yard } from "../modules/rule-engine/entities/yard.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 STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
|
||||||
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
|
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
|
||||||
@@ -295,6 +296,7 @@ export class PricingDataSeeder {
|
|||||||
originYardId: addis.id,
|
originYardId: addis.id,
|
||||||
destinationYardId: direDawa.id,
|
destinationYardId: direDawa.id,
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
|
direction: deriveTradeDirection(addis, direDawa),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await milestoneRepo.save([
|
await milestoneRepo.save([
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DataSource } from 'typeorm';
|
|||||||
|
|
||||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.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 { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.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: { code: 'RAIL_CONTAINER' } })) ??
|
||||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||||
const cargoType = await cargoTypeRepo.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(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,7 +96,7 @@ export class WarehouseDemoSeeder {
|
|||||||
): Promise<Booking> =>
|
): Promise<Booking> =>
|
||||||
bookingRepo.save(
|
bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
...this.demoBookingDefaults(),
|
...this.demoBookingDefaults(companyProfile),
|
||||||
reference,
|
reference,
|
||||||
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
||||||
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.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.
|
// 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;
|
created += 1;
|
||||||
|
|
||||||
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
|
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
|
||||||
@@ -199,6 +214,7 @@ export class WarehouseDemoSeeder {
|
|||||||
ethYard: Yard,
|
ethYard: Yard,
|
||||||
serviceType: ServiceType,
|
serviceType: ServiceType,
|
||||||
cargoType: CargoType | null,
|
cargoType: CargoType | null,
|
||||||
|
owner: CompanyProfile,
|
||||||
arrival: Date,
|
arrival: Date,
|
||||||
departure: Date,
|
departure: Date,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -238,7 +254,7 @@ export class WarehouseDemoSeeder {
|
|||||||
for (let i = 1; i <= 3; i++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
const b = await bookingRepo.save(
|
const b = await bookingRepo.save(
|
||||||
bookingRepo.create({
|
bookingRepo.create({
|
||||||
...this.demoBookingDefaults(),
|
...this.demoBookingDefaults(owner),
|
||||||
reference: `WH-DEMO-ARR-${i}`,
|
reference: `WH-DEMO-ARR-${i}`,
|
||||||
originYardId: djibYard.id,
|
originYardId: djibYard.id,
|
||||||
destinationYardId: ethYard.id,
|
destinationYardId: ethYard.id,
|
||||||
@@ -258,8 +274,10 @@ export class WarehouseDemoSeeder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private demoBookingDefaults(): Partial<Booking> {
|
private demoBookingDefaults(owner: CompanyProfile): Partial<Booking> {
|
||||||
return {
|
return {
|
||||||
|
companyId: owner.companyId,
|
||||||
|
companyProfileId: owner.id,
|
||||||
scheduledDate: new Date(),
|
scheduledDate: new Date(),
|
||||||
contractType: 'SPOT',
|
contractType: 'SPOT',
|
||||||
equipmentReturn: 'TERMINAL',
|
equipmentReturn: 'TERMINAL',
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa
|
|||||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||||
// Hidden for now — Shipment Requests pages disabled (imports kept commented).
|
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||||
// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||||
// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
|
||||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||||
|
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||||
@@ -187,13 +187,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Hidden for now — Shipment Requests nav item disabled.
|
{
|
||||||
// {
|
label: "Shipment Requests",
|
||||||
// label: "Shipment Requests",
|
href: "/dashboard/shipment-requests",
|
||||||
// href: "/dashboard/shipment-requests",
|
icon: <Send />,
|
||||||
// icon: <Send />,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
// 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",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
@@ -752,7 +757,6 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Hidden for now — Shipment Requests pages disabled.
|
|
||||||
<Route
|
<Route
|
||||||
path="shipment-requests"
|
path="shipment-requests"
|
||||||
element={
|
element={
|
||||||
@@ -773,7 +777,6 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
*/}
|
|
||||||
{/* GL (Path B) contract clearance review hub */}
|
{/* GL (Path B) contract clearance review hub */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/clearance"
|
path="contracts/clearance"
|
||||||
@@ -829,10 +832,17 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</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
|
<Route
|
||||||
path="contracts/ops-clearance"
|
path="contracts/ops-clearance"
|
||||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||||
|
>
|
||||||
|
<DocumentClearanceListPage opsMode />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contracts/:id/create-booking"
|
path="contracts/:id/create-booking"
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps {
|
|||||||
queriesLocked?: boolean;
|
queriesLocked?: boolean;
|
||||||
/** Read-only audit view — no approve/query actions. */
|
/** Read-only audit view — no approve/query actions. */
|
||||||
readOnly?: boolean;
|
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<
|
const STATUS_META: Record<
|
||||||
@@ -73,6 +79,7 @@ export function ClearanceReviewSection({
|
|||||||
approvalsLocked = false,
|
approvalsLocked = false,
|
||||||
queriesLocked = false,
|
queriesLocked = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
phasedCustoms = false,
|
||||||
}: ClearanceReviewSectionProps) {
|
}: ClearanceReviewSectionProps) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
@@ -240,7 +247,7 @@ export function ClearanceReviewSection({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{clearance.outputCode && (
|
{clearance.outputCode && !phasedCustoms && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={Upload}
|
icon={Upload}
|
||||||
title="Customs output documents"
|
title="Customs output documents"
|
||||||
@@ -341,7 +348,7 @@ export function ClearanceReviewSection({
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{finalizeMutation.isError && (
|
{!phasedCustoms && finalizeMutation.isError && (
|
||||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
{finalizeMutation.error instanceof Error
|
{finalizeMutation.error instanceof Error
|
||||||
? finalizeMutation.error.message
|
? finalizeMutation.error.message
|
||||||
@@ -349,8 +356,10 @@ export function ClearanceReviewSection({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md" p="md">
|
{phasedCustoms ? (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
// 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 }}>
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -358,26 +367,50 @@ export function ClearanceReviewSection({
|
|||||||
radius="md"
|
radius="md"
|
||||||
size={28}
|
size={28}
|
||||||
>
|
>
|
||||||
<FileCheck2 size={15} />
|
{clearance.allApproved ? (
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
) : (
|
||||||
|
<FileCheck2 size={15} />
|
||||||
|
)}
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fz="12.5px" c="dimmed">
|
<Text fz="12.5px" c="dimmed">
|
||||||
{clearance.allApproved
|
{clearance.allApproved
|
||||||
? "All required documents are approved — you can finalize."
|
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
|
||||||
: "Approve every required document to unlock finalization."}
|
: "Approve every required document to unlock the customs milestone steps."}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
</Paper>
|
||||||
color="edr-green"
|
) : (
|
||||||
radius="md"
|
<Paper withBorder radius="md" p="md">
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
<Group justify="space-between" wrap="nowrap">
|
||||||
disabled={!clearance.allApproved}
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
loading={finalizeMutation.isPending}
|
<ThemeIcon
|
||||||
onClick={() => finalizeMutation.mutate()}
|
variant="light"
|
||||||
>
|
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||||
Finalize clearance
|
radius="md"
|
||||||
</Button>
|
size={28}
|
||||||
</Group>
|
>
|
||||||
</Paper>
|
<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}
|
{viewer}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ import {
|
|||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { bookingsService } from "@/services/bookings.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:
|
* Export customs flow, ordered per the stakeholder process:
|
||||||
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
|
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
|
||||||
@@ -937,6 +944,7 @@ export function ReleaseOrderCard({
|
|||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [amendLoading, setAmendLoading] = useState(false);
|
const [amendLoading, setAmendLoading] = useState(false);
|
||||||
|
const minVesselDate = useMemo(todayISODate, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="md" p="md">
|
<Paper withBorder radius="md" p="md">
|
||||||
@@ -949,6 +957,7 @@ export function ReleaseOrderCard({
|
|||||||
label="Vessel departure date"
|
label="Vessel departure date"
|
||||||
value={vesselDate}
|
value={vesselDate}
|
||||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||||
|
minDate={minVesselDate}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Group>
|
<Group>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DateInput } from "@mantine/dates";
|
||||||
import { Ship, Upload } from "lucide-react";
|
import { Ship, Upload } from "lucide-react";
|
||||||
@@ -40,6 +40,13 @@ export function GlClearanceUploadModal({
|
|||||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
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 isDo = kind === "do";
|
||||||
const isRo = kind === "ro";
|
const isRo = kind === "ro";
|
||||||
@@ -115,6 +122,7 @@ export function GlClearanceUploadModal({
|
|||||||
label="Vessel departure date"
|
label="Vessel departure date"
|
||||||
value={vesselDate}
|
value={vesselDate}
|
||||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||||
|
minDate={todayISODate}
|
||||||
size="sm"
|
size="sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -123,6 +131,7 @@ export function GlClearanceUploadModal({
|
|||||||
label="Vessel arrival date (optional)"
|
label="Vessel arrival date (optional)"
|
||||||
value={vesselDate}
|
value={vesselDate}
|
||||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||||
|
minDate={todayISODate}
|
||||||
size="sm"
|
size="sm"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ import {
|
|||||||
/** All booking-window times are communicated in East Africa Time. */
|
/** All booking-window times are communicated in East Africa Time. */
|
||||||
const EAT_TZ = "Africa/Addis_Ababa";
|
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 {
|
function fmtWindowOpensAt(iso: string): string {
|
||||||
const date = new Date(iso).toLocaleDateString("en-GB", {
|
const date = new Date(iso).toLocaleDateString("en-GB", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
@@ -372,6 +387,72 @@ export default function GlCreateBookingForm() {
|
|||||||
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
|
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 =
|
const canSubmit =
|
||||||
windowOpen &&
|
windowOpen &&
|
||||||
Boolean(scheduledDate) &&
|
Boolean(scheduledDate) &&
|
||||||
@@ -401,7 +482,7 @@ export default function GlCreateBookingForm() {
|
|||||||
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
|
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
|
||||||
reeferQuantity: l.units.filter((u) => u.reefer).length,
|
reeferQuantity: l.units.filter((u) => u.reefer).length,
|
||||||
units: l.units.map((u) => ({
|
units: l.units.map((u) => ({
|
||||||
containerNumber: u.containerNumber,
|
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||||
vgmTons: Number(u.vgmTons) || 0,
|
vgmTons: Number(u.vgmTons) || 0,
|
||||||
})),
|
})),
|
||||||
@@ -458,6 +539,13 @@ export default function GlCreateBookingForm() {
|
|||||||
const overweightLines = validation?.overweightLines ?? [];
|
const overweightLines = validation?.overweightLines ?? [];
|
||||||
|
|
||||||
const openPriceModal = () => {
|
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);
|
setPriceOpen(true);
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
if (payload) {
|
if (payload) {
|
||||||
@@ -467,7 +555,7 @@ export default function GlCreateBookingForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!contract || !windowOpen) return;
|
if (!contract || !windowOpen || !cargoValid) return;
|
||||||
// Never book past unresolved 20ft pairing hard-blocks.
|
// Never book past unresolved 20ft pairing hard-blocks.
|
||||||
if (pairingErrors.length > 0) return;
|
if (pairingErrors.length > 0) return;
|
||||||
// A line above the container type's max capacity can never book.
|
// A line above the container type's max capacity can never book.
|
||||||
@@ -483,8 +571,13 @@ export default function GlCreateBookingForm() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Non-fatal
|
// 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 {
|
} else {
|
||||||
|
// ONE_TIME customs keeps its clearance on the contract.
|
||||||
navigate(`/dashboard/contracts/clearance/${contract.id}`);
|
navigate(`/dashboard/contracts/clearance/${contract.id}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -692,6 +785,11 @@ export default function GlCreateBookingForm() {
|
|||||||
label={unitIdx === 0 ? "Container number *" : undefined}
|
label={unitIdx === 0 ? "Container number *" : undefined}
|
||||||
placeholder="e.g. MSCU1234567"
|
placeholder="e.g. MSCU1234567"
|
||||||
value={unit.containerNumber}
|
value={unit.containerNumber}
|
||||||
|
error={
|
||||||
|
showErrors
|
||||||
|
? unitErrors[lineIdx]?.[unitIdx]?.containerNumber
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
patchUnit(lineIdx, unitIdx, {
|
patchUnit(lineIdx, unitIdx, {
|
||||||
containerNumber: e.currentTarget.value,
|
containerNumber: e.currentTarget.value,
|
||||||
@@ -720,6 +818,11 @@ export default function GlCreateBookingForm() {
|
|||||||
min={0}
|
min={0}
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
value={unit.vgmTons}
|
value={unit.vgmTons}
|
||||||
|
error={
|
||||||
|
showErrors
|
||||||
|
? unitErrors[lineIdx]?.[unitIdx]?.vgmTons
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
patchUnit(lineIdx, unitIdx, { vgmTons: v })
|
patchUnit(lineIdx, unitIdx, { vgmTons: v })
|
||||||
}
|
}
|
||||||
@@ -808,6 +911,7 @@ export default function GlCreateBookingForm() {
|
|||||||
min={0}
|
min={0}
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
value={line.cargoWeightTons}
|
value={line.cargoWeightTons}
|
||||||
|
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
|
||||||
onChange={(v) => patchBulk(idx, { cargoWeightTons: v })}
|
onChange={(v) => patchBulk(idx, { cargoWeightTons: v })}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
@@ -818,6 +922,7 @@ export default function GlCreateBookingForm() {
|
|||||||
placeholder="e.g. 500"
|
placeholder="e.g. 500"
|
||||||
min={0}
|
min={0}
|
||||||
value={line.itemCount}
|
value={line.itemCount}
|
||||||
|
error={showErrors ? bulkErrors[idx]?.quantity : undefined}
|
||||||
onChange={(v) => patchBulk(idx, { itemCount: v })}
|
onChange={(v) => patchBulk(idx, { itemCount: v })}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
@@ -828,6 +933,7 @@ export default function GlCreateBookingForm() {
|
|||||||
label="Hazardous quantity"
|
label="Hazardous quantity"
|
||||||
min={0}
|
min={0}
|
||||||
value={line.hazardousQuantity}
|
value={line.hazardousQuantity}
|
||||||
|
error={showErrors ? bulkErrors[idx]?.hazardous : undefined}
|
||||||
onChange={(v) => patchBulk(idx, { hazardousQuantity: v })}
|
onChange={(v) => patchBulk(idx, { hazardousQuantity: v })}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
@@ -838,6 +944,7 @@ export default function GlCreateBookingForm() {
|
|||||||
label="Refrigerated quantity"
|
label="Refrigerated quantity"
|
||||||
min={0}
|
min={0}
|
||||||
value={line.reeferQuantity}
|
value={line.reeferQuantity}
|
||||||
|
error={showErrors ? bulkErrors[idx]?.reefer : undefined}
|
||||||
onChange={(v) => patchBulk(idx, { reeferQuantity: v })}
|
onChange={(v) => patchBulk(idx, { reeferQuantity: v })}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
@@ -905,26 +1012,39 @@ export default function GlCreateBookingForm() {
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="flex-end" maw={896} mx="auto">
|
<Box maw={896} mx="auto">
|
||||||
<Button
|
{showErrors && !cargoValid ? (
|
||||||
variant="default"
|
<Alert
|
||||||
radius="md"
|
color="red"
|
||||||
onClick={() =>
|
variant="light"
|
||||||
navigate(`/dashboard/contracts/clearance/${contract.id}`)
|
radius="md"
|
||||||
}
|
icon={<AlertCircle size={16} />}
|
||||||
>
|
mb="sm"
|
||||||
Cancel
|
>
|
||||||
</Button>
|
Fix the highlighted cargo fields before reviewing the price.
|
||||||
<Button
|
</Alert>
|
||||||
color="edr-green"
|
) : null}
|
||||||
radius="md"
|
<Group justify="flex-end">
|
||||||
leftSection={<Receipt size={16} />}
|
<Button
|
||||||
disabled={!canSubmit}
|
variant="default"
|
||||||
onClick={openPriceModal}
|
radius="md"
|
||||||
>
|
onClick={() =>
|
||||||
Review price & book
|
navigate(`/dashboard/contracts/clearance/${contract.id}`)
|
||||||
</Button>
|
}
|
||||||
</Group>
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Receipt size={16} />}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={openPriceModal}
|
||||||
|
>
|
||||||
|
Review price & book
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ function computeImportActiveStep(
|
|||||||
bookingCreated: boolean,
|
bookingCreated: boolean,
|
||||||
bookingMilestones: MilestoneRow[],
|
bookingMilestones: MilestoneRow[],
|
||||||
t1Uploaded: boolean,
|
t1Uploaded: boolean,
|
||||||
|
freightPaid: boolean,
|
||||||
): number {
|
): number {
|
||||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||||
@@ -119,9 +120,12 @@ function computeImportActiveStep(
|
|||||||
if (!clearance.preClearanceFinalized) return 5;
|
if (!clearance.preClearanceFinalized) return 5;
|
||||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||||
if (!bookingCreated) return 7;
|
if (!bookingCreated) return 7;
|
||||||
if (!clearance.gatepassGranted) return 8;
|
// The customer pays the train/freight charges on the booking. Until that
|
||||||
if (!t1Uploaded && !clearance.t1?.closed) return 9;
|
// settles the gate pass is not granted for this booking, so the flow stops here.
|
||||||
if (!clearance.t1?.closed) return 10;
|
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
|
// 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
|
// already carries a riskLevel. The ET page derives its bookingMilestones from a
|
||||||
// separately-fetched booking id that can lag or mismatch the booking carrying
|
// separately-fetched booking id that can lag or mismatch the booking carrying
|
||||||
@@ -129,15 +133,15 @@ function computeImportActiveStep(
|
|||||||
const riskAssigned =
|
const riskAssigned =
|
||||||
Boolean(clearance.riskLevel) ||
|
Boolean(clearance.riskLevel) ||
|
||||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||||
if (!riskAssigned) return 11;
|
if (!riskAssigned) return 12;
|
||||||
// Additional duty round is optional — resolved once skipped or paid.
|
// Additional duty round is optional — resolved once skipped or paid.
|
||||||
const secondDutyResolved =
|
const secondDutyResolved =
|
||||||
clearance.secondDuty?.skipped ||
|
clearance.secondDuty?.skipped ||
|
||||||
clearance.secondDuty?.paid ||
|
clearance.secondDuty?.paid ||
|
||||||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
||||||
if (!secondDutyResolved) return 12;
|
if (!secondDutyResolved) return 13;
|
||||||
if (!clearance.importReleaseGranted) return 13;
|
if (!clearance.importReleaseGranted) return 14;
|
||||||
return 14;
|
return 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
function t1FilesFromWorkflow(
|
function t1FilesFromWorkflow(
|
||||||
@@ -243,6 +247,13 @@ export function PhasedClearanceActionPanel({
|
|||||||
const riskAssigned =
|
const riskAssigned =
|
||||||
Boolean(clearance.riskLevel) ||
|
Boolean(clearance.riskLevel) ||
|
||||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
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(
|
const activeStep = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isImport
|
isImport
|
||||||
@@ -251,9 +262,17 @@ export function PhasedClearanceActionPanel({
|
|||||||
effectiveBookingCreated,
|
effectiveBookingCreated,
|
||||||
bookingMilestones,
|
bookingMilestones,
|
||||||
t1Uploaded,
|
t1Uploaded,
|
||||||
|
freightPaid,
|
||||||
)
|
)
|
||||||
: 0,
|
: 0,
|
||||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
|
[
|
||||||
|
clearance,
|
||||||
|
isImport,
|
||||||
|
effectiveBookingCreated,
|
||||||
|
bookingMilestones,
|
||||||
|
t1Uploaded,
|
||||||
|
freightPaid,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isImport) {
|
if (isImport) {
|
||||||
@@ -554,14 +573,26 @@ export function PhasedClearanceActionPanel({
|
|||||||
)}
|
)}
|
||||||
</Stepper.Step>
|
</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
|
<Stepper.Step
|
||||||
label="Gate pass"
|
label="Gate pass"
|
||||||
description="Secured on the train schedule after wagon allocation"
|
description="Secured on the train schedule after payment and wagon allocation"
|
||||||
icon={
|
icon={
|
||||||
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ImportGatepassStep clearance={clearance} />
|
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
<Stepper.Step
|
||||||
@@ -927,8 +958,16 @@ function ImportT1CloseStep({
|
|||||||
/**
|
/**
|
||||||
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||||
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
* 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;
|
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||||
|
|
||||||
if (clearance.gatepassGranted) {
|
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);
|
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
|
||||||
|
|
||||||
return (
|
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) {
|
if (!canAct || !bookingId) {
|
||||||
return (
|
return (
|
||||||
<StepStatus
|
<StepStatus
|
||||||
|
|||||||
@@ -307,13 +307,24 @@ const RuleEngineFormDialog = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNumber = field.type === "number";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
key={field.name}
|
key={field.name}
|
||||||
label={label}
|
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] ?? "")}
|
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}
|
placeholder={field.placeholder}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
size="md"
|
size="md"
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({
|
|||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
clampBehavior="none"
|
clampBehavior="none"
|
||||||
|
allowNegative={false}
|
||||||
allowDecimal={false}
|
allowDecimal={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({
|
|||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
clampBehavior="none"
|
clampBehavior="none"
|
||||||
|
allowNegative={false}
|
||||||
allowDecimal={false}
|
allowDecimal={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export default function DurationField({
|
|||||||
emitNative(v === "" ? "" : Number(v), unit)
|
emitNative(v === "" ? "" : Number(v), unit)
|
||||||
}
|
}
|
||||||
clampBehavior="none"
|
clampBehavior="none"
|
||||||
|
allowNegative={false}
|
||||||
allowDecimal
|
allowDecimal
|
||||||
min={min != null ? convert(min, nativeUnit, unit) : 0}
|
min={min != null ? convert(min, nativeUnit, unit) : 0}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -67,9 +67,13 @@ export default function EditScheduleDateModal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
// Earliest selectable departure, refreshed each time the modal opens.
|
||||||
|
const [minValue, setMinValue] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (opened) setValue(toLocalInputValue(currentDate));
|
if (!opened) return;
|
||||||
|
setValue(toLocalInputValue(currentDate));
|
||||||
|
setMinValue(toLocalInputValue(new Date().toISOString()));
|
||||||
}, [opened, currentDate]);
|
}, [opened, currentDate]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -77,6 +81,13 @@ export default function EditScheduleDateModal({
|
|||||||
toast({ title: "Pick a departure date", variant: "destructive" });
|
toast({ title: "Pick a departure date", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (new Date(value).getTime() < Date.now()) {
|
||||||
|
toast({
|
||||||
|
title: "Departure date must be in the future",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await save.mutateAsync({
|
await save.mutateAsync({
|
||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
@@ -124,6 +135,7 @@ export default function EditScheduleDateModal({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Departure date"
|
label="Departure date"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
|
min={minValue}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.currentTarget.value)}
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Progress,
|
Progress,
|
||||||
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
@@ -15,9 +16,11 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Container,
|
Container,
|
||||||
Crown,
|
Crown,
|
||||||
|
FlaskConical,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
Layers,
|
Layers,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
|
Radio,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
Trophy,
|
Trophy,
|
||||||
XCircle,
|
XCircle,
|
||||||
@@ -30,6 +33,8 @@ import type {
|
|||||||
BatchBoardScheduleDetail,
|
BatchBoardScheduleDetail,
|
||||||
} from "@/types/trainScheduling";
|
} from "@/types/trainScheduling";
|
||||||
import { WindowPhasePill } from "./batchVisuals";
|
import { WindowPhasePill } from "./batchVisuals";
|
||||||
|
import { ForecastPanel } from "./ForecastPanel";
|
||||||
|
import { forecastIsLive } from "./batchForecast";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
* 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 phase = data.windowPhase;
|
||||||
const isPayPhase = phase === "PAYMENT";
|
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
|
// Rank exactly as the batch engine does: government first, then priority score
|
||||||
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
||||||
// backend uses). The board already returns them in this order, but re-sort
|
// 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;
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
|
||||||
{/* Header: phase + capacity meter */}
|
{/* Header: phase + capacity meter */}
|
||||||
<Paper radius="lg" withBorder p="lg">
|
<Paper radius="lg" withBorder p="lg">
|
||||||
<Group justify="space-between" wrap="wrap" gap="md">
|
<Group justify="space-between" wrap="wrap" gap="md">
|
||||||
|
|||||||
@@ -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 { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
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({
|
export function RescheduleTrainDialog({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
currentBookingIds,
|
currentBookingIds,
|
||||||
@@ -20,12 +30,21 @@ export function RescheduleTrainDialog({
|
|||||||
const [newDepartureDate, setNewDepartureDate] = useState("");
|
const [newDepartureDate, setNewDepartureDate] = useState("");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
// Earliest selectable departure, refreshed each time the dialog opens.
|
||||||
|
const minDepartureDate = useMemo(
|
||||||
|
() => (opened ? nowLocalDateTime() : ""),
|
||||||
|
[opened],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!newDepartureDate) {
|
if (!newDepartureDate) {
|
||||||
toast.error("Select a new departure date");
|
toast.error("Select a new departure date");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (new Date(newDepartureDate).getTime() < Date.now()) {
|
||||||
|
toast.error("New departure must be in the future");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await trainSchedulingService.maintenanceReschedule(scheduleId, {
|
await trainSchedulingService.maintenanceReschedule(scheduleId, {
|
||||||
@@ -53,6 +72,7 @@ export function RescheduleTrainDialog({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="New departure"
|
label="New departure"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
|
min={minDepartureDate}
|
||||||
value={newDepartureDate}
|
value={newDepartureDate}
|
||||||
onChange={(e) => setNewDepartureDate(e.target.value)}
|
onChange={(e) => setNewDepartureDate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
type ContainerItem,
|
type ContainerItem,
|
||||||
type ContainerItemStage,
|
type ContainerItemStage,
|
||||||
} from '@/services/warehouse.service';
|
} from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
interface ContainerItemsModalProps {
|
interface ContainerItemsModalProps {
|
||||||
@@ -36,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
|
|||||||
{ value: 'ALL', label: 'All' },
|
{ value: 'ALL', label: 'All' },
|
||||||
{ value: 'RECEIVED', label: 'Received' },
|
{ value: 'RECEIVED', label: 'Received' },
|
||||||
{ value: 'GRN', label: "GRN'd" },
|
{ value: 'GRN', label: "GRN'd" },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
{ value: 'LOADED', label: 'Loaded' },
|
{ value: 'LOADED', label: 'Loaded' },
|
||||||
{ value: 'LEFT', label: 'Left' },
|
{ value: 'LEFT', label: 'Left' },
|
||||||
{ value: 'DELIVERED', label: 'Delivered' },
|
{ value: 'DELIVERED', label: 'Delivered' },
|
||||||
@@ -45,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
|||||||
PENDING: 'gray',
|
PENDING: 'gray',
|
||||||
RECEIVED: 'blue',
|
RECEIVED: 'blue',
|
||||||
GRN: 'teal',
|
GRN: 'teal',
|
||||||
|
ASSIGNED: 'indigo',
|
||||||
LOADED: 'grape',
|
LOADED: 'grape',
|
||||||
LEFT: 'orange',
|
LEFT: 'orange',
|
||||||
DELIVERED: 'green',
|
DELIVERED: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Loadable = not yet on a truck (before LOADED). */
|
/** 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';
|
const isLoadable = (i: ContainerItem) =>
|
||||||
|
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
||||||
|
|
||||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -76,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||||
[items, tab],
|
[items, tab],
|
||||||
);
|
);
|
||||||
|
// Only arrived, not-yet-departed trucks can be loaded.
|
||||||
const truckOptions = trucks
|
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}` }));
|
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||||
|
|
||||||
const loadMutation = useMutation({
|
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) }),
|
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) => {
|
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||||
} catch (e) {
|
} 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.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>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
{i.truckAssignmentId && (
|
{i.loaded && i.truckAssignmentId && (
|
||||||
<Button
|
<Tooltip
|
||||||
size="compact-xs"
|
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||||
variant="light"
|
disabled={i.handoverSigned}
|
||||||
color="orange"
|
withArrow
|
||||||
leftSection={<FileText size={13} />}
|
multiline
|
||||||
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
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.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
||||||
toast({ title: 'Warehouse updated' });
|
toast({ title: 'Warehouse updated' });
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(payload);
|
await createMutation.mutateAsync({ ...payload, status: form.status });
|
||||||
toast({ title: 'Warehouse created' });
|
toast({ title: 'Warehouse created' });
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
|||||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
/>
|
/>
|
||||||
{isEdit && (
|
<Select
|
||||||
<Select
|
label="Status"
|
||||||
label="Status"
|
data={statusOptions}
|
||||||
data={statusOptions}
|
value={form.status}
|
||||||
value={form.status}
|
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
allowDeselect={false}
|
||||||
allowDeselect={false}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
|
|||||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
|
||||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
|
|||||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
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) {
|
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
|
||||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [viewItem, setViewItem] = 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) => {
|
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||||
setBusyId(item.id);
|
setBusyId(item.id);
|
||||||
try {
|
try {
|
||||||
const stored = await storeMutation.mutateAsync(item.id);
|
const stored = await storeMutation.mutateAsync({ id: item.id });
|
||||||
toast({
|
toast({
|
||||||
title: 'Inventory stored',
|
title: 'Inventory stored',
|
||||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
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) {
|
switch (action) {
|
||||||
case 'store':
|
case 'store':
|
||||||
return storeInventory(item);
|
return storeInventory(item);
|
||||||
case 'reserve':
|
|
||||||
setReserveItem(item);
|
|
||||||
return;
|
|
||||||
case 'ready-for-loading':
|
case 'ready-for-loading':
|
||||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||||
case 'load':
|
case 'load':
|
||||||
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
<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} />
|
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||||
<InventoryHistoryModal
|
<InventoryHistoryModal
|
||||||
opened={Boolean(historyItem)}
|
opened={Boolean(historyItem)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
|
ArrowRightLeft,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -27,6 +29,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
History,
|
History,
|
||||||
Info,
|
Info,
|
||||||
|
MapPin,
|
||||||
|
MoreHorizontal,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
PackageSearch,
|
PackageSearch,
|
||||||
@@ -61,7 +65,6 @@ import type {
|
|||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { BookingSelect } from './BookingSelect';
|
import { BookingSelect } from './BookingSelect';
|
||||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||||
import { TruckDispatchModal } from './TruckDispatchModal';
|
|
||||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||||
import { FeePreviewModal } from './FeePreviewModal';
|
import { FeePreviewModal } from './FeePreviewModal';
|
||||||
import { InspectionReportModal } from './InspectionReportModal';
|
import { InspectionReportModal } from './InspectionReportModal';
|
||||||
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
|
|||||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||||
|
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
|
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||||
import { openPdfBlob } from './pdf';
|
import { openPdfBlob } from './pdf';
|
||||||
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const inspectMutation = useMutation(
|
const inspectMutation = useMutation(
|
||||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||||
);
|
);
|
||||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
|
||||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [deliverItem, setDeliverItem] = 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 [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 allSelected = rows.length > 0 && selected.size === rows.length;
|
||||||
const someSelected = selected.size > 0 && !allSelected;
|
const someSelected = selected.size > 0 && !allSelected;
|
||||||
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="yellow"
|
||||||
loading={busyId === r.id}
|
leftSection={<Truck size={14} />}
|
||||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
>
|
>
|
||||||
Store
|
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||||
</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
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
Exit Paper
|
Exit Paper
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
||||||
<Button
|
<Menu.Target>
|
||||||
size="compact-xs"
|
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
||||||
variant="light"
|
<MoreHorizontal size={16} />
|
||||||
color="green"
|
</ActionIcon>
|
||||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
</Menu.Target>
|
||||||
>
|
<Menu.Dropdown>
|
||||||
Deliver
|
{r.currentStatus === 'UNLOADED' && (
|
||||||
</Button>
|
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
||||||
)}
|
Store…
|
||||||
{r.inspectionStatus === 'PASSED' && (
|
</Menu.Item>
|
||||||
<Button
|
)}
|
||||||
size="compact-xs"
|
{r.currentStatus !== 'UNLOADED' && (
|
||||||
variant="light"
|
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
||||||
color="teal"
|
Move…
|
||||||
leftSection={<FileText size={14} />}
|
</Menu.Item>
|
||||||
onClick={() => openHandoverDocument(r)}
|
)}
|
||||||
>
|
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
||||||
</Button>
|
Ready for pickup
|
||||||
)}
|
</Menu.Item>
|
||||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
)}
|
||||||
Inspect / Report
|
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||||
</Button>
|
<Menu.Item
|
||||||
<Tooltip label="Storage / fee preview" withArrow>
|
leftSection={<Truck size={14} />}
|
||||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
disabled={!r.hasAssignedTruck}
|
||||||
<PackageCheck size={16} />
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
</ActionIcon>
|
>
|
||||||
</Tooltip>
|
{r.hasAssignedTruck
|
||||||
<Tooltip label="History" withArrow>
|
? r.releaseOrderReference
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
? 'Truck leaving'
|
||||||
<History size={16} />
|
: 'Truck arrival'
|
||||||
</ActionIcon>
|
: 'Truck arrival — assign a truck first'}
|
||||||
</Tooltip>
|
</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>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
inventoryId={feeItem?.id ?? null}
|
inventoryId={feeItem?.id ?? null}
|
||||||
/>
|
/>
|
||||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
<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} />
|
<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
|
<ContainerItemsModal
|
||||||
opened={Boolean(containerItemsItem)}
|
opened={Boolean(containerItemsItem)}
|
||||||
onClose={() => setContainerItemsItem(null)}
|
onClose={() => setContainerItemsItem(null)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
|
|||||||
containerNumber?: string | null;
|
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) => {
|
const toIsoDateTime = (value: string) => {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||||
enabled: opened && Boolean(bookingId),
|
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 [reference, setReference] = useState('');
|
||||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||||
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
truckType: t.truckType,
|
truckType: t.truckType,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
const truckSelectOptions = [
|
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||||
...assignedTruckOptions,
|
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||||
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
// operator types the plate manually in the field below.
|
||||||
value: t.value,
|
const truckSelectOptions = assignedTruckOptions;
|
||||||
label: t.label,
|
|
||||||
trailerPlate: t.trailerPlate,
|
|
||||||
driverName: '',
|
|
||||||
driverPhone: '',
|
|
||||||
truckType: '',
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
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 =
|
const computedNetWeight =
|
||||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||||
const weightMismatch =
|
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' });
|
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||||
|
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isExitStep && systemNetWeight === '') {
|
if (isExitStep && systemNetWeight === '') {
|
||||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||||
return;
|
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.
|
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Select
|
{truckSelectOptions.length > 0 && (
|
||||||
label="Registered first / last-mile truck"
|
<Select
|
||||||
placeholder="Select truck or type plate manually below"
|
label="Assigned first / last-mile truck"
|
||||||
searchable
|
placeholder="Select the assigned truck"
|
||||||
clearable
|
searchable
|
||||||
data={truckSelectOptions}
|
clearable
|
||||||
disabled={isTruckIdentityLocked}
|
// Enabled at arrival so the operator picks which assigned truck came;
|
||||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
// only locked on the exit (leaving) step once identity is captured.
|
||||||
onChange={(value) => {
|
disabled={isEntranceLocked}
|
||||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
data={truckSelectOptions}
|
||||||
setTruckPlateNumber(truck?.value ?? '');
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
onChange={(value) => {
|
||||||
if (truck?.driverName) setDriverName(truck.driverName);
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
setTruckPlateNumber(truck?.value ?? '');
|
||||||
if (truck?.truckType) setTruckType(truck.truckType);
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||||
}}
|
if (truck?.driverName) setDriverName(truck.driverName);
|
||||||
/>
|
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||||
|
if (truck?.truckType) setTruckType(truck.truckType);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Truck plate number"
|
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="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} />
|
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow align="flex-start">
|
||||||
<Stack gap={6}>
|
{hasContainerWeights ? (
|
||||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
<MultiSelect
|
||||||
{containerNumbers.map((containerNumber, index) => (
|
label="Containers on this truck"
|
||||||
<TextInput
|
description={
|
||||||
key={index}
|
isExitStep
|
||||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||||||
value={containerNumber}
|
: 'Containers this truck will carry.'
|
||||||
onChange={(e) =>
|
}
|
||||||
setContainerNumbers((numbers) =>
|
placeholder="Select containers"
|
||||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
searchable
|
||||||
)
|
data={containerSelectData}
|
||||||
}
|
value={selectedContainerNumbers}
|
||||||
readOnly={isTruckIdentityLocked}
|
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||||
/>
|
/>
|
||||||
))}
|
) : (
|
||||||
</SimpleGrid>
|
<Stack gap={6}>
|
||||||
</Stack>
|
<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} />
|
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
<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="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>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
|
|||||||
|
|
||||||
const actionColor: Record<InventoryAction, string> = {
|
const actionColor: Record<InventoryAction, string> = {
|
||||||
store: 'blue',
|
store: 'blue',
|
||||||
reserve: 'grape',
|
|
||||||
'ready-for-loading': 'cyan',
|
'ready-for-loading': 'cyan',
|
||||||
load: 'teal',
|
load: 'teal',
|
||||||
dispatch: 'edr-green',
|
dispatch: 'edr-green',
|
||||||
|
|||||||
@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
|||||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
tracking: {
|
tracking: {
|
||||||
view: "edr_freight_app:tracking:view",
|
view: "edr_freight_app:tracking:view",
|
||||||
|
manage: "edr_freight_app:tracking:manage",
|
||||||
},
|
},
|
||||||
fuel: {
|
fuel: {
|
||||||
view: "edr_freight_app:fuel:view",
|
view: "edr_freight_app:fuel:view",
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
hideSummary
|
hideSummary
|
||||||
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||||
queriesLocked={queriesLocked}
|
queriesLocked={queriesLocked}
|
||||||
|
phasedCustoms={isPhasedGeneral}
|
||||||
onChanged={() => void refetch()}
|
onChanged={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|||||||
@@ -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 navigate = useNavigate();
|
||||||
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
const [pageTab, setPageTab] = useState<PageTab>("queue");
|
||||||
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
|
||||||
@@ -139,7 +148,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
const isHistory = pageTab === "history";
|
const isHistory = pageTab === "history";
|
||||||
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||||
queryKey: ["clearance", "list", isHistory],
|
queryKey: ["clearance", "list", isHistory, opsMode],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
bookingsService.list({
|
bookingsService.list({
|
||||||
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
|
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
|
||||||
@@ -148,8 +157,10 @@ export default function DocumentClearanceListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const allRows = useMemo(() => {
|
const allRows = useMemo(() => {
|
||||||
// GL clearance queue: customs bookings only
|
// opsMode: self-clearance (non-customs) bookings; else customs bookings only.
|
||||||
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
|
const rows = (data?.items ?? [])
|
||||||
|
.map(toClearanceRow)
|
||||||
|
.filter((r) => (opsMode ? !r.hasCustoms : r.hasCustoms));
|
||||||
|
|
||||||
if (isHistory) {
|
if (isHistory) {
|
||||||
return [...rows].sort((a, b) => {
|
return [...rows].sort((a, b) => {
|
||||||
@@ -159,7 +170,7 @@ export default function DocumentClearanceListPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [data?.items, isHistory]);
|
}, [data?.items, isHistory, opsMode]);
|
||||||
|
|
||||||
const tabCounts = useMemo(
|
const tabCounts = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -310,8 +321,12 @@ export default function DocumentClearanceListPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Document Clearance"
|
title={opsMode ? "Self-Clearance Review" : "Document Clearance"}
|
||||||
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
|
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}
|
meta={statusBadge}
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
PackagePlus,
|
PackagePlus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
Send,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShipWheel,
|
ShipWheel,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
@@ -49,11 +50,13 @@ import {
|
|||||||
useContractClearanceQueue,
|
useContractClearanceQueue,
|
||||||
useEtClearanceQueue,
|
useEtClearanceQueue,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
type QueueTab = "all" | "et";
|
type QueueTab = "all" | "et" | "shipments";
|
||||||
|
|
||||||
interface ClearanceRow {
|
interface ClearanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -194,6 +197,10 @@ export default function ContractClearanceListPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||||
|
const canCreateBooking = hasPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.contracts.createBooking,
|
||||||
|
);
|
||||||
|
|
||||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||||
@@ -202,16 +209,29 @@ export default function ContractClearanceListPage() {
|
|||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
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 } =
|
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||||
useEtClearanceQueue(queueTab === "et");
|
useEtClearanceQueue(queueTab === "et");
|
||||||
|
const {
|
||||||
|
data: bookingQueue,
|
||||||
|
isLoading: bookingsLoading,
|
||||||
|
isError: bookingsError,
|
||||||
|
isFetching: bookingsFetching,
|
||||||
|
refetch: refetchBookings,
|
||||||
|
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
||||||
|
|
||||||
const data = queueTab === "et" ? etData : allData;
|
const data = queueTab === "et" ? etData : allData;
|
||||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||||
const isError = queueTab === "et" ? etError : allError;
|
const isError = queueTab === "et" ? etError : allError;
|
||||||
const isFetching = queueTab === "et" ? etFetching : allFetching;
|
const isFetching =
|
||||||
|
queueTab === "et"
|
||||||
|
? etFetching
|
||||||
|
: queueTab === "shipments"
|
||||||
|
? bookingsFetching
|
||||||
|
: allFetching;
|
||||||
const refetch = () => {
|
const refetch = () => {
|
||||||
if (queueTab === "et") void refetchEt();
|
if (queueTab === "et") void refetchEt();
|
||||||
|
else if (queueTab === "shipments") void refetchBookings();
|
||||||
else void refetchAll();
|
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;
|
return opts;
|
||||||
}, [canReview, canEt]);
|
}, [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(
|
const allRows = useMemo(
|
||||||
() => (data?.items ?? []).map(toClearanceRow),
|
() => (data?.items ?? []).map(toClearanceRow),
|
||||||
[data?.items],
|
[data?.items],
|
||||||
@@ -269,7 +323,7 @@ export default function ContractClearanceListPage() {
|
|||||||
);
|
);
|
||||||
}, [allRows, query]);
|
}, [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 pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
const pagedRows = useMemo(() => {
|
const pagedRows = useMemo(() => {
|
||||||
@@ -410,16 +464,29 @@ export default function ContractClearanceListPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<Group gap="sm" wrap="nowrap">
|
||||||
variant="default"
|
{canCreateBooking ? (
|
||||||
size="lg"
|
<Button
|
||||||
radius="md"
|
variant="filled"
|
||||||
onClick={() => refetch()}
|
color="edr-green"
|
||||||
loading={isFetching}
|
radius="md"
|
||||||
aria-label="Refresh"
|
leftSection={<Send size={15} />}
|
||||||
>
|
onClick={() => navigate("/dashboard/shipment-requests")}
|
||||||
<RefreshCw size={16} />
|
>
|
||||||
</ActionIcon>
|
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>
|
</Group>
|
||||||
</Box>
|
</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">
|
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||||
<DataTable<ClearanceRow, unknown>
|
<DataTable<ClearanceRow, unknown>
|
||||||
columns={columns}
|
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({
|
function ClearanceCardGrid({
|
||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() {
|
|||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
{data.kind === "booking" ? (
|
{data.kind === "booking" ? (
|
||||||
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
|
<ClearanceReviewSection
|
||||||
|
bookingId={id!}
|
||||||
|
hideSummary
|
||||||
|
readOnly
|
||||||
|
phasedCustoms
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ContractClearanceReviewSection
|
<ContractClearanceReviewSection
|
||||||
contractId={id!}
|
contractId={id!}
|
||||||
|
|||||||
@@ -1,62 +1,99 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
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 { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
|
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
export default function GlDjiboutiClearanceListPage() {
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||||
|
const { data: bookingQueue, isLoading: bookingsLoading } =
|
||||||
|
useBookingDjClearanceQueue();
|
||||||
|
|
||||||
const contractItems = contractQueue?.items ?? [];
|
const contractItems = contractQueue?.items ?? [];
|
||||||
|
const bookingItems = bookingQueue ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="GL Djibouti — Clearance"
|
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}>
|
<Group justify="center" py={60}>
|
||||||
<Loader color="edr-green" />
|
<Loader color="edr-green" />
|
||||||
</Group>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{contractItems.length === 0 ? (
|
{contractItems.length === 0 && bookingItems.length === 0 ? (
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
No Djibouti customs contracts yet.
|
No Djibouti customs work yet.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
contractItems.map((c) => (
|
<>
|
||||||
<Card
|
{contractItems.map((c) => (
|
||||||
key={c.id}
|
<Card
|
||||||
withBorder
|
key={c.id}
|
||||||
radius="md"
|
withBorder
|
||||||
padding="md"
|
radius="md"
|
||||||
style={{ cursor: "pointer" }}
|
padding="md"
|
||||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||||
<Group justify="space-between" wrap="nowrap">
|
>
|
||||||
<Group gap="sm">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
<Group gap="sm">
|
||||||
<div>
|
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
<Text fw={700}>{c.reference}</Text>
|
<div>
|
||||||
<Text size="sm" c="dimmed">
|
<Text fw={700}>{c.reference}</Text>
|
||||||
{c.tradeDirection} · {c.status}
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
{c.tradeDirection} · {c.status}
|
||||||
</div>
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color="edr-green">
|
||||||
|
Contract
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
</Card>
|
||||||
<Badge variant="light" color="edr-green">
|
))}
|
||||||
Contract
|
{bookingItems.map((b) => (
|
||||||
</Badge>
|
<Card
|
||||||
<ChevronRight size={18} className="text-muted-foreground" />
|
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>
|
||||||
</Group>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
))
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ const normalizePayload = (values: Record<string, FormValue>) =>
|
|||||||
.filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)),
|
.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 extractBackendErrors = (error: unknown) => {
|
||||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
const data = responseData && typeof responseData === 'object' ? responseData as Record<string, unknown> : undefined;
|
const data = responseData && typeof responseData === 'object' ? responseData as Record<string, unknown> : undefined;
|
||||||
@@ -536,7 +540,7 @@ export function WagonTypesCrudPage() {
|
|||||||
name: '',
|
name: '',
|
||||||
capacityTons: 0,
|
capacityTons: 0,
|
||||||
lengthMeters: 0,
|
lengthMeters: 0,
|
||||||
maxWagonsPerTrain: '',
|
tareWeightTons: '',
|
||||||
supportedLoadTypes: '',
|
supportedLoadTypes: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
@@ -587,7 +591,7 @@ export function WagonTypesCrudPage() {
|
|||||||
name: '',
|
name: '',
|
||||||
capacityTons: 0,
|
capacityTons: 0,
|
||||||
lengthMeters: 0,
|
lengthMeters: 0,
|
||||||
maxWagonsPerTrain: '',
|
tareWeightTons: '',
|
||||||
supportedLoadTypes: '',
|
supportedLoadTypes: '',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
@@ -601,7 +605,7 @@ export function WagonTypesCrudPage() {
|
|||||||
name: type.name ?? '',
|
name: type.name ?? '',
|
||||||
capacityTons: type.capacityTons ?? 0,
|
capacityTons: type.capacityTons ?? 0,
|
||||||
lengthMeters: type.lengthMeters ?? 0,
|
lengthMeters: type.lengthMeters ?? 0,
|
||||||
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
|
tareWeightTons: type.tareWeightTons ?? '',
|
||||||
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
|
||||||
isActive: type.isActive,
|
isActive: type.isActive,
|
||||||
});
|
});
|
||||||
@@ -610,10 +614,17 @@ export function WagonTypesCrudPage() {
|
|||||||
|
|
||||||
const validateWagonType = () => {
|
const validateWagonType = () => {
|
||||||
const errors: Record<string, string> = {};
|
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.code ?? '').trim()) errors.code = 'Code is required';
|
||||||
if (!String(form.name ?? '').trim()) errors.name = 'Name 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 (!positive(form.capacityTons)) errors.capacityTons = 'Capacity must be greater than 0';
|
||||||
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
|
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;
|
return errors;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -710,6 +721,7 @@ export function WagonTypesCrudPage() {
|
|||||||
</MantineButton>
|
</MantineButton>
|
||||||
</MantineTable.Th>
|
</MantineTable.Th>
|
||||||
<MantineTable.Th>Length (m)</MantineTable.Th>
|
<MantineTable.Th>Length (m)</MantineTable.Th>
|
||||||
|
<MantineTable.Th>Tare weight (tons)</MantineTable.Th>
|
||||||
<MantineTable.Th>Load types</MantineTable.Th>
|
<MantineTable.Th>Load types</MantineTable.Th>
|
||||||
<MantineTable.Th>Status</MantineTable.Th>
|
<MantineTable.Th>Status</MantineTable.Th>
|
||||||
<MantineTable.Th ta="right">Actions</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.name}</MantineTable.Td>
|
||||||
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
|
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
|
||||||
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
|
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
|
||||||
|
<MantineTable.Td>{type.tareWeightTons ?? '-'}</MantineTable.Td>
|
||||||
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
|
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
|
||||||
<MantineTable.Td>
|
<MantineTable.Td>
|
||||||
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
|
<MantineBadge color={type.isActive === false ? 'gray' : 'edr-green'} variant="light">
|
||||||
@@ -750,7 +763,7 @@ export function WagonTypesCrudPage() {
|
|||||||
))}
|
))}
|
||||||
{!query.isLoading && filtered.length === 0 ? (
|
{!query.isLoading && filtered.length === 0 ? (
|
||||||
<MantineTable.Tr>
|
<MantineTable.Tr>
|
||||||
<MantineTable.Td colSpan={7}>
|
<MantineTable.Td colSpan={8}>
|
||||||
<Text ta="center" c="dimmed" py="xl">
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
No wagon types found.
|
No wagon types found.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -759,7 +772,7 @@ export function WagonTypesCrudPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<MantineTable.Tr>
|
<MantineTable.Tr>
|
||||||
<MantineTable.Td colSpan={7}>
|
<MantineTable.Td colSpan={8}>
|
||||||
<Text ta="center" c="dimmed" py="xl">
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
Loading...
|
Loading...
|
||||||
</Text>
|
</Text>
|
||||||
@@ -815,10 +828,13 @@ export function WagonTypesCrudPage() {
|
|||||||
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<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}
|
min={0}
|
||||||
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
|
value={form.tareWeightTons === '' || form.tareWeightTons == null ? '' : Number(form.tareWeightTons)}
|
||||||
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
|
error={fieldErrors.tareWeightTons}
|
||||||
|
onChange={(value) => setForm((current) => ({ ...current, tareWeightTons: value }))}
|
||||||
/>
|
/>
|
||||||
<MantineSelect
|
<MantineSelect
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -910,7 +926,17 @@ export function WagonsCrudPage() {
|
|||||||
? `${wagon.currentLocationYard.label ?? wagon.currentLocationYard.code} (${wagon.currentLocationYard.country ?? '-'})`
|
? `${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) },
|
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
|
||||||
]}
|
]}
|
||||||
fields={[
|
fields={[
|
||||||
@@ -921,11 +947,6 @@ export function WagonsCrudPage() {
|
|||||||
type: 'select',
|
type: 'select',
|
||||||
required: true,
|
required: true,
|
||||||
options: wagonTypeOptions,
|
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',
|
key: 'currentLocationYardId',
|
||||||
@@ -934,8 +955,6 @@ export function WagonsCrudPage() {
|
|||||||
required: true,
|
required: true,
|
||||||
options: yardOptions,
|
options: yardOptions,
|
||||||
},
|
},
|
||||||
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
|
|
||||||
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
|
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
@@ -951,7 +970,7 @@ export function WagonsCrudPage() {
|
|||||||
},
|
},
|
||||||
{ key: 'notes', label: 'Notes' },
|
{ 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: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
||||||
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
||||||
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
{ 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={[
|
fields={[
|
||||||
{ key: 'code', label: 'Code', required: true },
|
{ 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: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', 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: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||||
@@ -1157,6 +1191,8 @@ export function LocomotivesCrudPage() {
|
|||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
maxPullWeightTons: 0,
|
maxPullWeightTons: 0,
|
||||||
maxTrainLengthMeters: 760,
|
maxTrainLengthMeters: 760,
|
||||||
|
overageToleranceTons: '',
|
||||||
|
overageToleranceMeters: '',
|
||||||
powerKw: '',
|
powerKw: '',
|
||||||
tractionForceKn: '',
|
tractionForceKn: '',
|
||||||
maxSpeedKmh: '',
|
maxSpeedKmh: '',
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
|
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
|
|||||||
export function TrackingPage() {
|
export function TrackingPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||||
const [mapsReady, setMapsReady] = useState(false);
|
const [mapsReady, setMapsReady] = useState(false);
|
||||||
@@ -288,9 +292,11 @@ export function TrackingPage() {
|
|||||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
||||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
{canManage && (
|
||||||
Register tracker
|
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||||
</Button>
|
Register tracker
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -358,9 +364,11 @@ export function TrackingPage() {
|
|||||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||||
{selected.online ? "Live" : "Offline"}
|
{selected.online ? "Live" : "Offline"}
|
||||||
</Badge>
|
</Badge>
|
||||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
{canManage && (
|
||||||
<Trash2 size={16} />
|
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||||
</ActionIcon>
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -393,6 +401,7 @@ export function TrackingPage() {
|
|||||||
data={vehicleOptions}
|
data={vehicleOptions}
|
||||||
value={selected.vehicleId ?? null}
|
value={selected.vehicleId ?? null}
|
||||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||||
|
disabled={!canManage}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
@@ -421,14 +430,16 @@ export function TrackingPage() {
|
|||||||
<Table.Td align="right">
|
<Table.Td align="right">
|
||||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||||
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
||||||
<ActionIcon
|
{canManage && (
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
size="sm"
|
variant="subtle"
|
||||||
aria-label="Edit tracker"
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
aria-label="Edit tracker"
|
||||||
>
|
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||||
<Pencil size={15} />
|
>
|
||||||
</ActionIcon>
|
<Pencil size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", 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.
|
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
|
||||||
formFields: [
|
formFields: [
|
||||||
@@ -167,6 +169,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||||
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
||||||
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", 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: "powerKw", label: "Power (kW)", type: "number" },
|
||||||
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
|
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
|
||||||
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
||||||
@@ -178,6 +185,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
currentYardId: "",
|
currentYardId: "",
|
||||||
maxPullWeightTons: 2500,
|
maxPullWeightTons: 2500,
|
||||||
maxTrainLengthMeters: 760,
|
maxTrainLengthMeters: 760,
|
||||||
|
overageToleranceTons: "",
|
||||||
|
overageToleranceMeters: "",
|
||||||
powerKw: "",
|
powerKw: "",
|
||||||
tractionForceKn: "",
|
tractionForceKn: "",
|
||||||
maxSpeedKmh: "",
|
maxSpeedKmh: "",
|
||||||
@@ -254,17 +263,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
cardSubtitleKey: "currentYard",
|
cardSubtitleKey: "currentYard",
|
||||||
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
|
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "currentYardId"],
|
||||||
columns: [
|
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: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
|
||||||
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
|
{ 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: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
|
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
|
||||||
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
|
{ 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: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||||
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
|
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
|
||||||
{ name: "notes", label: "Notes", type: "textarea" },
|
{ name: "notes", label: "Notes", type: "textarea" },
|
||||||
@@ -272,8 +280,6 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
emptyValues: {
|
emptyValues: {
|
||||||
wagonNumber: "",
|
wagonNumber: "",
|
||||||
wagonTypeId: "",
|
wagonTypeId: "",
|
||||||
tareWeight: 0,
|
|
||||||
maxPayloadWeight: 0,
|
|
||||||
currentYardId: "",
|
currentYardId: "",
|
||||||
status: Freight.WagonStatus.Available,
|
status: Freight.WagonStatus.Available,
|
||||||
notes: "",
|
notes: "",
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
|||||||
handoverDocumentReference: row.handoverDocumentReference,
|
handoverDocumentReference: row.handoverDocumentReference,
|
||||||
handoverDocumentDate: row.handoverDocumentDate,
|
handoverDocumentDate: row.handoverDocumentDate,
|
||||||
deliveredAt: row.deliveredAt,
|
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
|
booking: row.bookingId
|
||||||
? {
|
? {
|
||||||
id: row.bookingId,
|
id: row.bookingId,
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ id: "name", header: "Name", accessorKey: "name" },
|
{ id: "name", header: "Name", accessorKey: "name" },
|
||||||
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||||
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", 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",
|
id: "supportedLoadTypes",
|
||||||
header: "Load types",
|
header: "Load types",
|
||||||
@@ -291,11 +291,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ name: "name", label: "Name", type: "text", required: true },
|
{ name: "name", label: "Name", type: "text", required: true },
|
||||||
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
|
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
|
||||||
{ name: "lengthMeters", label: "Length (meters)", 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",
|
name: "tareWeightTons",
|
||||||
label: "Max wagons per train",
|
label: "Tare weight (tons)",
|
||||||
type: "number",
|
type: "number",
|
||||||
optional: true,
|
required: true,
|
||||||
|
description: "Empty wagon weight — counts against the locomotive's pull limit alongside the cargo",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "supportedLoadTypes",
|
name: "supportedLoadTypes",
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ function CapacityChip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gross weight (wagon tare + cargo) against the locomotive's pull limit. */
|
||||||
function weightPctOf(s: BatchBoardSchedule) {
|
function weightPctOf(s: BatchBoardSchedule) {
|
||||||
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
|
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
|
||||||
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
|
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
|
||||||
@@ -185,6 +186,11 @@ function lengthPctOf(s: BatchBoardSchedule) {
|
|||||||
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
|
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
|
||||||
: null;
|
: 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 }) {
|
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -192,6 +198,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
|||||||
|
|
||||||
const lengthPct = lengthPctOf(schedule);
|
const lengthPct = lengthPctOf(schedule);
|
||||||
const weightPct = weightPctOf(schedule);
|
const weightPct = weightPctOf(schedule);
|
||||||
|
const wagonPct = wagonPctOf(schedule);
|
||||||
|
|
||||||
const totalBookings = totalBookingCount(counts);
|
const totalBookings = totalBookingCount(counts);
|
||||||
|
|
||||||
@@ -275,7 +282,7 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* capacity: weight + length rings + wagons (numbers preserved) */}
|
{/* capacity: the three axes a train is limited by — gross weight, wagon slots, length */}
|
||||||
<Box
|
<Box
|
||||||
py="sm"
|
py="sm"
|
||||||
px="xs"
|
px="xs"
|
||||||
@@ -289,26 +296,35 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
|||||||
{weightPct != null ? (
|
{weightPct != null ? (
|
||||||
<CapacityRing
|
<CapacityRing
|
||||||
pct={weightPct}
|
pct={weightPct}
|
||||||
label="WEIGHT"
|
label="GROSS WT"
|
||||||
current={fmtTons(capacity.usedWeightTons)}
|
current={fmtTons(capacity.usedWeightTons)}
|
||||||
max={fmtTons(capacity.maxWeightTons ?? 0)}
|
max={fmtTons(capacity.maxWeightTons ?? 0)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
{wagonPct != null ? (
|
||||||
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
<CapacityRing
|
||||||
<Package size={17} />
|
pct={wagonPct}
|
||||||
</ThemeIcon>
|
label="WAGONS"
|
||||||
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
current={String(capacity.allocatedWagons)}
|
||||||
{capacity.allocatedWagons}
|
max={String(capacity.maxWagons ?? 0)}
|
||||||
</Text>
|
/>
|
||||||
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
) : (
|
||||||
Wagons
|
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
||||||
</Text>
|
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
||||||
<Text size="xs" c="dimmed">
|
<Package size={17} />
|
||||||
allocated
|
</ThemeIcon>
|
||||||
</Text>
|
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
||||||
</Stack>
|
{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 ? (
|
{lengthPct != null ? (
|
||||||
<CapacityRing
|
<CapacityRing
|
||||||
@@ -519,30 +535,42 @@ export default function BatchBoardPage() {
|
|||||||
id: "capacity",
|
id: "capacity",
|
||||||
header: "Capacity",
|
header: "Capacity",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<Group gap={6} wrap="nowrap">
|
const { allocatedWagons, maxWagons } = row.original.capacity;
|
||||||
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
|
const wagonPct = wagonPctOf(row.original);
|
||||||
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
return (
|
||||||
<Group
|
<Group gap={6} wrap="nowrap">
|
||||||
gap={4}
|
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="gross" />
|
||||||
wrap="nowrap"
|
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
||||||
style={{
|
{wagonPct != null ? (
|
||||||
padding: "2px 8px",
|
<CapacityChip
|
||||||
borderRadius: 8,
|
icon={Package}
|
||||||
background: "var(--mantine-color-edr-green-0)",
|
pct={wagonPct}
|
||||||
border: "1px solid var(--mantine-color-edr-green-1)",
|
text={`${allocatedWagons}/${maxWagons} wgn`}
|
||||||
}}
|
/>
|
||||||
>
|
) : (
|
||||||
<Package size={12} color="var(--mantine-color-edr-green-7)" />
|
<Group
|
||||||
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
|
gap={4}
|
||||||
{row.original.capacity.allocatedWagons}
|
wrap="nowrap"
|
||||||
</Text>
|
style={{
|
||||||
<Text size="10px" c="dimmed" lh={1.2}>
|
padding: "2px 8px",
|
||||||
wgn
|
borderRadius: 8,
|
||||||
</Text>
|
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>
|
||||||
</Group>
|
);
|
||||||
),
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bookings",
|
id: "bookings",
|
||||||
|
|||||||
@@ -892,7 +892,9 @@ export default function BatchScheduleDetailPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Allocated wagons",
|
label: "Allocated wagons",
|
||||||
value: data.capacity.allocatedWagons,
|
value: data.capacity.maxWagons
|
||||||
|
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
||||||
|
: data.capacity.allocatedWagons,
|
||||||
hint: "on this train",
|
hint: "on this train",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
},
|
},
|
||||||
@@ -904,10 +906,11 @@ export default function BatchScheduleDetailPage() {
|
|||||||
icon: Ruler,
|
icon: Ruler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Weight",
|
label: "Gross weight",
|
||||||
value: data.capacity.maxWeightTons
|
value: data.capacity.maxWeightTons
|
||||||
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
? `${fmtTons(data.capacity.usedWeightTons)} / ${fmtTons(data.capacity.maxWeightTons)}`
|
||||||
: fmtTons(data.capacity.usedWeightTons),
|
: fmtTons(data.capacity.usedWeightTons),
|
||||||
|
hint: "wagon tare + cargo",
|
||||||
icon: Weight,
|
icon: Weight,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user