mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
@@ -53,23 +53,23 @@ import {
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
// Disabled seeds — imports commented out with their provider/injection/run below.
|
||||
// import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder";
|
||||
// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
@@ -195,22 +195,22 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
PricingDataSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
DemoFreightDataSeeder,
|
||||
GovCompaniesSeeder,
|
||||
EdrTruckFleetSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
// FreightStaffUsersSeeder,
|
||||
// PricingDataSeeder,
|
||||
// DemoFreightDataSeeder,
|
||||
// GovCompaniesSeeder,
|
||||
// IndodeFacilitySeeder,
|
||||
// Batch14TestDataSeeder,
|
||||
// Batch5TestDataSeeder,
|
||||
// Batch7TestDataSeeder,
|
||||
// Batch8TestDataSeeder,
|
||||
// WarehouseDemoSeeder,
|
||||
// ExportDjiboutiInterchangeDemoSeeder,
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
],
|
||||
@@ -220,53 +220,66 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
// private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
// private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
// private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
// private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
// private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||
// private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
// private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
// private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
// private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
// private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
// private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
// private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
// ── Enabled: permissions + file-upload settings (+ dropdown settings) only ──
|
||||
// Everything else below is intentionally disabled. Seeders stay registered
|
||||
// as providers and injected; only their .run() calls are commented out, so
|
||||
// re-enabling any of them is a one-line uncomment.
|
||||
|
||||
// Permissions foundation — keep enabled:
|
||||
// freightPermissionKeyMigration → renames legacy permission keys
|
||||
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
|
||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
await this.indodeFacilitySeeder.run();
|
||||
await this.batch14TestDataSeeder.run();
|
||||
await this.batch5TestDataSeeder.run();
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||
await this.demoFreightDataSeeder.run();
|
||||
// Government entities (with importer/exporter profiles) that government
|
||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||
await this.govCompaniesSeeder.run();
|
||||
await this.edrTruckFleetSeeder.run();
|
||||
|
||||
// Dropdown settings are not seeded on boot; run them with
|
||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||
|
||||
// ── Disabled: demo / test / reference data seeds ──
|
||||
// Uncomment a line to re-enable that seed.
|
||||
// await this.demoUsersSeeder.run();
|
||||
// await this.freightStaffUsersSeeder.run();
|
||||
// await this.pricingDataSeeder.run();
|
||||
// await this.indodeFacilitySeeder.run();
|
||||
// await this.batch14TestDataSeeder.run();
|
||||
// await this.batch5TestDataSeeder.run();
|
||||
// await this.batch7TestDataSeeder.run();
|
||||
// await this.batch8TestDataSeeder.run();
|
||||
// await this.warehouseDemoSeeder.run();
|
||||
// await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
// await this.marshallingDemoTrainsSeeder.run();
|
||||
// demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval
|
||||
// rules are already disabled inside the seeder).
|
||||
// await this.demoFreightDataSeeder.run();
|
||||
// Government entities (importer/exporter profiles) that government bookings
|
||||
// bill to. Idempotent — keyed by fixed IDs.
|
||||
// await this.govCompaniesSeeder.run();
|
||||
}
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@@ -26,6 +26,18 @@ export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
|
||||
|
||||
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
||||
|
||||
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
|
||||
export const WagonTransferRequest = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferRequest);
|
||||
|
||||
/** OCC fulfils a wagon-transfer request — picks the wagons and executes the move. */
|
||||
export const WagonTransferFulfill = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferFulfill);
|
||||
|
||||
/** Admin: read every staffer's wagon-transfer history (not just one's own). */
|
||||
export const WagonTransferHistoryAll = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* The person who signs off a handover must record their full name (a signature
|
||||
* is optional, especially for self-haul). Stored per handover record.
|
||||
*/
|
||||
export class AddHandoverSignerName2130000000000 implements MigrationInterface {
|
||||
name = "AddHandoverSignerName2130000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
ADD COLUMN IF NOT EXISTS signer_name varchar(160)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
DROP COLUMN IF EXISTS signer_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
|
||||
* accrual as reviewed (optionally snoozed until a date) so it stops nudging and
|
||||
* drops down the accrual dashboard. One row per inventory item.
|
||||
*/
|
||||
export class CreateAccrualAcks2140000000000 implements MigrationInterface {
|
||||
name = "CreateAccrualAcks2140000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id uuid NOT NULL UNIQUE,
|
||||
acknowledged_by uuid,
|
||||
acknowledged_at timestamptz NOT NULL DEFAULT now(),
|
||||
snooze_until timestamptz,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Train Builder: a `Train` becomes a first-class buildable consist — a coded
|
||||
* train (e.g. 81001) assembled in one yard from 2+ locomotives and ordered
|
||||
* wagons, then reused by scheduling ("schedule the train" instead of picking
|
||||
* locomotives per departure).
|
||||
*
|
||||
* - `freight.train_locomotives` — link table train ⇄ locomotive with an order
|
||||
* index (mirrors `train_set_locomotives`).
|
||||
* - `trains.current_yard_id` — yard the train sits in; wagons/locomotives may
|
||||
* only be attached from this yard.
|
||||
* - `train_sets.train_id` — which built train an operational set was formed
|
||||
* from, so schedules can surface the train code and the lifecycle can sync
|
||||
* the train's status/yard on dispatch/arrival/cancel.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class TrainBuilder2150000000000 implements MigrationInterface {
|
||||
name = 'TrainBuilder2150000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_locomotives (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
train_id uuid NOT NULL,
|
||||
locomotive_id uuid NOT NULL,
|
||||
sequence_no int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT "PK_train_locomotives" PRIMARY KEY (id),
|
||||
CONSTRAINT "FK_train_locomotives_train" FOREIGN KEY (train_id)
|
||||
REFERENCES freight.trains (id) ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_train_locomotives_locomotive" FOREIGN KEY (locomotive_id)
|
||||
REFERENCES freight.locomotives (id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_locomotives_train_loco"
|
||||
ON freight.train_locomotives (train_id, locomotive_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS current_yard_id uuid;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_trains_current_yard'
|
||||
) THEN
|
||||
ALTER TABLE freight.trains
|
||||
ADD CONSTRAINT "FK_trains_current_yard" FOREIGN KEY (current_yard_id)
|
||||
REFERENCES freight.yards (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_trains_current_yard_id"
|
||||
ON freight.trains (current_yard_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_sets
|
||||
ADD COLUMN IF NOT EXISTS train_id uuid;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_train_sets_train'
|
||||
) THEN
|
||||
ALTER TABLE freight.train_sets
|
||||
ADD CONSTRAINT "FK_train_sets_train" FOREIGN KEY (train_id)
|
||||
REFERENCES freight.trains (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_train_sets_train_id"
|
||||
ON freight.train_sets (train_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_train_sets_train_id";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_sets
|
||||
DROP CONSTRAINT IF EXISTS "FK_train_sets_train",
|
||||
DROP COLUMN IF EXISTS train_id;
|
||||
`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_trains_current_yard_id";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP CONSTRAINT IF EXISTS "FK_trains_current_yard",
|
||||
DROP COLUMN IF EXISTS current_yard_id;
|
||||
`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_train_locomotives_train_loco";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_locomotives;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* A container type / cargo type can now be carried by SEVERAL wagon types
|
||||
* (e.g. a 20ft container rides NX70 or NW5). Replaces the single
|
||||
* `wagon_type_id` FK on both tables with proper link tables; train scheduling
|
||||
* resolves the wagon type from the list, picking whichever type the schedule's
|
||||
* built train (or the yard) actually has.
|
||||
*
|
||||
* Backfills one link row from each existing `wagon_type_id`, then drops the
|
||||
* old column — the single-FK field is removed from the API and UI entirely.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class MultiWagonTypePerCargoAndContainer2160000000000 implements MigrationInterface {
|
||||
name = 'MultiWagonTypePerCargoAndContainer2160000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.container_type_wagon_types (
|
||||
container_type_id uuid NOT NULL,
|
||||
wagon_type_id uuid NOT NULL,
|
||||
CONSTRAINT "PK_container_type_wagon_types" PRIMARY KEY (container_type_id, wagon_type_id),
|
||||
CONSTRAINT "FK_ctwt_container_type" FOREIGN KEY (container_type_id)
|
||||
REFERENCES freight.container_types (id) ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_ctwt_wagon_type" FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.cargo_type_wagon_types (
|
||||
cargo_type_id uuid NOT NULL,
|
||||
wagon_type_id uuid NOT NULL,
|
||||
CONSTRAINT "PK_cargo_type_wagon_types" PRIMARY KEY (cargo_type_id, wagon_type_id),
|
||||
CONSTRAINT "FK_cgwt_cargo_type" FOREIGN KEY (cargo_type_id)
|
||||
REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_cgwt_wagon_type" FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
|
||||
// Backfill from the old single FK (column may already be gone on re-run).
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'container_types'
|
||||
AND column_name = 'wagon_type_id'
|
||||
) THEN
|
||||
INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
|
||||
SELECT ct.id, ct.wagon_type_id
|
||||
FROM freight.container_types ct
|
||||
WHERE ct.wagon_type_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'cargo_types'
|
||||
AND column_name = 'wagon_type_id'
|
||||
) THEN
|
||||
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||
SELECT cg.id, cg.wagon_type_id
|
||||
FROM freight.cargo_types cg
|
||||
WHERE cg.wagon_type_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Old single-FK column is fully retired (API + UI now use the lists).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid
|
||||
REFERENCES freight.wagon_types (id) ON DELETE RESTRICT;
|
||||
`);
|
||||
// Restore the first linked wagon type per row, then drop the link tables.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types ct
|
||||
SET wagon_type_id = link.wagon_type_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (container_type_id) container_type_id, wagon_type_id
|
||||
FROM freight.container_type_wagon_types
|
||||
ORDER BY container_type_id, wagon_type_id
|
||||
) link
|
||||
WHERE link.container_type_id = ct.id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.cargo_types cg
|
||||
SET wagon_type_id = link.wagon_type_id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (cargo_type_id) cargo_type_id, wagon_type_id
|
||||
FROM freight.cargo_type_wagon_types
|
||||
ORDER BY cargo_type_id, wagon_type_id
|
||||
) link
|
||||
WHERE link.cargo_type_id = cg.id;
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.container_type_wagon_types;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargo_type_wagon_types;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer request queue. A requester records a count-only
|
||||
* request (N wagons of a type, from yard → to yard); OCC staff later pick the
|
||||
* physical wagons and execute the move. Replaces the single-step instant
|
||||
* bulk-transfer as the customer-facing yard-to-yard relocation path.
|
||||
*/
|
||||
export class CreateWagonTransferRequests2170000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateWagonTransferRequests2170000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_transfer_requests (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
from_yard_id uuid NOT NULL,
|
||||
to_yard_id uuid NOT NULL,
|
||||
wagon_type_id uuid NOT NULL,
|
||||
quantity integer NOT NULL,
|
||||
status varchar(20) NOT NULL DEFAULT 'PENDING',
|
||||
requested_by_user_id uuid NULL,
|
||||
fulfilled_by_user_id uuid NULL,
|
||||
fulfilled_at timestamptz NULL,
|
||||
note text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL,
|
||||
CONSTRAINT pk_wagon_transfer_requests PRIMARY KEY (id),
|
||||
CONSTRAINT fk_wtr_from_yard FOREIGN KEY (from_yard_id) REFERENCES freight.yards (id),
|
||||
CONSTRAINT fk_wtr_to_yard FOREIGN KEY (to_yard_id) REFERENCES freight.yards (id),
|
||||
CONSTRAINT fk_wtr_wagon_type FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types (id),
|
||||
CONSTRAINT chk_wtr_quantity CHECK (quantity > 0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wtr_status_from_yard
|
||||
ON freight.wagon_transfer_requests (status, from_yard_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_wtr_status_from_yard`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.wagon_transfer_requests`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Consist adjustments from a schedule: staff can trim free wagons off a built
|
||||
* train when their tare pushes gross weight over the locomotives' pull limit
|
||||
* (incl. overage tolerance), or couple extra yard wagons on while weight and
|
||||
* length headroom remain. Each add/remove is logged here so the schedule keeps
|
||||
* an auditable history; the built train itself is updated in place.
|
||||
*
|
||||
* Plain columns (no FKs) so the history survives wagon/train deletion.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface {
|
||||
name = 'ScheduleWagonAdjustmentLogs2170000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
train_schedule_id uuid NOT NULL,
|
||||
train_id uuid NOT NULL,
|
||||
action varchar(10) NOT NULL,
|
||||
wagon_id uuid NOT NULL,
|
||||
wagon_number varchar(50) NOT NULL,
|
||||
adjusted_by_user_id uuid,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id"
|
||||
ON freight.schedule_wagon_adjustment_logs (train_schedule_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_swal_train_id"
|
||||
ON freight.schedule_wagon_adjustment_logs (train_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Link each physical wagon move back to the transfer request that drove it, so
|
||||
* the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103".
|
||||
* Nullable — legacy moves and non-request manual corrections carry no request.
|
||||
* Also indexes `moved_by_user_id` for the per-user history queries.
|
||||
*/
|
||||
export class LinkWagonMovementToTransferRequest2180000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'LinkWagonMovementToTransferRequest2180000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagon_movements
|
||||
ADD CONSTRAINT fk_wm_transfer_request
|
||||
FOREIGN KEY (transfer_request_id)
|
||||
REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_transfer_request
|
||||
ON freight.wagon_movements (transfer_request_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wm_moved_by
|
||||
ON freight.wagon_movements (moved_by_user_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP CONSTRAINT IF EXISTS fk_wm_transfer_request
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_movements
|
||||
DROP COLUMN IF EXISTS transfer_request_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1255,10 +1255,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
destinationYard: true,
|
||||
// units carry the real per-container numbers entered at booking time —
|
||||
// the wagon plan shows those instead of generated placeholders.
|
||||
// containerType.wagonType + cargoType.wagonType drive wagon-type
|
||||
// resolution during scheduling (FK, not the old load-type string map).
|
||||
bookingContainers: { containerType: { wagonType: true }, units: true },
|
||||
cargoType: { wagonType: true },
|
||||
// containerType.wagonTypes + cargoType.wagonTypes drive wagon-type
|
||||
// resolution during scheduling (many-to-many lists — the plan mixes
|
||||
// wagon types within one consist).
|
||||
bookingContainers: { containerType: { wagonTypes: true }, units: true },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
@@ -328,18 +328,21 @@ export class CustomerTruckService {
|
||||
if (assignment.departedAt) {
|
||||
throw new ConflictException('This truck has already left — its load is locked');
|
||||
}
|
||||
// Containers can only be loaded after the truck has physically arrived at the
|
||||
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||||
if (!assignment.arrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||||
);
|
||||
}
|
||||
// Loading a truck at the warehouse implies it is physically present, so a
|
||||
// truck that is still only assigned (not yet marked arrived) is auto-arrived
|
||||
// here rather than blocking the operator — the real gross is weighed on
|
||||
// departure anyway.
|
||||
const needsArrival = !assignment.arrivedAt;
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (!requested.length) {
|
||||
throw new BadRequestException('Select at least one container to load onto the truck');
|
||||
}
|
||||
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
|
||||
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
@@ -352,6 +355,12 @@ export class CustomerTruckService {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — load only 1 container onto this truck',
|
||||
);
|
||||
}
|
||||
|
||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -371,9 +380,20 @@ export class CustomerTruckService {
|
||||
);
|
||||
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
|
||||
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
|
||||
// Auto-stamp arrival if the truck was still only assigned.
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
grossWeightKg: grossTons,
|
||||
...(needsArrival ? { arrivedAt: new Date() } : {}),
|
||||
});
|
||||
if (needsArrival) {
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings
|
||||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[bookingId],
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
|
||||
|
||||
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
|
||||
export class LoadCustomerTruckDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
// A truck carries at most 2 containers (two 20ft, or one 40ft).
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
|
||||
@@ -67,6 +67,12 @@ export class LastMileController {
|
||||
return this.lastMileService.findById(id);
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/arrival-trucks')
|
||||
@ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" })
|
||||
arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.lastMileService.arrivalTrucksForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Post('accept/:reference')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||
|
||||
@@ -256,7 +256,95 @@ export class LastMileService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The EDR last-mile trucks assigned to a booking, joined with driver details,
|
||||
* shaped for the arrival/exit weighing prefill (plate, driver, type, container).
|
||||
* Returns [] when the booking has no last-mile truck assigned. Lets the
|
||||
* warehouse arrival/load modals surface an assigned EDR truck the same way the
|
||||
* self-haul customer trucks are surfaced.
|
||||
*/
|
||||
async arrivalTrucksForBooking(bookingId: string): Promise<
|
||||
Array<{
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
driverName: string | null;
|
||||
driverLicense: string | null;
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
}>
|
||||
> {
|
||||
const [lm] = await this.lastMileRepository.findAll({
|
||||
where: { bookingId },
|
||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
|
||||
take: 1,
|
||||
});
|
||||
if (!lm) return [];
|
||||
|
||||
// Prefer the multi-truck junction; fall back to the legacy single vehicle.
|
||||
const sources = lm.vehicleAssignments?.length
|
||||
? lm.vehicleAssignments.map((va) => ({
|
||||
vehicle: va.vehicle,
|
||||
containerNumber: va.containerNumber ?? null,
|
||||
}))
|
||||
: lm.vehicle
|
||||
? [{ vehicle: lm.vehicle, containerNumber: null }]
|
||||
: [];
|
||||
|
||||
const out: Array<{
|
||||
vehicleId: string;
|
||||
truckPlateNumber: string | null;
|
||||
trailerPlateNumber: string | null;
|
||||
driverName: string | null;
|
||||
driverLicense: string | null;
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
}> = [];
|
||||
for (const { vehicle, containerNumber } of sources) {
|
||||
if (!vehicle) continue;
|
||||
let driverName = vehicle.assignedDriverName ?? null;
|
||||
let driverLicense: string | null = null;
|
||||
let driverPhone: string | null = null;
|
||||
if (vehicle.assignedDriverId) {
|
||||
try {
|
||||
const d = await this.driversService.findById(vehicle.assignedDriverId);
|
||||
driverName = driverName || `${d.firstName ?? ''} ${d.lastName ?? ''}`.trim() || null;
|
||||
driverLicense = d.licenseNumber ?? null;
|
||||
driverPhone = d.phoneNumber ?? null;
|
||||
} catch {
|
||||
/* driver lookup is best-effort — plate still prefills */
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
vehicleId: vehicle.id,
|
||||
truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null,
|
||||
trailerPlateNumber: vehicle.trailerPlateNo || null,
|
||||
driverName,
|
||||
driverLicense,
|
||||
driverPhone,
|
||||
truckType: vehicle.vehicleType || null,
|
||||
containerNumber,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||
// Idempotent: a booking gets exactly one last-mile record. Extra trucks live
|
||||
// inside that record (vehicleAssignments), never as additional rows — so if a
|
||||
// last-mile already exists for this booking, return it instead of inserting a
|
||||
// duplicate delivery row (which is what made the same booking appear twice in
|
||||
// the Assign-Mile list).
|
||||
const [existing] = await this.lastMileRepository.findAll({
|
||||
where: { bookingId: dto.bookingId },
|
||||
take: 1,
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const record = await this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
@@ -318,6 +406,17 @@ export class LastMileService {
|
||||
}
|
||||
}
|
||||
|
||||
// A last-mile truck must have a driver before it can be assigned (same rule
|
||||
// as setVehicles) — block driverless single-vehicle (re)assignment too.
|
||||
if (dto.vehicleId && dto.vehicleId !== existing.vehicleId) {
|
||||
const vehicle = await this.vehiclesService.findById(dto.vehicleId);
|
||||
if (!vehicle?.assignedDriverId) {
|
||||
throw new BadRequestException(
|
||||
`Truck ${vehicle?.plateNumber ?? dto.vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dtoAny = dto as any;
|
||||
const updated = await this.lastMileRepository.update(id, {
|
||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||
@@ -478,6 +577,17 @@ export class LastMileService {
|
||||
)];
|
||||
const added = desired.filter((v) => !junctionSet.has(v));
|
||||
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||
|
||||
// A last-mile truck must have a driver before it can be assigned — a delivery
|
||||
// can't run driverless, and the arrival/exit weighing needs the driver.
|
||||
for (const vehicleId of added) {
|
||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||
if (!vehicle?.assignedDriverId) {
|
||||
throw new BadRequestException(
|
||||
`Truck ${vehicle?.plateNumber ?? vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Vehicles that stay but whose container number changed.
|
||||
const changed = current.filter(
|
||||
(a) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateCargoTypeDto {
|
||||
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
||||
@@ -22,12 +22,15 @@ export class CreateCargoTypeDto {
|
||||
parentGroupId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
|
||||
'Wagon types that can carry this (bulk) cargo. Drives train scheduling wagon-type resolution; at least one is required for bulk commodities that are scheduled.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
wagonTypeId?: string | null;
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonTypeIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateContainerTypeDto {
|
||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||
@@ -31,12 +31,15 @@ export class CreateContainerTypeDto {
|
||||
isOpenTop?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
|
||||
'Wagon types that can carry this container. Drives train scheduling wagon-type resolution; at least one is required when this container type is scheduled.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
wagonTypeId?: string | null;
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonTypeIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'cargo_types' })
|
||||
@Index(['isActive'])
|
||||
@Index(['displayOrder'])
|
||||
@Index(['parentGroupId'])
|
||||
@Index(['wagonTypeId'])
|
||||
@Index(['code'])
|
||||
export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
|
||||
@@ -28,17 +36,19 @@ export class CargoType extends BaseEntity {
|
||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||
|
||||
/**
|
||||
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
|
||||
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
|
||||
* through this FK. Nullable — grouping rows and container/legacy cargo never
|
||||
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
|
||||
* Wagon types that can carry this (bulk) cargo. Train scheduling resolves the
|
||||
* bulk wagon type through this list, picking whichever type the schedule's
|
||||
* train (or yard) actually has. Grouping rows and container/legacy cargo
|
||||
* leave it empty; scheduling throws if a scheduled bulk cargo type has none.
|
||||
*/
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||
wagonTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType | null;
|
||||
@ManyToMany(() => WagonType)
|
||||
@JoinTable({
|
||||
name: 'cargo_type_wagon_types',
|
||||
schema: 'freight',
|
||||
joinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' },
|
||||
})
|
||||
wagonTypes?: WagonType[];
|
||||
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinTable, ManyToMany, OneToMany } from 'typeorm';
|
||||
import { WeightLimitRule } from './weight-limit-rule.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'container_types' })
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
@Index(['wagonTypeId'])
|
||||
export class ContainerType extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||
code!: string;
|
||||
@@ -27,17 +26,19 @@ export class ContainerType extends BaseEntity {
|
||||
isOpenTop!: boolean;
|
||||
|
||||
/**
|
||||
* Wagon type that carries this container. Replaces the former hardcoded
|
||||
* container wagon-code default (NW5): train scheduling resolves the container
|
||||
* wagon type through this FK. Nullable; scheduling throws if a scheduled
|
||||
* container type leaves it unset.
|
||||
* Wagon types that can carry this container (e.g. a 20ft rides NX70 or NW5).
|
||||
* Train scheduling resolves the container wagon type through this list,
|
||||
* picking whichever type the schedule's train (or yard) actually has.
|
||||
* Scheduling throws if a scheduled container type has none configured.
|
||||
*/
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||
wagonTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType | null;
|
||||
@ManyToMany(() => WagonType)
|
||||
@JoinTable({
|
||||
name: 'container_type_wagon_types',
|
||||
schema: 'freight',
|
||||
joinColumn: { name: 'container_type_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' },
|
||||
})
|
||||
wagonTypes?: WagonType[];
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
}
|
||||
|
||||
findById(id: string): Promise<CargoType | null> {
|
||||
return this.repo.findOne({ where: { id }, relations: { parent: true } });
|
||||
return this.repo.findOne({ where: { id }, relations: { parent: true, wagonTypes: true } });
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<CargoType | null> {
|
||||
@@ -35,6 +35,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('cargoType')
|
||||
.leftJoinAndSelect('cargoType.parent', 'parent')
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'wagonType')
|
||||
.orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
@@ -63,7 +64,18 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
|
||||
await this.repo.update(id, data as never);
|
||||
// Relation lists can't ride a column UPDATE — sync them via entity save.
|
||||
const { wagonTypes, ...columns } = data;
|
||||
if (Object.keys(columns).length) {
|
||||
await this.repo.update(id, columns as never);
|
||||
}
|
||||
if (wagonTypes) {
|
||||
const entity = await this.repo.findOne({ where: { id } });
|
||||
if (entity) {
|
||||
entity.wagonTypes = wagonTypes;
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
}
|
||||
|
||||
findById(id: string): Promise<ContainerType | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
return this.repo.findOne({ where: { id }, relations: { wagonTypes: true } });
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<ContainerType | null> {
|
||||
@@ -34,6 +34,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('containerType')
|
||||
.leftJoinAndSelect('containerType.wagonTypes', 'wagonType')
|
||||
.orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
@@ -54,7 +55,18 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
|
||||
await this.repo.update(id, data as never);
|
||||
// Relation lists can't ride a column UPDATE — sync them via entity save.
|
||||
const { wagonTypes, ...columns } = data;
|
||||
if (Object.keys(columns).length) {
|
||||
await this.repo.update(id, columns as never);
|
||||
}
|
||||
if (wagonTypes) {
|
||||
const entity = await this.repo.findOne({ where: { id } });
|
||||
if (entity) {
|
||||
entity.wagonTypes = wagonTypes;
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
}
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||
import { CargoType } from '../entities/cargo-type.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
@@ -59,7 +60,8 @@ export class CargoTypesService {
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
wagonTypeId: dto.wagonTypeId ?? null,
|
||||
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
|
||||
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
@@ -72,7 +74,13 @@ export class CargoTypesService {
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
const updated = await this.repository.update(id, dto);
|
||||
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
|
||||
const updated = await this.repository.update(id, {
|
||||
...columns,
|
||||
...(wagonTypeIds
|
||||
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
|
||||
: {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||
import { ContainerType } from '../entities/container-type.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
CONTAINER_TYPES_REPOSITORY,
|
||||
IContainerTypesRepository,
|
||||
@@ -51,7 +52,8 @@ export class ContainerTypesService {
|
||||
isReefer: dto.isReefer ?? false,
|
||||
isOpenTop: dto.isOpenTop ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
wagonTypeId: dto.wagonTypeId ?? null,
|
||||
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
|
||||
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
@@ -59,7 +61,13 @@ export class ContainerTypesService {
|
||||
/** Update an existing container type. */
|
||||
async update(id: string, dto: UpdateContainerTypeDto): Promise<ContainerType> {
|
||||
await this.findById(id);
|
||||
const updated = await this.repository.update(id, dto);
|
||||
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
|
||||
const updated = await this.repository.update(id, {
|
||||
...columns,
|
||||
...(wagonTypeIds
|
||||
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
|
||||
: {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Container type ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
|
||||
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* History row for a consist adjustment made from a schedule: staff coupled a
|
||||
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
|
||||
* e.g. trimming free wagons whose tare pushed gross weight over the
|
||||
* locomotives' pull limit. Plain columns (no FK relations) so the history
|
||||
* survives the wagon or train being deleted later.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
|
||||
@Index(['trainScheduleId'])
|
||||
@Index(['trainId'])
|
||||
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@Column({ name: 'action', type: 'varchar', length: 10 })
|
||||
action!: WagonAdjustmentAction;
|
||||
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
@Column({ name: 'wagon_number', type: 'varchar', length: 50 })
|
||||
wagonNumber!: string;
|
||||
|
||||
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
|
||||
adjustedByUserId!: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||
occurredAt!: Date;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
train: true,
|
||||
wagons: {
|
||||
wagonType: true,
|
||||
physicalWagon: true,
|
||||
|
||||
@@ -886,7 +886,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
};
|
||||
|
||||
it('charges a bulk booking the tare of ITS wagon type, not the representative', () => {
|
||||
const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } });
|
||||
const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } });
|
||||
const need = service.needFor(booking, dimsWithTypes);
|
||||
expect(need.wagons).toBe(30);
|
||||
expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation
|
||||
@@ -905,7 +905,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
{
|
||||
quantity: 2,
|
||||
wagonsRequired: 2,
|
||||
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' },
|
||||
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: BatchBoardSchedule["train"];
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -235,6 +237,12 @@ export interface BatchBoardSchedule {
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||
train: {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
} | null;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
trainSet: { locomotive: true, train: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
// Yards supply the route's display name for `routeName` below;
|
||||
@@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
train: s.trainSet?.train
|
||||
? {
|
||||
id: s.trainSet.train.id,
|
||||
code: s.trainSet.train.code,
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
train: s.trainSet?.train
|
||||
? {
|
||||
id: s.trainSet.train.id,
|
||||
code: s.trainSet.train.code,
|
||||
trainName: s.trainSet.train.trainName ?? null,
|
||||
}
|
||||
: null,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -2913,21 +2935,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Dimensions of the wagon type THIS booking rides: bulk resolves through its
|
||||
* cargo type's wagon_type_id, container through the first container line's
|
||||
* type — the same FK resolution `resolveWagonType` applies when the paid
|
||||
* booking is allocated. Board/fill math measured on a representative wagon
|
||||
* while allocation validated the real one let a selected batch flunk the
|
||||
* post-payment gross-weight check; sharing the resolution closes that gap.
|
||||
* Falls back to the representative dims when the FK or relation is absent.
|
||||
* cargo type's allowed wagon-type list, container through the first container
|
||||
* line's — the same list resolution the scheduling planner applies when the
|
||||
* paid booking is allocated. Board/fill math measured on a representative
|
||||
* wagon while allocation validated the real one let a selected batch flunk
|
||||
* the post-payment gross-weight check; sharing the resolution closes that
|
||||
* gap. Uses the first configured type (the fill engine has no train context);
|
||||
* falls back to the representative dims when the list or relation is absent.
|
||||
*/
|
||||
private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims {
|
||||
const fallback =
|
||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||
const wagonTypeId =
|
||||
booking.freightType === "BULK"
|
||||
? booking.cargoType?.wagonTypeId
|
||||
? booking.cargoType?.wagonTypes?.[0]?.id
|
||||
: (booking.bookingContainers ?? [])
|
||||
.map((line) => line.containerType?.wagonTypeId)
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wagonType) => wagonType.id)
|
||||
.find((id): id is string => Boolean(id));
|
||||
const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined;
|
||||
if (!dims) return fallback;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AdjustScheduleConsistDto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
"AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
addWagonIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
removeWagonIds?: string[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableTrainsQueryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
@@ -20,15 +20,26 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Built train (Train Builder) to run this departure — its locomotive set is used. Provide either trainId or locomotiveIds.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Locomotives pulling the train (minimum 2 — front and back)',
|
||||
description:
|
||||
'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
locomotiveIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from "./dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
|
||||
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
|
||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
|
||||
@@ -153,6 +155,46 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("available-trains")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List built trains (Train Builder) schedulable on a route, annotated with yard position and future runs",
|
||||
})
|
||||
getAvailableTrains(@Query() query: AvailableTrainsQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableTrainsForRoute(
|
||||
query.routeId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/consist")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history",
|
||||
})
|
||||
getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleConsist(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/adjust-consist")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
|
||||
})
|
||||
adjustScheduleConsist(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AdjustScheduleConsistDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.adjustScheduleConsist(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||
|
||||
@@ -72,7 +72,7 @@ const makeBooking = (
|
||||
wagonsRequired,
|
||||
vgmPerUnitTons: weight / quantity,
|
||||
isOverweight: false,
|
||||
containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id },
|
||||
containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] },
|
||||
},
|
||||
],
|
||||
...extra,
|
||||
@@ -80,7 +80,7 @@ const makeBooking = (
|
||||
|
||||
describe('TrainSchedulingService', () => {
|
||||
let service: TrainSchedulingService;
|
||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
|
||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
|
||||
let bookingsRepository: Record<string, jest.Mock>;
|
||||
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let wagonTypesRepository: { findAll: jest.Mock };
|
||||
@@ -91,7 +91,12 @@ describe('TrainSchedulingService', () => {
|
||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
|
||||
dataSource = {
|
||||
getRepository: jest.fn(),
|
||||
transaction: jest.fn(),
|
||||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
findByIdsForScheduling: jest.fn(),
|
||||
@@ -259,8 +264,10 @@ describe('TrainSchedulingService', () => {
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(result.summary.wagonsNeeded).toBe(45);
|
||||
expect(result.wagonPlan).toHaveLength(45);
|
||||
// TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) —
|
||||
// the planner packs by container size, not the stored per-line fallback.
|
||||
expect(result.summary.wagonsNeeded).toBe(40);
|
||||
expect(result.wagonPlan).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('returns soft hold warnings without forceAssign', async () => {
|
||||
@@ -293,7 +300,7 @@ describe('TrainSchedulingService', () => {
|
||||
wagonsRequired: 80,
|
||||
vgmPerUnitTons: 45,
|
||||
isOverweight: true,
|
||||
containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id },
|
||||
containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -655,10 +662,12 @@ describe('TrainSchedulingService', () => {
|
||||
destinationStationId: 'yard-djibouti',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
|
||||
).toBe(true);
|
||||
// List-based planner: a booking with no plannable wagon at the yard is
|
||||
// DEFERRED with the wagon-type reason (assign still hard-fails when no
|
||||
// booking fits), instead of surfacing a phantom-slot violation.
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.wagonPlan).toHaveLength(0);
|
||||
expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true);
|
||||
});
|
||||
|
||||
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
expandBookingContainerUnits,
|
||||
roundTons,
|
||||
tareTonsOf,
|
||||
teuSlotsForSizeFt,
|
||||
type SlotLoadType,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
|
||||
/**
|
||||
* Wagon types allowed to carry each container type / bulk cargo type — the
|
||||
* many-to-many configuration lists, resolved once per validation run.
|
||||
*/
|
||||
export type AllowedWagonTypeMap = {
|
||||
byContainerTypeId: Map<string, WagonType[]>;
|
||||
byCargoTypeId: Map<string, WagonType[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plannable wagon inventory. TRAIN mode is the built train's own consist —
|
||||
* a hard cap, the plan never reaches for loose yard wagons. YARD mode is the
|
||||
* AVAILABLE pool at the boarding yards (legacy schedules).
|
||||
*/
|
||||
export type WagonStock = {
|
||||
mode: 'TRAIN' | 'YARD';
|
||||
/** Remaining plannable wagons per wagon type id. Missing type = 0. */
|
||||
remainingByTypeId: Map<string, number>;
|
||||
/** Wagon-type code per id, for human-readable shortfall messages. */
|
||||
codesByTypeId: Map<string, string>;
|
||||
};
|
||||
|
||||
export type FlexPlanResult = {
|
||||
plan: WagonPlanSlot[];
|
||||
fitting: Booking[];
|
||||
deferred: DeferredBookingRow[];
|
||||
/**
|
||||
* Misconfiguration (a scheduled type with no wagon types configured) —
|
||||
* a hard violation, unlike stock shortfalls which merely defer bookings.
|
||||
*/
|
||||
configIssues: string[];
|
||||
};
|
||||
|
||||
type OpenSlot = {
|
||||
slot: WagonPlanSlot;
|
||||
teuUsed: number;
|
||||
kind: SlotLoadType;
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
};
|
||||
|
||||
type PlacementProblem = { kind: 'config' | 'stock'; message: string };
|
||||
|
||||
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
|
||||
sequenceNo: 0, // stamped at the end
|
||||
wagonTypeId: wagonType.id,
|
||||
wagonTypeCode: wagonType.code,
|
||||
capacityTons: Number(wagonType.capacityTons),
|
||||
lengthMeters: Number(wagonType.lengthMeters),
|
||||
tareWeightTons: tareTonsOf(wagonType),
|
||||
assignedWeightTons: 0,
|
||||
allocations: [],
|
||||
slotLoadType: kind,
|
||||
});
|
||||
|
||||
const addAllocation = (
|
||||
slot: WagonPlanSlot,
|
||||
bookingId: string,
|
||||
bookingReference: string,
|
||||
weightTons: number,
|
||||
loadType: AllocationLoadType,
|
||||
) => {
|
||||
let allocation = slot.allocations.find((a) => a.bookingId === bookingId);
|
||||
if (!allocation) {
|
||||
allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType };
|
||||
slot.allocations.push(allocation);
|
||||
}
|
||||
allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons);
|
||||
slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the wagon plan against a wagon-type inventory, mixing wagon types
|
||||
* within one consist. Each booking is atomic: it either fits entirely (its
|
||||
* containers/tonnage placed on wagons whose type is allowed for its container
|
||||
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
|
||||
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
|
||||
* two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon
|
||||
* with a different cargo type.
|
||||
*/
|
||||
export function planWagonsWithStock(params: {
|
||||
bookings: Booking[];
|
||||
allowed: AllowedWagonTypeMap;
|
||||
stock: WagonStock;
|
||||
}): FlexPlanResult {
|
||||
const { bookings, allowed, stock } = params;
|
||||
const remaining = new Map(stock.remainingByTypeId);
|
||||
const openSlots: OpenSlot[] = [];
|
||||
const fitting: Booking[] = [];
|
||||
const deferred: DeferredBookingRow[] = [];
|
||||
const configIssues = new Set<string>();
|
||||
|
||||
const noStockMessage = (candidates: WagonType[]): string => {
|
||||
const codes = candidates.map((wt) => wt.code).join('/');
|
||||
return stock.mode === 'TRAIN'
|
||||
? `Train has no free ${codes} wagon left`
|
||||
: `No available ${codes} wagon at the yard`;
|
||||
};
|
||||
|
||||
/** Open a new wagon of one of the candidate types, consuming stock. */
|
||||
const openSlot = (
|
||||
candidates: WagonType[],
|
||||
kind: SlotLoadType,
|
||||
cargoTypeId: string | null,
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
||||
if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
|
||||
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
|
||||
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
kind === 'BULK'
|
||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
|
||||
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
|
||||
)[0];
|
||||
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
|
||||
const open: OpenSlot = {
|
||||
slot: slotFromWagonType(chosen, kind),
|
||||
teuUsed: 0,
|
||||
kind,
|
||||
cargoTypeId,
|
||||
freeCapacityTons: Number(chosen.capacityTons),
|
||||
};
|
||||
openSlots.push(open);
|
||||
return open;
|
||||
};
|
||||
|
||||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
if (!units.length) {
|
||||
// Degenerate container booking with no lines still reserves one wagon
|
||||
// (legacy behavior) — but there is no container type to resolve against.
|
||||
return {
|
||||
kind: 'config',
|
||||
message: `Booking ${booking.reference} has no container lines to plan`,
|
||||
};
|
||||
}
|
||||
for (const unit of units) {
|
||||
const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? [];
|
||||
if (!candidates.length) {
|
||||
return {
|
||||
kind: 'config',
|
||||
message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`,
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
|
||||
let target = openSlots.find(
|
||||
(open) =>
|
||||
open.kind === 'CONTAINER' &&
|
||||
allowedIds.has(open.slot.wagonTypeId) &&
|
||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
||||
);
|
||||
if (!target) {
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
target = openedSlot;
|
||||
}
|
||||
addAllocation(
|
||||
target.slot,
|
||||
unit.bookingId,
|
||||
unit.bookingReference,
|
||||
unit.grossWeightTons,
|
||||
AllocationLoadType.Container,
|
||||
);
|
||||
target.teuUsed += teu;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// BULK — weight-based, one cargo type per wagon.
|
||||
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null;
|
||||
const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
|
||||
if (!candidates.length) {
|
||||
return {
|
||||
kind: 'config',
|
||||
message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`,
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
let placedAnywhere = false;
|
||||
|
||||
// Top off wagons already carrying THIS cargo type before opening new ones.
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
open.slot,
|
||||
booking.id,
|
||||
booking.reference,
|
||||
take,
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
openedSlot.slot,
|
||||
booking.id,
|
||||
booking.reference,
|
||||
take,
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take);
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||||
const remainingSnapshot = new Map(remaining);
|
||||
const slotCountSnapshot = openSlots.length;
|
||||
const slotStateSnapshot = openSlots.map((open) => ({
|
||||
teuUsed: open.teuUsed,
|
||||
freeCapacityTons: open.freeCapacityTons,
|
||||
assignedWeightTons: open.slot.assignedWeightTons,
|
||||
allocationCount: open.slot.allocations.length,
|
||||
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
||||
}));
|
||||
|
||||
const problem = tryPlaceBooking(booking);
|
||||
if (!problem) {
|
||||
fitting.push(booking);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Roll back this booking's partial placements.
|
||||
remaining.clear();
|
||||
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
|
||||
openSlots.length = slotCountSnapshot;
|
||||
openSlots.forEach((open, index) => {
|
||||
const snap = slotStateSnapshot[index];
|
||||
if (!snap) return;
|
||||
open.teuUsed = snap.teuUsed;
|
||||
open.freeCapacityTons = snap.freeCapacityTons;
|
||||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||||
open.slot.allocations.length = snap.allocationCount;
|
||||
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
||||
open.slot.allocations[allocationIndex].allocatedWeightTons = weight;
|
||||
});
|
||||
});
|
||||
|
||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||
deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
|
||||
}
|
||||
|
||||
return {
|
||||
plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })),
|
||||
fitting,
|
||||
deferred,
|
||||
configIssues: [...configIssues],
|
||||
};
|
||||
}
|
||||
|
||||
/** Unbounded stock — used to compute pure demand for availability reporting. */
|
||||
export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock {
|
||||
const remainingByTypeId = new Map<string, number>();
|
||||
const codesByTypeId = new Map<string, string>();
|
||||
for (const list of [
|
||||
...allowed.byContainerTypeId.values(),
|
||||
...allowed.byCargoTypeId.values(),
|
||||
]) {
|
||||
for (const wagonType of list) {
|
||||
remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER);
|
||||
codesByTypeId.set(wagonType.id, wagonType.code);
|
||||
}
|
||||
}
|
||||
return { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||
}
|
||||
@@ -514,7 +514,7 @@ export function validateTrainLimits(
|
||||
*/
|
||||
export function validateMixedTrainLimits(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: WagonType[],
|
||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetLocomotive } from './train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from './train-set-wagon.entity';
|
||||
@@ -32,6 +33,14 @@ export class TrainSet extends BaseEntity {
|
||||
@OneToMany(() => TrainSetLocomotive, (link) => link.trainSet)
|
||||
locomotives?: TrainSetLocomotive[];
|
||||
|
||||
/** Built fleet train this set was formed from (Train Builder), when scheduled by train. */
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId!: string | null;
|
||||
|
||||
@ManyToOne(() => Train, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train?: Train | null;
|
||||
|
||||
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
totalWeightTons!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class AssignTrainWagonsDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Wagons to append to the consist, in order. Each must be AVAILABLE in the train\'s yard.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BuildTrainDto {
|
||||
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
|
||||
@IsUUID()
|
||||
currentYardId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Wagons to attach at build time, in consist order (must sit in the same yard)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 100 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
trainName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Freight } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export class ListBuiltTrainsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: Freight.TrainStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(Freight.TrainStatus)
|
||||
status?: Freight.TrainStatus;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Only trains sitting in this yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['code', 'trainName', 'status', 'createdAt'] })
|
||||
@IsOptional()
|
||||
@IsIn(['code', 'trainName', 'status', 'createdAt'])
|
||||
sortBy?: 'code' | 'trainName' | 'status' | 'createdAt';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReorderTrainWagonsDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Every wagon of the train, in the new consist order',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateTrainLocomotivesDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Full replacement locomotive set (minimum 2), in consist order',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
|
||||
@IsUUID('all', { each: true })
|
||||
locomotiveIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateTrainYardDto {
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.',
|
||||
})
|
||||
@IsUUID()
|
||||
currentYardId!: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { Train } from './train.entity';
|
||||
|
||||
/**
|
||||
* Link row joining a built train to one of its locomotives. A train must be
|
||||
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
|
||||
* in the consist — 0 is the lead locomotive.
|
||||
*
|
||||
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built
|
||||
* in the Train Builder rather than the per-departure operational train set.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'train_locomotives' })
|
||||
@Index(['trainId', 'locomotiveId'], { unique: true })
|
||||
export class TrainLocomotive extends BaseEntity {
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@ManyToOne(() => Train, (train) => train.locomotives, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train?: Train;
|
||||
|
||||
@Column({ name: 'locomotive_id', type: 'uuid' })
|
||||
locomotiveId!: string;
|
||||
|
||||
@ManyToOne(() => Locomotive)
|
||||
@JoinColumn({ name: 'locomotive_id' })
|
||||
locomotive?: Locomotive;
|
||||
|
||||
@Column({ name: 'sequence_no', type: 'int', default: 0 })
|
||||
sequenceNo!: number;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Column, Entity, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { TrainLocomotive } from './train-locomotive.entity';
|
||||
|
||||
/**
|
||||
* Fleet master data — named wagon consist in inventory (POST /trains).
|
||||
* Operational departures use train_schedules + locomotives; scheduling never creates trains rows.
|
||||
* Fleet master data — a train built in the Train Builder: a coded consist
|
||||
* (e.g. 81001) of 2+ locomotives and ordered wagons, assembled in one yard.
|
||||
* Operational departures reference it through `train_sets.train_id`; the
|
||||
* schedule's own composition still lives on the train set.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'trains' })
|
||||
export class Train extends BaseEntity {
|
||||
@@ -56,7 +60,19 @@ export class Train extends BaseEntity {
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string;
|
||||
|
||||
/** Yard where the train currently sits (set at build, moved on schedule arrival). */
|
||||
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
|
||||
currentYardId!: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
// --- relationships ---
|
||||
@OneToMany(() => Wagon, (wagon) => wagon.train)
|
||||
wagons!: Wagon[]; // fixed typo: was 'wagens'
|
||||
@OneToMany(() => Wagon, (wagon) => wagon.train)
|
||||
wagons!: Wagon[];
|
||||
|
||||
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
|
||||
@OneToMany(() => TrainLocomotive, (link) => link.train)
|
||||
locomotives?: TrainLocomotive[];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
|
||||
@ApiTags('train-builder')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-builder')
|
||||
@FleetView()
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
|
||||
build(@Body() dto: BuildTrainDto) {
|
||||
return this.trainBuilderService.buildTrain(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Paginated built trains with composition summary' })
|
||||
list(@Query() query: ListBuiltTrainsQueryDto) {
|
||||
return this.trainBuilderService.listBuilt(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
|
||||
composition(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.getComposition(id);
|
||||
}
|
||||
|
||||
@Put(':id/locomotives')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
|
||||
setLocomotives(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTrainLocomotivesDto,
|
||||
) {
|
||||
return this.trainBuilderService.setLocomotives(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/yard')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
|
||||
})
|
||||
setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) {
|
||||
return this.trainBuilderService.setYard(id, dto.currentYardId);
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
|
||||
return this.trainBuilderService.assignWagons(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
) {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
|
||||
return this.trainBuilderService.reorderWagons(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
|
||||
disband(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.disband(id);
|
||||
}
|
||||
}
|
||||
619
apps/edr-freight-api/src/modules/trains/train-builder.service.ts
Normal file
619
apps/edr-freight-api/src/modules/trains/train-builder.service.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
|
||||
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
|
||||
* ordered wagons, all in one yard) that scheduling can later reference as a
|
||||
* unit instead of hand-picking locomotives per departure.
|
||||
*
|
||||
* Resource rules:
|
||||
* - Locomotive double-use is prevented through the `train_locomotives` link
|
||||
* table (a locomotive rides at most one built train); its `status` column
|
||||
* keeps its operational meaning (ASSIGNED = out on a dispatched train).
|
||||
* - Wagons attached to a train are flipped to ASSIGNED (same semantic the
|
||||
* legacy assign-train flow uses), so no other train or schedule grabs them.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TrainBuilderService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async buildTrain(dto: BuildTrainDto) {
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||
}
|
||||
|
||||
const trainId = await this.dataSource.transaction(async (manager) => {
|
||||
const code = dto.code.trim();
|
||||
const existing = await manager.getRepository(Train).findOne({ where: { code } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Train code ${code} is already in use`);
|
||||
}
|
||||
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
|
||||
|
||||
const locomotives = await this.validateAndLockLocomotives(
|
||||
manager,
|
||||
locomotiveIds,
|
||||
yard,
|
||||
null,
|
||||
);
|
||||
|
||||
// Effective haul capacity is capped by the weakest locomotive in the set.
|
||||
const limits = minLocomotiveLimits(locomotives);
|
||||
const train = await manager.getRepository(Train).save(
|
||||
manager.getRepository(Train).create({
|
||||
code,
|
||||
currentYardId: yard.id,
|
||||
capacityTons: round(limits?.maxPullWeightTons ?? 0),
|
||||
status: Freight.TrainStatus.Available,
|
||||
trainName: dto.trainName?.trim() || undefined,
|
||||
notes: dto.notes?.trim() || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
||||
|
||||
if (dto.wagonIds?.length) {
|
||||
await this.attachWagons(manager, train, dto.wagonIds, 0);
|
||||
}
|
||||
return train.id;
|
||||
});
|
||||
|
||||
return this.getComposition(trainId);
|
||||
}
|
||||
|
||||
/** Paginated builder list with a composition summary per train. */
|
||||
async listBuilt(query: ListBuiltTrainsQueryDto) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
const search = query.search?.trim();
|
||||
const filters = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
||||
};
|
||||
const where = search
|
||||
? [
|
||||
{ ...filters, code: ILike(`%${search}%`) },
|
||||
{ ...filters, trainName: ILike(`%${search}%`) },
|
||||
]
|
||||
: filters;
|
||||
|
||||
const [trains, total] = await this.dataSource.getRepository(Train).findAndCount({
|
||||
where,
|
||||
relations: {
|
||||
currentYard: true,
|
||||
locomotives: { locomotive: true },
|
||||
wagons: { wagonType: true },
|
||||
},
|
||||
order: { [query.sortBy ?? 'createdAt']: query.sortOrder ?? 'DESC' },
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
return {
|
||||
items: trains.map((train) => this.mapSummary(train)),
|
||||
meta: buildPaginationMeta(total, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
|
||||
async getComposition(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
currentYard: true,
|
||||
locomotives: { locomotive: { currentYard: true } },
|
||||
wagons: { wagonType: true, currentYard: true },
|
||||
},
|
||||
order: {
|
||||
locomotives: { sequenceNo: 'ASC' },
|
||||
wagons: { sequenceNumber: 'ASC' },
|
||||
},
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
|
||||
const schedules: { id: string; status: string; reference: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
ORDER BY ts.scheduled_departure_date ASC`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const locomotives = (train.locomotives ?? [])
|
||||
.filter((link) => link.locomotive)
|
||||
.map((link, index) => ({
|
||||
id: link.locomotive!.id,
|
||||
code: link.locomotive!.code,
|
||||
name: link.locomotive!.name ?? null,
|
||||
locomotiveType: link.locomotive!.locomotiveType,
|
||||
status: link.locomotive!.status,
|
||||
sequenceNo: link.sequenceNo,
|
||||
role: index === 0 ? 'LEAD' : 'ASSIST',
|
||||
currentYardId: link.locomotive!.currentYardId ?? null,
|
||||
currentYard: link.locomotive!.currentYard
|
||||
? {
|
||||
id: link.locomotive!.currentYard.id,
|
||||
code: link.locomotive!.currentYard.code,
|
||||
label: link.locomotive!.currentYard.label,
|
||||
}
|
||||
: null,
|
||||
maxPullWeightTons: round(link.locomotive!.maxPullWeightTons),
|
||||
maxTrainLengthMeters: round(link.locomotive!.maxTrainLengthMeters),
|
||||
}));
|
||||
|
||||
const wagons = (train.wagons ?? []).map((wagon) => ({
|
||||
id: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
sequenceNumber: wagon.sequenceNumber,
|
||||
status: wagon.status,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
capacityTons: round(wagon.wagonType.capacityTons),
|
||||
tareWeightTons: round(wagon.wagonType.tareWeightTons),
|
||||
lengthMeters: round(wagon.wagonType.lengthMeters),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
const limits = minLocomotiveLimits(
|
||||
(train.locomotives ?? [])
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||
);
|
||||
const totalTareTons = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ?? 0), 0),
|
||||
);
|
||||
const totalCapacityTons = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.capacityTons ?? 0), 0),
|
||||
);
|
||||
const totalLengthMeters = round(
|
||||
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
|
||||
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
|
||||
|
||||
return {
|
||||
id: train.id,
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
notes: train.notes ?? null,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
||||
: null,
|
||||
locomotives,
|
||||
wagons,
|
||||
totals: {
|
||||
wagonCount: wagons.length,
|
||||
totalTareTons,
|
||||
// Informational only — building never checks against full capacity;
|
||||
// the real gross check (cargo + tare vs haul limit) runs at allocation.
|
||||
totalCapacityTons,
|
||||
totalLengthMeters,
|
||||
maxPullWeightTons,
|
||||
maxTrainLengthMeters,
|
||||
// Cargo the locomotives can still haul once pulling the empty consist.
|
||||
payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)),
|
||||
// Share of the haul limit consumed by the empty wagons alone.
|
||||
tareUtilizationPct: maxPullWeightTons
|
||||
? round((totalTareTons / maxPullWeightTons) * 100)
|
||||
: null,
|
||||
lengthUtilizationPct: maxTrainLengthMeters
|
||||
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
|
||||
: null,
|
||||
},
|
||||
activeSchedules: schedules,
|
||||
// Composition is frozen while the train is out on a dispatched run.
|
||||
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
|
||||
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||
}
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const yard = await manager
|
||||
.getRepository(Yard)
|
||||
.findOne({ where: { id: train.currentYardId ?? '' } });
|
||||
if (!yard) {
|
||||
throw new BadRequestException('Train has no yard; set the yard before changing locomotives');
|
||||
}
|
||||
const locomotives = await this.validateAndLockLocomotives(
|
||||
manager,
|
||||
locomotiveIds,
|
||||
yard,
|
||||
train.id,
|
||||
);
|
||||
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
|
||||
const limits = minLocomotiveLimits(locomotives);
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit: every
|
||||
* coupled locomotive and wagon follows to the new yard (so their current
|
||||
* yards always match the train's), and each wagon gets a movement-ledger row.
|
||||
* Blocked while the train is out on a dispatched run.
|
||||
*/
|
||||
async setYard(id: string, currentYardId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
if (train.currentYardId === currentYardId) return;
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||
|
||||
await manager.getRepository(Train).update(train.id, { currentYardId: yard.id });
|
||||
|
||||
const links = await manager
|
||||
.getRepository(TrainLocomotive)
|
||||
.find({ where: { trainId: train.id } });
|
||||
if (links.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update(
|
||||
{ id: In(links.map((link) => link.locomotiveId)) },
|
||||
{ currentYardId: yard.id },
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } });
|
||||
const now = new Date();
|
||||
for (const wagon of wagons) {
|
||||
if (wagon.currentYardId === yard.id) continue;
|
||||
await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id });
|
||||
// Ledger row keeps the wagon's yard history auditable (mirrors the
|
||||
// manual-relocation path in the wagons service).
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
kind: WagonMovementKind.Manual,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Append AVAILABLE wagons from the train's own yard to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const currentCount = await manager
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId: train.id } });
|
||||
await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (wagon.currentTrainScheduleId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagons = await manager
|
||||
.getRepository(Wagon)
|
||||
.find({ where: { trainId: train.id } });
|
||||
const current = new Set(wagons.map((w) => w.id));
|
||||
const incoming = new Set(dto.wagonIds);
|
||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||
}
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Disband the train: release wagons and locomotives, then delete it. */
|
||||
async disband(id: string): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
const active: { count: string }[] = await manager.query(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
||||
[id],
|
||||
);
|
||||
if (Number(active[0]?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
'Train has active schedules; cancel them before disbanding the train',
|
||||
);
|
||||
}
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ trainId: train.id },
|
||||
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
|
||||
);
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
||||
await manager.getRepository(Train).remove(train);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
private mapSummary(train: Train) {
|
||||
const locomotives = [...(train.locomotives ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco));
|
||||
const wagons = train.wagons ?? [];
|
||||
const totalTareTons = round(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
|
||||
);
|
||||
return {
|
||||
id: train.id,
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
||||
: null,
|
||||
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
|
||||
wagonCount: wagons.length,
|
||||
totalTareTons,
|
||||
totalLengthMeters: round(
|
||||
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
|
||||
),
|
||||
maxPullWeightTons: round(train.capacityTons),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
||||
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
return train;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock and validate the locomotives for a build/replace: each must exist, be
|
||||
* serviceable, sit in the train's yard, and not ride another built train.
|
||||
*/
|
||||
private async validateAndLockLocomotives(
|
||||
manager: EntityManager,
|
||||
locomotiveIds: string[],
|
||||
yard: Yard,
|
||||
ownTrainId: string | null,
|
||||
): Promise<Locomotive[]> {
|
||||
const locomotives: Locomotive[] = [];
|
||||
for (const locomotiveId of locomotiveIds) {
|
||||
const locked = await manager.getRepository(Locomotive).findOne({
|
||||
where: { id: locomotiveId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!locked) throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||
if (locked.status === 'OUT_OF_SERVICE' || locked.status === 'MAINTENANCE') {
|
||||
throw new ConflictException(`Locomotive ${locked.code} is ${locked.status.toLowerCase().replace('_', ' ')}`);
|
||||
}
|
||||
if (locked.currentYardId !== yard.id) {
|
||||
throw new BadRequestException(
|
||||
`Locomotive ${locked.code} is not in yard ${yard.label ?? yard.code}; a train can only be built from locomotives in its own yard`,
|
||||
);
|
||||
}
|
||||
locomotives.push(locked);
|
||||
}
|
||||
|
||||
const taken = await manager.getRepository(TrainLocomotive).find({
|
||||
where: { locomotiveId: In(locomotiveIds) },
|
||||
relations: { train: true },
|
||||
});
|
||||
const conflict = taken.find((link) => link.trainId !== ownTrainId);
|
||||
if (conflict) {
|
||||
const loco = locomotives.find((l) => l.id === conflict.locomotiveId);
|
||||
throw new ConflictException(
|
||||
`Locomotive ${loco?.code ?? conflict.locomotiveId} is already coupled to train ${conflict.train?.code ?? conflict.trainId}`,
|
||||
);
|
||||
}
|
||||
return locomotives;
|
||||
}
|
||||
|
||||
private async replaceLocomotiveLinks(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
locomotiveIds: string[],
|
||||
): Promise<void> {
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId });
|
||||
await manager.getRepository(TrainLocomotive).save(
|
||||
locomotiveIds.map((locomotiveId, index) =>
|
||||
manager.getRepository(TrainLocomotive).create({ trainId, locomotiveId, sequenceNo: index }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async attachWagons(
|
||||
manager: EntityManager,
|
||||
train: Train,
|
||||
wagonIds: string[],
|
||||
startCount: number,
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(wagonIds)];
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
|
||||
// First pass: lock + validate every wagon so the length gate below sees
|
||||
// the full incoming set before any row is written.
|
||||
const toAttach: Wagon[] = [];
|
||||
for (const wagonId of uniqueIds) {
|
||||
const wagon = await wagonRepo.findOne({
|
||||
where: { id: wagonId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
||||
if (wagon.trainId === train.id) continue;
|
||||
if (wagon.trainId) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
|
||||
}
|
||||
if (wagon.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
|
||||
}
|
||||
if (wagon.currentYardId !== train.currentYardId) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||
);
|
||||
}
|
||||
toAttach.push(wagon);
|
||||
}
|
||||
if (!toAttach.length) return;
|
||||
|
||||
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
||||
|
||||
let sequence = startCount;
|
||||
for (const wagon of toAttach) {
|
||||
sequence += 1;
|
||||
await wagonRepo.update(wagon.id, {
|
||||
trainId: train.id,
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The consist (already-attached wagons + the incoming ones) must fit the
|
||||
* train's locomotive length limit — the weakest locomotive of the set caps
|
||||
* the train, mirroring how scheduling derives capacity.
|
||||
*/
|
||||
private async assertConsistLengthWithinLimit(
|
||||
manager: EntityManager,
|
||||
train: Train,
|
||||
incoming: Wagon[],
|
||||
): Promise<void> {
|
||||
const links = await manager.getRepository(TrainLocomotive).find({
|
||||
where: { trainId: train.id },
|
||||
relations: { locomotive: true },
|
||||
});
|
||||
const limits = minLocomotiveLimits(
|
||||
links
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco)),
|
||||
);
|
||||
const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0);
|
||||
if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return;
|
||||
|
||||
const existing = await manager.getRepository(Wagon).find({
|
||||
where: { trainId: train.id },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const currentLength = existing.reduce(
|
||||
(sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))];
|
||||
const types = typeIds.length
|
||||
? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } })
|
||||
: [];
|
||||
const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0]));
|
||||
const addedLength = incoming.reduce(
|
||||
(sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const totalLength = currentLength + addedLength;
|
||||
if (totalLength > maxLengthMeters) {
|
||||
throw new BadRequestException(
|
||||
`Cannot attach wagons — train length would be ${round(totalLength)} m ` +
|
||||
`(current ${round(currentLength)} m + ${round(addedLength)} m added), ` +
|
||||
`over the locomotive limit of ${round(maxLengthMeters)} m. ` +
|
||||
'Remove wagons from the consist or use locomotives with a higher length limit.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact wagon sequence numbers back to 1..n after a removal. */
|
||||
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
|
||||
const wagons = await manager.getRepository(Wagon).find({
|
||||
where: { trainId },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
});
|
||||
for (let i = 0; i < wagons.length; i++) {
|
||||
if (wagons[i].sequenceNumber !== i + 1) {
|
||||
await manager.getRepository(Wagon).update(wagons[i].id, { sequenceNumber: i + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
// apps/edr-freight-api/src/modules/trains/trains.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import { TrainBuilderController } from './train-builder.controller';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
import { TrainsController } from './trains.controller';
|
||||
import { TrainsService } from './trains.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Train])],
|
||||
controllers: [TrainsController],
|
||||
providers: [TrainsService],
|
||||
exports: [TrainsService], // if other modules need it
|
||||
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])],
|
||||
controllers: [TrainsController, TrainBuilderController],
|
||||
providers: [TrainsService, TrainBuilderService],
|
||||
exports: [TrainsService, TrainBuilderService],
|
||||
})
|
||||
export class TrainsModule {}
|
||||
export class TrainsModule {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
||||
* those at fulfilment.
|
||||
*/
|
||||
export class CreateTransferRequestDto {
|
||||
@IsUUID()
|
||||
fromYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
toYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
wagonTypeId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC fulfilment: the specific wagons hand-picked to satisfy a transfer request.
|
||||
* The service validates they all sit in the request's source yard, match its
|
||||
* wagon type, and number exactly the requested quantity.
|
||||
*/
|
||||
export class FulfillTransferRequestDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Wagon } from './wagon.entity';
|
||||
import { WagonTransferRequest } from './wagon-transfer-request.entity';
|
||||
|
||||
/**
|
||||
* Ledger of every physical wagon relocation between yards — one row per move.
|
||||
@@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity {
|
||||
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
|
||||
movedByUserId?: string | null;
|
||||
|
||||
/** The transfer request this move fulfilled, when it came from one. */
|
||||
@Column({ name: 'transfer_request_id', type: 'uuid', nullable: true })
|
||||
transferRequestId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonTransferRequest, { nullable: true })
|
||||
@JoinColumn({ name: 'transfer_request_id' })
|
||||
transferRequest?: WagonTransferRequest | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
/**
|
||||
* A two-person wagon relocation request. A requester asks for `quantity` wagons
|
||||
* of `wagonTypeId` to move from `fromYardId` to `toYardId` — specifying a count
|
||||
* only, never the physical wagons. OCC staff later open the PENDING request,
|
||||
* hand-pick the actual wagons in the source yard, and execute the transfer
|
||||
* (which writes the `wagon_movements` ledger and marks this FULFILLED).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_transfer_requests' })
|
||||
@Index(['status', 'fromYardId'])
|
||||
export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'from_yard_id', type: 'uuid' })
|
||||
fromYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'from_yard_id' })
|
||||
fromYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||
toYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'to_yard_id' })
|
||||
toYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
||||
wagonTypeId!: string;
|
||||
|
||||
@ManyToOne(() => WagonType)
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType | null;
|
||||
|
||||
/** How many wagons of `wagonTypeId` to move out of `fromYardId`. */
|
||||
@Column({ name: 'quantity', type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: WagonTransferRequestStatus.Pending,
|
||||
})
|
||||
status!: WagonTransferRequestStatus;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
||||
fulfilledByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
||||
fulfilledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
|
||||
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
|
||||
* the move. Separate top-level path so it never collides with `wagons/:id`.
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView()
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@WagonTransferRequest()
|
||||
@ApiOperation({ summary: 'File a count-only wagon-transfer request' })
|
||||
create(
|
||||
@Body() dto: CreateTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.createRequest(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
|
||||
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
|
||||
list(@Query('status') status?: WagonTransferRequestStatus) {
|
||||
return this.service.listRequests(status);
|
||||
}
|
||||
|
||||
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Get('history')
|
||||
@ApiOperation({
|
||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||
})
|
||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||
if (!user?.id) return { requests: [], movements: [] };
|
||||
return this.service.getHistory(user.id);
|
||||
}
|
||||
|
||||
@Get('history/all')
|
||||
@WagonTransferHistoryAll()
|
||||
@ApiQuery({ name: 'userId', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||
})
|
||||
allHistory(@Query('userId') userId?: string) {
|
||||
return this.service.getHistory(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one transfer request' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post(':id/fulfill')
|
||||
@WagonTransferFulfill()
|
||||
@ApiOperation({ summary: 'OCC: pick wagons and execute the transfer' })
|
||||
fulfill(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: FulfillTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.fulfillRequest(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
/** Bundled per-user activity: requests they touched + wagons they moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovement[];
|
||||
}
|
||||
|
||||
/** How many ledger rows the history returns at most (newest first). */
|
||||
const HISTORY_LIMIT = 500;
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
toYard: true,
|
||||
wagonType: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer workflow. A requester records a count-only request
|
||||
* (see `createRequest`); OCC staff later open the PENDING queue, hand-pick the
|
||||
* physical wagons, and `fulfillRequest` validates + executes the move. Replaces
|
||||
* the single-step instant bulk transfer.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonTransferRequestsService {
|
||||
constructor(
|
||||
@InjectRepository(WagonTransferRequest)
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>,
|
||||
@InjectRepository(WagonMovement)
|
||||
private readonly movementRepo: Repository<WagonMovement>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
/** Record a PENDING request. Count-only — no wagons are picked here. */
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
if (dto.fromYardId === dto.toYardId) {
|
||||
throw new BadRequestException(
|
||||
'Source and destination yard must be different',
|
||||
);
|
||||
}
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
wagonTypeId: dto.wagonTypeId,
|
||||
quantity: dto.quantity,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: userId ?? null,
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
const saved = await this.requestRepo.save(request);
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||
async listRequests(
|
||||
status?: WagonTransferRequestStatus,
|
||||
): Promise<WagonTransferRequest[]> {
|
||||
return this.requestRepo.find({
|
||||
where: status ? { status } : {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.requestRepo.findOne({
|
||||
where: { id },
|
||||
relations: REQUEST_RELATIONS,
|
||||
});
|
||||
if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
|
||||
* in the request's source yard, match its wagon type, and the count must equal
|
||||
* the requested quantity — then the transfer runs and the request is marked
|
||||
* FULFILLED.
|
||||
*/
|
||||
async fulfillRequest(
|
||||
id: string,
|
||||
dto: FulfillTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagonIds = [...new Set(dto.wagonIds)];
|
||||
if (wagonIds.length !== request.quantity) {
|
||||
throw new BadRequestException(
|
||||
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await this.wagonRepo.find({ where: { id: In(wagonIds) } });
|
||||
if (wagons.length !== wagonIds.length) {
|
||||
throw new NotFoundException('One or more selected wagons not found');
|
||||
}
|
||||
const offSource = wagons.filter((w) => w.currentYardId !== request.fromYardId);
|
||||
if (offSource.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are not in the source yard: ${offSource
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const wrongType = wagons.filter((w) => w.wagonTypeId !== request.wagonTypeId);
|
||||
if (wrongType.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are the wrong type: ${wrongType
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
|
||||
// each stamped with this request's id so history can link them back).
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds, toYardId: request.toYardId },
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
|
||||
* individual wagons they physically moved (linked back to their request when
|
||||
* one drove the move). Pass a `userId` to scope to one staffer; pass
|
||||
* `undefined` for the admin all-staff view. Scope is decided by the CALLER
|
||||
* (the controller passes the caller's id unless they hold the history-all
|
||||
* permission) — this method trusts its argument.
|
||||
*/
|
||||
async getHistory(userId?: string | null): Promise<TransferHistory> {
|
||||
const requests = await this.requestRepo.find({
|
||||
where: userId
|
||||
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
||||
: {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
const movements = await this.movementRepo.find({
|
||||
// Own view: moves I made. All view: every user-attributed move (skip the
|
||||
// system-written loaded/reposition legs that carry no mover).
|
||||
where: userId
|
||||
? { movedByUserId: userId }
|
||||
: { movedByUserId: Not(IsNull()) },
|
||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||
order: { occurredAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
return { requests, movements };
|
||||
}
|
||||
|
||||
/** Withdraw a still-PENDING request. */
|
||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
|
||||
);
|
||||
}
|
||||
request.status = WagonTransferRequestStatus.Cancelled;
|
||||
await this.requestRepo.save(request);
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
|
||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])],
|
||||
controllers: [WagonsController, TrainWagonsReorderController],
|
||||
providers: [WagonsService],
|
||||
exports: [WagonsService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Wagon,
|
||||
WagonMovement,
|
||||
WagonTransferRequest,
|
||||
Train,
|
||||
Yard,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
TrainWagonsReorderController,
|
||||
WagonTransferRequestsController,
|
||||
],
|
||||
providers: [WagonsService, WagonTransferRequestsService],
|
||||
exports: [WagonsService, WagonTransferRequestsService],
|
||||
})
|
||||
export class WagonsModule {}
|
||||
|
||||
@@ -180,6 +180,7 @@ export class WagonsService {
|
||||
async bulkTransfer(
|
||||
dto: BulkTransferWagonsDto,
|
||||
userId?: string | null,
|
||||
opts?: { transferRequestId?: string | null },
|
||||
): Promise<{ moved: number }> {
|
||||
const { wagonIds, toYardId } = dto;
|
||||
if (!wagonIds.length) return { moved: 0 };
|
||||
@@ -215,6 +216,7 @@ export class WagonsService {
|
||||
toYardId,
|
||||
kind: WagonMovementKind.Manual,
|
||||
movedByUserId: userId ?? null,
|
||||
transferRequestId: opts?.transferRequestId ?? null,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
/** Acknowledge (optionally snooze) an item's fee-accrual alert. */
|
||||
export class AcknowledgeAccrualDto {
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(90)
|
||||
snoozeDays?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional reason / note.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** The customer approving a handover must record their full name (signature optional). */
|
||||
export class ApproveDeliveryDto {
|
||||
@ApiProperty({ description: 'Full name of the person approving delivery.' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(160)
|
||||
signerName!: string;
|
||||
}
|
||||
@@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity {
|
||||
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
||||
signedAt?: Date | null;
|
||||
|
||||
/** Full name of the person who signed off the handover (required at sign time). */
|
||||
@Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true })
|
||||
signerName?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
|
||||
signedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -183,12 +183,20 @@ export class HandoverService {
|
||||
}
|
||||
|
||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
||||
async signForBooking(
|
||||
bookingId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(BookingHandover)
|
||||
.update(
|
||||
{ bookingId, signedAt: IsNull() },
|
||||
{ signedAt: new Date(), signedByUserId: userId ?? null },
|
||||
{
|
||||
signedAt: new Date(),
|
||||
signedByUserId: userId ?? null,
|
||||
signerName: signerName?.trim() || null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||
@@ -26,6 +29,35 @@ interface ItemAttributes {
|
||||
zoneId: string | null;
|
||||
}
|
||||
|
||||
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
|
||||
|
||||
export interface AccrualDashboardRow {
|
||||
inventoryId: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
currency: string;
|
||||
accruedAmount: number;
|
||||
freeDaysLeft: number | null;
|
||||
charging: boolean;
|
||||
alert: AccrualAlert;
|
||||
/** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
|
||||
acknowledged: boolean;
|
||||
snoozeUntil: string | null;
|
||||
breakdown: Array<{
|
||||
type: FeeRuleType;
|
||||
amount: number;
|
||||
freeDays: number;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||||
@@ -70,12 +102,82 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseFeeService {
|
||||
private readonly logger = new Logger(WarehouseFeeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Daily accrual alerts: for every in-warehouse item that is charging or within
|
||||
* its last free days, send the customer an in-app notification with the
|
||||
* outstanding accrued amount so they can collect before (more) charges hit.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
|
||||
async sendAccrualAlerts(): Promise<void> {
|
||||
try {
|
||||
const alerts = (await this.accrualDashboard()).filter(
|
||||
(r) => r.alert !== 'OK' && !r.acknowledged,
|
||||
);
|
||||
if (!alerts.length) return;
|
||||
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
|
||||
|
||||
// Per-customer: notify each company about its own items.
|
||||
for (const row of alerts.filter((r) => r.companyId)) {
|
||||
const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
|
||||
const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
|
||||
const body = row.charging
|
||||
? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.`
|
||||
: `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: row.companyId! },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
|
||||
body,
|
||||
link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
|
||||
data: {
|
||||
inventoryId: row.inventoryId,
|
||||
bookingId: row.bookingId,
|
||||
alert: row.alert,
|
||||
accruedAmount: row.accruedAmount,
|
||||
action: 'ACCRUAL_ALERT',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ops staff: one digest covering every alerting item.
|
||||
const charging = alerts.filter((r) => r.charging).length;
|
||||
const nearing = alerts.length - charging;
|
||||
const currency = alerts[0]?.currency ?? 'USD';
|
||||
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Warehouse fee accruals need attention',
|
||||
body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
|
||||
link: '/dashboard/warehouse-fee-invoices',
|
||||
data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
||||
listRules(): Promise<WarehouseFeeRule[]> {
|
||||
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
|
||||
@@ -416,6 +518,138 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live accrual dashboard: for every item still in the warehouse, the fees
|
||||
* accruing right now (storage + demurrage + double-handling), how many free
|
||||
* days remain, and an alert level so staff can act before charges land.
|
||||
*/
|
||||
async accrualDashboard(billingCurrency = 'USD'): Promise<AccrualDashboardRow[]> {
|
||||
const items: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
bookingId: string | null;
|
||||
companyId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
warehouseCode: string | null;
|
||||
zoneCode: string | null;
|
||||
receivedAt: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.status,
|
||||
b.id AS "bookingId",
|
||||
b.company_id AS "companyId",
|
||||
b.reference AS "bookingReference",
|
||||
c.name AS "customerName",
|
||||
w.code AS "warehouseCode",
|
||||
z.code AS "zoneCode",
|
||||
inv.created_at AS "receivedAt"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
ORDER BY inv.created_at ASC`,
|
||||
);
|
||||
|
||||
const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
|
||||
FROM freight.warehouse_accrual_acks`,
|
||||
);
|
||||
const now = new Date();
|
||||
const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));
|
||||
|
||||
const rows = await Promise.all(
|
||||
items.map(async (it): Promise<AccrualDashboardRow> => {
|
||||
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
|
||||
(p) => p.ruleId,
|
||||
);
|
||||
const accruedAmount =
|
||||
Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
|
||||
const charging = previews.some((p) => p.chargeableDays > 0);
|
||||
const freeDaysLeftVals = previews
|
||||
.filter((p) => p.endIsOpen)
|
||||
.map((p) => Math.max(0, p.freeDays - p.elapsedDays));
|
||||
const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
|
||||
const alert: AccrualAlert = charging
|
||||
? 'CHARGING'
|
||||
: freeDaysLeft != null && freeDaysLeft <= 2
|
||||
? 'WARNING'
|
||||
: 'OK';
|
||||
return {
|
||||
inventoryId: it.id,
|
||||
status: it.status,
|
||||
bookingId: it.bookingId,
|
||||
companyId: it.companyId,
|
||||
bookingReference: it.bookingReference,
|
||||
customerName: it.customerName,
|
||||
warehouseCode: it.warehouseCode,
|
||||
zoneCode: it.zoneCode,
|
||||
receivedAt: it.receivedAt,
|
||||
currency: billingCurrency,
|
||||
accruedAmount,
|
||||
freeDaysLeft,
|
||||
charging,
|
||||
alert,
|
||||
acknowledged:
|
||||
acks.has(it.id) &&
|
||||
(acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
|
||||
snoozeUntil: acks.get(it.id) ?? null,
|
||||
breakdown: previews.map((p) => ({
|
||||
type: p.ruleType,
|
||||
amount: p.amount,
|
||||
freeDays: p.freeDays,
|
||||
elapsedDays: p.elapsedDays,
|
||||
chargeableDays: p.chargeableDays,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
|
||||
// Acknowledged items sink to the bottom; among the rest, worst alert first.
|
||||
return rows.sort(
|
||||
(a, b) =>
|
||||
Number(a.acknowledged) - Number(b.acknowledged) ||
|
||||
rank(a.alert) - rank(b.alert) ||
|
||||
b.accruedAmount - a.accruedAmount,
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
|
||||
async acknowledgeAccrual(
|
||||
inventoryId: string,
|
||||
opts: { snoozeDays?: number; note?: string; userId?: string } = {},
|
||||
): Promise<void> {
|
||||
const snoozeUntil =
|
||||
opts.snoozeDays && opts.snoozeDays > 0
|
||||
? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouse_accrual_acks
|
||||
(inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
|
||||
VALUES ($1, $2, now(), $3, $4, now())
|
||||
ON CONFLICT (inventory_id) DO UPDATE
|
||||
SET acknowledged_by = EXCLUDED.acknowledged_by,
|
||||
acknowledged_at = now(),
|
||||
snooze_until = EXCLUDED.snooze_until,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now()`,
|
||||
[inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove an acknowledgement so the item re-surfaces for alerts. */
|
||||
async unacknowledgeAccrual(inventoryId: string): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
|
||||
[inventoryId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
@@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
@ApiTags('warehouse-inspection')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
|
||||
export class WarehouseInspectionController {
|
||||
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
||||
|
||||
@Post('warehouse-inventory/:inventoryId/inspection-reports')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create)
|
||||
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
|
||||
create(
|
||||
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||
@@ -46,12 +50,14 @@ export class WarehouseInspectionController {
|
||||
}
|
||||
|
||||
@Patch('warehouse-inspection-reports/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
|
||||
@ApiOperation({ summary: 'Update an inspection report' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
|
||||
return this.inspectionService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post('warehouse-inspection-reports/:id/attachments')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload inspection images / documents' })
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -11,6 +13,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
@@ -29,48 +32,63 @@ export class WarehouseInventoryController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findReadyForLoading(filter);
|
||||
}
|
||||
|
||||
@Get('inquiry')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
|
||||
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
|
||||
return this.inventoryService.inquiry(filter);
|
||||
}
|
||||
|
||||
@Get('arrival-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
|
||||
arrivalQueue() {
|
||||
return this.inventoryService.arrivalQueue();
|
||||
}
|
||||
|
||||
@Get('ops-stats')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' })
|
||||
opsStats() {
|
||||
return this.inventoryService.opsStats();
|
||||
}
|
||||
|
||||
@Get('zone-occupancy')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
||||
zoneOccupancy(@Query('yardId') yardId?: string) {
|
||||
return this.inventoryService.zoneOccupancy(yardId);
|
||||
}
|
||||
|
||||
@Post('auto-unload-arrived')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||
autoUnloadArrived() {
|
||||
return this.inventoryService.autoUnloadArrived();
|
||||
}
|
||||
|
||||
@Post('auto-load-ready')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
|
||||
autoLoadReady() {
|
||||
return this.inventoryService.autoLoadReady();
|
||||
}
|
||||
|
||||
@Get('eligible-bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
|
||||
eligibleBookings(@Query('direction') direction?: string) {
|
||||
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
|
||||
@@ -78,6 +96,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('receive-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
receiveBulk(@Body() dto: BulkReceiveDto) {
|
||||
return this.inventoryService.bulkReceive(dto);
|
||||
@@ -85,36 +104,42 @@ export class WarehouseInventoryController {
|
||||
|
||||
|
||||
@Get('ready-to-load-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
|
||||
readyToLoadExport() {
|
||||
return this.inventoryService.readyToLoadExport();
|
||||
}
|
||||
|
||||
@Get('received-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
|
||||
receivedExport() {
|
||||
return this.inventoryService.receivedExport();
|
||||
}
|
||||
|
||||
@Get('loaded-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||
loadedExport() {
|
||||
return this.inventoryService.loadedExport();
|
||||
}
|
||||
|
||||
@Get('loadable-trains')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
|
||||
loadableTrains() {
|
||||
return this.inventoryService.loadableTrains();
|
||||
}
|
||||
|
||||
@Get('train/:scheduleId/loadable-items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
|
||||
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.inventoryService.trainLoadableItems(scheduleId);
|
||||
}
|
||||
|
||||
@Post('train/:scheduleId/load')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
|
||||
loadItemsOntoTrain(
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
@@ -124,18 +149,21 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bulk-dispatch-export')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
|
||||
}
|
||||
|
||||
@Post('bulk-mark-inspected')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect)
|
||||
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
|
||||
bulkMarkInspected(@Body() dto: BulkInspectDto) {
|
||||
return this.inventoryService.bulkMarkInspected(dto);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/unload')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||
unloadBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@@ -145,24 +173,28 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/gate-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
|
||||
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
|
||||
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.gateClearance(id, performedBy);
|
||||
}
|
||||
|
||||
@Get('import/arrive-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
|
||||
importArriveQueue() {
|
||||
return this.scheduling.importArriveQueue();
|
||||
}
|
||||
|
||||
@Get('import/trains/:scheduleId/items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
|
||||
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.importTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('import/auto-unload-arrived-bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
|
||||
autoUnloadArrivedBookings(@Body() dto: {
|
||||
scheduleId: string;
|
||||
@@ -179,12 +211,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
|
||||
importUnloadedQueue() {
|
||||
return this.inventoryService.importUnloadedQueue();
|
||||
}
|
||||
|
||||
@Get('export/djibouti-arrival-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
|
||||
exportDjiboutiArrivalQueue(
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@@ -203,102 +237,119 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('export/djibouti-trains/:scheduleId/items')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
|
||||
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('export/auto-unload-at-djibouti')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
|
||||
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
|
||||
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
|
||||
}
|
||||
|
||||
@Get('import/pickup-ready-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
||||
importPickupReadyQueue() {
|
||||
return this.inventoryService.importPickupReadyQueue();
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
return this.scheduling.listLoadableWagons();
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/schedule')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
|
||||
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.scheduling.getBookingSchedule(bookingId);
|
||||
}
|
||||
|
||||
@Post('receive')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||
return this.inventoryService.receive(dto);
|
||||
}
|
||||
|
||||
@Post('reserve')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||
reserve(@Body() dto: ReserveInventoryDto) {
|
||||
return this.inventoryService.reserve(dto);
|
||||
}
|
||||
|
||||
@Get(':id/movements')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Inventory movement history' })
|
||||
movements(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findMovements(id);
|
||||
}
|
||||
|
||||
@Get(':id/activity')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Inventory activity log' })
|
||||
activity(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findActivity(id);
|
||||
}
|
||||
|
||||
@Get(':id/loadings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Loading records for an inventory item' })
|
||||
loadings(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findLoadingsByInventory(id);
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
|
||||
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
|
||||
return this.inventoryService.move(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForLoading(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
|
||||
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
|
||||
return this.inventoryService.load(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-pickup')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
||||
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForPickup(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/release')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.release)
|
||||
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
|
||||
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
|
||||
return this.inventoryService.release(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/release-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
|
||||
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
|
||||
@@ -309,6 +360,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('customer-truck-exit-paper/:assignmentId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
|
||||
async truckExitPaper(
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@@ -322,6 +374,7 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get(':id/grn-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocument(id);
|
||||
@@ -342,12 +395,17 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/approve-delivery')
|
||||
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
|
||||
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
|
||||
approveDeliveryForBooking(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ApproveDeliveryDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
||||
return this.inventoryService.approveDeliveryForBooking(
|
||||
bookingId,
|
||||
req.user?.id ?? req.user?.sub,
|
||||
dto.signerName,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handovers')
|
||||
@@ -362,6 +420,26 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/grn-document')
|
||||
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
|
||||
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(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/release-document')
|
||||
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
|
||||
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(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/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
@@ -385,12 +463,14 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
return this.inventoryService.deliver(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/dispatch')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.dispatch(id, performedBy);
|
||||
|
||||
@@ -397,6 +397,45 @@ export class WarehouseInventoryService {
|
||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||
*/
|
||||
/**
|
||||
* At-a-glance warehouse ops counters for the KPI strip:
|
||||
* - receivedToday: items received today
|
||||
* - pendingInspection: RECEIVED items not yet inspected
|
||||
* - trucksOnSite: customer trucks arrived but not departed
|
||||
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
|
||||
*/
|
||||
async opsStats(): Promise<{
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}> {
|
||||
const [row]: Array<{
|
||||
receivedToday: number;
|
||||
pendingInspection: number;
|
||||
trucksOnSite: number;
|
||||
itemsAging: number;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL
|
||||
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||||
);
|
||||
return {
|
||||
receivedToday: row?.receivedToday ?? 0,
|
||||
pendingInspection: row?.pendingInspection ?? 0,
|
||||
trucksOnSite: row?.trucksOnSite ?? 0,
|
||||
itemsAging: row?.itemsAging ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
||||
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
||||
@@ -454,14 +493,17 @@ export class WarehouseInventoryService {
|
||||
|
||||
return rows.map((r) => {
|
||||
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
|
||||
// Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
|
||||
// used weight to tonnes before comparing so weight occupancy is correct.
|
||||
const usedWeightTons = r.usedWeight / 1000;
|
||||
const byWeight =
|
||||
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
|
||||
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
|
||||
const byItems =
|
||||
r.capacityContainers && r.capacityContainers > 0
|
||||
? (r.usedItems / r.capacityContainers) * 100
|
||||
: null;
|
||||
// Prefer container-count occupancy (unit-consistent). Weight capacity is
|
||||
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
|
||||
// Container zones use item-count occupancy; bulk zones (no container cap)
|
||||
// fall back to the now unit-correct weight occupancy.
|
||||
const pct = byItems ?? byWeight;
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -2788,6 +2830,7 @@ export class WarehouseInventoryService {
|
||||
Array<{
|
||||
containerNumber: string;
|
||||
goods: string | null;
|
||||
containerSize: string | null;
|
||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
grnNumber: string | null;
|
||||
truckAssignmentId: string | null;
|
||||
@@ -2804,6 +2847,7 @@ export class WarehouseInventoryService {
|
||||
const rows: Array<{
|
||||
containerNumber: string;
|
||||
goods: string | null;
|
||||
containerSize: string | null;
|
||||
received: boolean;
|
||||
grnNumber: string | null;
|
||||
truckAssignmentId: string | null;
|
||||
@@ -2818,6 +2862,7 @@ export class WarehouseInventoryService {
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||||
bc.container_size AS "containerSize",
|
||||
bcu.received_to_port AS received,
|
||||
bcu.grn_number AS "grnNumber",
|
||||
ctc.assignment_id AS "truckAssignmentId",
|
||||
@@ -2854,6 +2899,7 @@ export class WarehouseInventoryService {
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
goods: r.goods,
|
||||
containerSize: r.containerSize,
|
||||
// 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.
|
||||
@@ -3132,16 +3178,20 @@ export class WarehouseInventoryService {
|
||||
async approveDeliveryForBooking(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
signerName?: string,
|
||||
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Authentication is required to approve delivery');
|
||||
}
|
||||
|
||||
const signature = await this.signatures.getForUser(userId);
|
||||
if (!signature?.signatureImageUrl) {
|
||||
throw new BadRequestException('Please save your signature before approving delivery');
|
||||
const name = signerName?.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Please enter your full name to approve delivery');
|
||||
}
|
||||
|
||||
// A saved signature is applied when available; otherwise the typed full name
|
||||
// is the record of who approved (self-haul customers may have no signature).
|
||||
const signature = await this.signatures.getForUser(userId).catch(() => null);
|
||||
|
||||
const [item]: Array<{
|
||||
id: string;
|
||||
warehouseId: string | null;
|
||||
@@ -3176,8 +3226,8 @@ export class WarehouseInventoryService {
|
||||
const approvedAt = new Date();
|
||||
const approval = {
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
signatureImageUrl: signature.signatureImageUrl,
|
||||
signerDisplayName: name,
|
||||
signatureImageUrl: signature?.signatureImageUrl ?? null,
|
||||
userId,
|
||||
};
|
||||
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||
@@ -3192,8 +3242,8 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: item.id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Customer approved delivery as ${signature.signerDisplayName}`,
|
||||
performedBy: signature.signerDisplayName,
|
||||
description: `Customer approved delivery as ${name}`,
|
||||
performedBy: name,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
@@ -3201,13 +3251,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||
// the truck leaves). Kept alongside the legacy approval note.
|
||||
await this.handover.signForBooking(bookingId, userId);
|
||||
await this.handover.signForBooking(bookingId, userId, name);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
inventoryId: item.id,
|
||||
approvedAt: approval.approvedAt,
|
||||
signerDisplayName: signature.signerDisplayName,
|
||||
signerDisplayName: name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3226,6 +3276,31 @@ export class WarehouseInventoryService {
|
||||
return this.handoverDocument(inv.id);
|
||||
}
|
||||
|
||||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
|
||||
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 inv.id;
|
||||
}
|
||||
|
||||
/** Booking-scoped GRN document (customer portal). */
|
||||
async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
/** Booking-scoped gate-clearance / release document (customer portal). */
|
||||
async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
@@ -13,12 +15,14 @@ export class WarehouseInvoiceController {
|
||||
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
|
||||
|
||||
@Post('warehouse-inventory/:id/generate-fee-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
|
||||
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
|
||||
return this.invoiceService.generateForInventory(id, dto);
|
||||
}
|
||||
|
||||
@Post('last-mile/:id/generate-truck-detention-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
|
||||
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
|
||||
generateTruckDetention(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -28,6 +32,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-invoices')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
|
||||
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
|
||||
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.listForInventory(id);
|
||||
@@ -40,6 +45,7 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Get('warehouse-fee-invoices')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
|
||||
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
|
||||
findAll(
|
||||
@Query('status') status?: string,
|
||||
@@ -86,12 +92,14 @@ export class WarehouseInvoiceController {
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-invoices/:id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.invoiceService.cancel(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-invoices/:id/pay')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay)
|
||||
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
|
||||
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||
return this.invoiceService.pay(id, dto);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-loadings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-loadings')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
export class WarehouseLoadingsController {
|
||||
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
AllocationPreviewDto,
|
||||
CreateAllocationRuleDto,
|
||||
UpdateAllocationRuleDto,
|
||||
} from './dto/allocation-rule.dto';
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseFeeService } from './warehouse-fee.service';
|
||||
|
||||
@@ -21,18 +24,21 @@ export class WarehouseRulesController {
|
||||
|
||||
// ── Allocation rules ───────────────────────────────────────────────────────
|
||||
@Get('warehouse-allocation-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
|
||||
@ApiOperation({ summary: 'List warehouse allocation rules' })
|
||||
listAllocationRules() {
|
||||
return this.allocationService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create)
|
||||
@ApiOperation({ summary: 'Create a warehouse allocation rule' })
|
||||
createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
|
||||
return this.allocationService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-allocation-rules/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update)
|
||||
@ApiOperation({ summary: 'Update a warehouse allocation rule' })
|
||||
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
|
||||
return this.allocationService.updateRule(id, dto);
|
||||
@@ -40,12 +46,14 @@ export class WarehouseRulesController {
|
||||
|
||||
@Delete('warehouse-allocation-rules/:id')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete)
|
||||
@ApiOperation({ summary: 'Delete a warehouse allocation rule' })
|
||||
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.allocationService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Post('warehouse-allocation/preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
|
||||
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
|
||||
previewAllocation(@Body() dto: AllocationPreviewDto) {
|
||||
return this.allocationService.resolveLocation(dto);
|
||||
@@ -53,18 +61,21 @@ export class WarehouseRulesController {
|
||||
|
||||
// ── Fee rules ────────────────────────────────────────────────────────────────
|
||||
@Get('warehouse-fee-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'List storage / demurrage fee rules' })
|
||||
listFeeRules() {
|
||||
return this.feeService.listRules();
|
||||
}
|
||||
|
||||
@Post('warehouse-fee-rules')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create)
|
||||
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
|
||||
createFeeRule(@Body() dto: CreateFeeRuleDto) {
|
||||
return this.feeService.createRule(dto);
|
||||
}
|
||||
|
||||
@Patch('warehouse-fee-rules/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Update a fee rule' })
|
||||
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
|
||||
return this.feeService.updateRule(id, dto);
|
||||
@@ -72,12 +83,42 @@ export class WarehouseRulesController {
|
||||
|
||||
@Delete('warehouse-fee-rules/:id')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete)
|
||||
@ApiOperation({ summary: 'Delete a fee rule' })
|
||||
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.feeService.deleteRule(id);
|
||||
}
|
||||
|
||||
@Get('warehouse-fees/accrual-dashboard')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
|
||||
accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
|
||||
return this.feeService.accrualDashboard(billingCurrency);
|
||||
}
|
||||
|
||||
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
|
||||
acknowledgeAccrual(
|
||||
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
|
||||
@Body() dto: AcknowledgeAccrualDto,
|
||||
) {
|
||||
return this.feeService.acknowledgeAccrual(inventoryId, {
|
||||
snoozeDays: dto.snoozeDays,
|
||||
note: dto.note,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('warehouse-fees/accrual/:inventoryId/acknowledge')
|
||||
@HttpCode(204)
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
|
||||
@ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' })
|
||||
unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
|
||||
return this.feeService.unacknowledgeAccrual(inventoryId);
|
||||
}
|
||||
|
||||
@Get('warehouse-inventory/:id/fee-preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
|
||||
feePreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -87,6 +128,7 @@ export class WarehouseRulesController {
|
||||
}
|
||||
|
||||
@Get('last-mile/:id/truck-detention-preview')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
|
||||
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
|
||||
truckDetentionPreview(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
@@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
@@ -28,18 +31,21 @@ export class WarehouseYardsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.update)
|
||||
@ApiOperation({ summary: 'Update warehouse yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
|
||||
return this.yardsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':yardId/zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
@ApiOperation({ summary: 'List zones within a yard' })
|
||||
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
|
||||
return this.zonesService.findByYard(yardId);
|
||||
}
|
||||
|
||||
@Post(':yardId/zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
|
||||
@ApiOperation({ summary: 'Create a zone within a yard' })
|
||||
createZone(
|
||||
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-zones')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-zones')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@@ -23,6 +26,7 @@ export class WarehouseZonesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
|
||||
@ApiOperation({ summary: 'Update warehouse zone' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||
return this.zonesService.update(id, dto);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
@@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
@ApiTags('warehouses')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouses')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.view)
|
||||
export class WarehousesController {
|
||||
constructor(
|
||||
private readonly warehousesService: WarehousesService,
|
||||
@@ -26,12 +29,14 @@ export class WarehousesController {
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
|
||||
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
|
||||
dashboard() {
|
||||
return this.dashboardService.getDashboard();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.create)
|
||||
@ApiOperation({ summary: 'Create warehouse' })
|
||||
create(@Body() dto: CreateWarehouseDto) {
|
||||
return this.warehousesService.create(dto);
|
||||
@@ -44,18 +49,21 @@ export class WarehousesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.update)
|
||||
@ApiOperation({ summary: 'Update warehouse' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
|
||||
return this.warehousesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
|
||||
return this.yardsService.findByWarehouse(warehouseId);
|
||||
}
|
||||
|
||||
@Post(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.create)
|
||||
@ApiOperation({ summary: 'Create a yard within a warehouse' })
|
||||
createYard(
|
||||
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,
|
||||
|
||||
@@ -175,6 +175,9 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
|
||||
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
|
||||
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
|
||||
perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'),
|
||||
perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'),
|
||||
perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"),
|
||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||
@@ -440,6 +443,13 @@ export const FREIGHT_PERMS = {
|
||||
create: 'edr_freight_app:wagons:create',
|
||||
update: 'edr_freight_app:wagons:update',
|
||||
delete: 'edr_freight_app:wagons:delete',
|
||||
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
|
||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||
transferFulfill: 'edr_freight_app:wagons:transfer_fulfill',
|
||||
// Admin: read every staffer's transfer history. Without it, a user only sees
|
||||
// their own (the /history endpoint uses the caller id, backend-enforced).
|
||||
transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all',
|
||||
},
|
||||
trains: {
|
||||
view: 'edr_freight_app:trains:view',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Hammer,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
@@ -109,6 +110,8 @@ import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityP
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
|
||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
@@ -261,6 +264,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
@@ -1032,6 +1041,22 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
@@ -1246,6 +1271,22 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
|
||||
@@ -49,6 +49,7 @@ import { api } from "@/services/api";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import {
|
||||
useContractCapacity,
|
||||
useContractDetail,
|
||||
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
|
||||
}>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const requestIdParam = searchParams.get("requestId");
|
||||
// Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same
|
||||
// contract (GL only picks a new schedule). Set by the clearance Rebook action.
|
||||
const copyFromParam = searchParams.get("copyFrom");
|
||||
const navigate = useNavigate();
|
||||
const { data: contract, isLoading } = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
@@ -205,6 +209,13 @@ export default function GlCreateBookingForm() {
|
||||
enabled: Boolean(requestId),
|
||||
});
|
||||
|
||||
// The expired booking a Rebook is copying from (its cargo seeds the form).
|
||||
const { data: copyFromBooking } = useQuery({
|
||||
queryKey: ["rebook-copy-from", copyFromParam],
|
||||
queryFn: () => bookingsService.getById(copyFromParam!),
|
||||
enabled: Boolean(copyFromParam),
|
||||
});
|
||||
|
||||
// Same window-gating the customer sees: booking is only allowed while a
|
||||
// window is OPEN for one of the contract's routes. Intercity contracts are
|
||||
// never window-gated — the shipment rides a passing train staff pick later.
|
||||
@@ -363,6 +374,28 @@ export default function GlCreateBookingForm() {
|
||||
if (bookingRequest.notes) setNotes(bookingRequest.notes);
|
||||
}, [bookingRequest, prefilled]);
|
||||
|
||||
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
|
||||
// item count isn't on the booking payload yet, so bulk rebooks fall through to
|
||||
// the normal contract seed and GL re-enters the quantity.)
|
||||
useEffect(() => {
|
||||
if (!copyFromBooking || prefilled) return;
|
||||
const lines = copyFromBooking.bookingContainers ?? [];
|
||||
if (!lines.length) return;
|
||||
setPrefilled(true);
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
return {
|
||||
containerSize: String(c.containerType?.sizeFt ?? ""),
|
||||
quantity: String(qty),
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: Array.from({ length: qty }, emptyUnit),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [copyFromBooking, prefilled]);
|
||||
|
||||
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||
// portal form does. Subsequent renders reuse the lines.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Button,
|
||||
TextInput,
|
||||
Textarea,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Switch,
|
||||
Stack,
|
||||
@@ -79,8 +80,12 @@ const buildInitialValues = (
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const raw = record?.[field.name];
|
||||
if (raw !== undefined && raw !== null) {
|
||||
const raw = field.getInitialValue && record
|
||||
? field.getInitialValue(record)
|
||||
: record?.[field.name];
|
||||
if (field.type === "multiselect") {
|
||||
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
|
||||
} else if (raw !== undefined && raw !== null) {
|
||||
if (field.type === "date" && typeof raw === "string") {
|
||||
values[field.name] = raw.slice(0, 10);
|
||||
} else if (Array.isArray(raw)) {
|
||||
@@ -197,7 +202,10 @@ const RuleEngineFormDialog = ({
|
||||
|
||||
for (const field of visibleFields) {
|
||||
const raw = values[field.name];
|
||||
if (field.type === "number") {
|
||||
if (field.type === "multiselect") {
|
||||
// Always the full replacement list — the API syncs the relation to it.
|
||||
payload[field.name] = Array.isArray(raw) ? raw : [];
|
||||
} else if (field.type === "number") {
|
||||
if (raw === "" || raw === undefined) continue;
|
||||
payload[field.name] = Number(raw);
|
||||
} else if (field.type === "boolean") {
|
||||
@@ -258,6 +266,38 @@ const RuleEngineFormDialog = ({
|
||||
|
||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||
|
||||
if (field.type === "multiselect") {
|
||||
const options = field.optionsFromValues
|
||||
? field.optionsFromValues(values)
|
||||
: (field.options ?? []);
|
||||
const selected = Array.isArray(values[field.name])
|
||||
? (values[field.name] as string[])
|
||||
: [];
|
||||
return (
|
||||
<MultiSelect
|
||||
key={field.name}
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder={
|
||||
selectOptionsLoading
|
||||
? "Loading options..."
|
||||
: (field.placeholder ?? "Select one or more")
|
||||
}
|
||||
value={selected}
|
||||
onChange={(v) => setField(field.name, v)}
|
||||
disabled={selectOptionsLoading}
|
||||
data={options
|
||||
.filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE)
|
||||
.map((opt) => ({ label: opt.label, value: opt.value }))}
|
||||
searchable
|
||||
clearable
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
|
||||
/**
|
||||
* AVAILABLE wagons standing in the train's own yard — the only ones that can
|
||||
* be coupled. Pick any number and append them to the consist.
|
||||
*/
|
||||
export default function AvailableWagonsPanel({
|
||||
yardId,
|
||||
yardLabel,
|
||||
onAssign,
|
||||
assigning,
|
||||
}: AvailableWagonsPanelProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("ALL");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const wagonsQuery = useQuery(
|
||||
api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: { status: Freight.WagonStatus.Available, currentYardId: yardId },
|
||||
},
|
||||
enabled: Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
|
||||
const wagons = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return (wagonsQuery.data ?? []).filter((wagon) => {
|
||||
if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false;
|
||||
if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [wagonsQuery.data, search, typeFilter]);
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const byId = new Map<string, string>();
|
||||
for (const wagon of wagonsQuery.data ?? []) {
|
||||
if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
|
||||
}
|
||||
return [
|
||||
{ value: "ALL", label: "All types" },
|
||||
...[...byId.entries()].map(([value, label]) => ({ value, label })),
|
||||
];
|
||||
}, [wagonsQuery.data]);
|
||||
|
||||
const toggle = (wagonId: string, checked: boolean) => {
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId),
|
||||
);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (!selected.length) return;
|
||||
onAssign(selected);
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" grow>
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder="Search wagon number…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
data={typeOptions}
|
||||
value={typeFilter}
|
||||
onChange={(v) => setTypeFilter(v ?? "ALL")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ScrollArea.Autosize mah={380} type="auto">
|
||||
<Stack gap={6}>
|
||||
{wagonsQuery.isLoading ? (
|
||||
<Text py="md" ta="center" c="dimmed" size="sm">
|
||||
Loading wagons…
|
||||
</Text>
|
||||
) : !wagons.length ? (
|
||||
<Text py="md" ta="center" c="dimmed" size="sm">
|
||||
No available wagons in {yardLabel ?? "this yard"}
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((wagon) => (
|
||||
<Group
|
||||
key={wagon.id}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected.includes(wagon.id)}
|
||||
onChange={(e) => toggle(wagon.id, e.currentTarget.checked)}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selected.length}
|
||||
loading={assigning}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AvailableWagonsPanelProps {
|
||||
yardId: string;
|
||||
yardLabel?: string | null;
|
||||
onAssign: (wagonIds: string[]) => void;
|
||||
assigning: boolean;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Step one of the Train Builder: give the train its operator code, pick the
|
||||
* yard it is being assembled in, and couple at least two locomotives from that
|
||||
* yard. Wagons are attached afterwards on the composition page.
|
||||
*/
|
||||
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [code, setCode] = useState("");
|
||||
const [trainName, setTrainName] = useState("");
|
||||
const [yardId, setYardId] = useState("");
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
// Only serviceable locomotives standing in the selected yard can be coupled.
|
||||
const locomotivesQuery = useQuery(
|
||||
api.locomotives.listFiltered.queryOptions({
|
||||
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
|
||||
enabled: Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
const build = useMutation(api.trainBuilder.build.mutationOptions());
|
||||
|
||||
// A locomotive belongs to one yard — switching yards invalidates the pick.
|
||||
useEffect(() => {
|
||||
setLocomotiveIds([]);
|
||||
}, [yardId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setCode("");
|
||||
setTrainName("");
|
||||
setYardId("");
|
||||
setLocomotiveIds([]);
|
||||
setNotes("");
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
if (!code.trim() || !yardId || locomotiveIds.length < 2) {
|
||||
toast({
|
||||
title: "Enter a train code, pick a yard, and couple at least two locomotives",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const composition = await build.mutateAsync({
|
||||
code: code.trim(),
|
||||
currentYardId: yardId,
|
||||
locomotiveIds,
|
||||
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
|
||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||
});
|
||||
toast({ title: `Train ${composition.code} built` });
|
||||
onClose();
|
||||
onBuilt(composition);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Build failed",
|
||||
description: parseError(err, "Could not build the train"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const locomotiveOptions = (locomotivesQuery.data ?? []).map((loco) => ({
|
||||
value: loco.id,
|
||||
label: `${loco.code}${loco.name ? ` — ${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Build a train</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
A train is assembled in one yard: two or more locomotives plus wagons
|
||||
standing in that same yard. Wagons are attached on the next screen.
|
||||
</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Train code"
|
||||
placeholder="e.g. 81001"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.currentTarget.value)}
|
||||
maxLength={32}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name (optional)"
|
||||
placeholder="e.g. Fertilizer block"
|
||||
value={trainName}
|
||||
onChange={(e) => setTrainName(e.currentTarget.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
label="Build yard"
|
||||
placeholder="Select the yard the train is assembled in"
|
||||
data={(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
}))}
|
||||
value={yardId || null}
|
||||
onChange={(v) => setYardId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<MultiSelect
|
||||
label="Locomotives"
|
||||
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
|
||||
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
|
||||
data={locomotiveOptions}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
disabled={!yardId}
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
}
|
||||
nothingFoundMessage={
|
||||
yardId ? "No available locomotives in this yard" : "Select a yard first"
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
label="Notes (optional)"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={build.isPending} onClick={handleBuild}>
|
||||
Build train
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BuildTrainModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onBuilt: (composition: TrainComposition) => void;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Button, Group, Modal, MultiSelect, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
|
||||
export default function ChangeLocomotivesModal({
|
||||
composition,
|
||||
opened,
|
||||
onClose,
|
||||
}: ChangeLocomotivesModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
|
||||
const yardId = composition?.currentYard?.id ?? "";
|
||||
const availableQuery = useQuery(
|
||||
api.locomotives.listFiltered.queryOptions({
|
||||
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
|
||||
enabled: opened && Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
const setLocomotives = useMutation(api.trainBuilder.setLocomotives.mutationOptions());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && composition) {
|
||||
setLocomotiveIds(composition.locomotives.map((l) => l.id));
|
||||
}
|
||||
}, [opened, composition]);
|
||||
|
||||
// Pickable = available locomotives in the yard + the ones already coupled
|
||||
// to this train (valid to keep even though they are not "loose" anymore).
|
||||
const options = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const rows: Array<{ value: string; label: string }> = [];
|
||||
for (const loco of composition?.locomotives ?? []) {
|
||||
seen.add(loco.id);
|
||||
rows.push({
|
||||
value: loco.id,
|
||||
label: `${loco.code}${loco.name ? ` — ${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T (coupled)`,
|
||||
});
|
||||
}
|
||||
for (const loco of availableQuery.data ?? []) {
|
||||
if (seen.has(loco.id)) continue;
|
||||
rows.push({
|
||||
value: loco.id,
|
||||
label: `${loco.code}${loco.name ? ` — ${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [composition, availableQuery.data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!composition) return;
|
||||
if (locomotiveIds.length < 2) {
|
||||
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setLocomotives.mutateAsync({ id: composition.id, locomotiveIds });
|
||||
toast({ title: "Locomotives updated" });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update locomotives"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Change locomotives</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Only available locomotives standing in{" "}
|
||||
{composition?.currentYard?.label ?? "the train's yard"} can be coupled.
|
||||
The first pick is the lead locomotive.
|
||||
</Text>
|
||||
<MultiSelect
|
||||
label="Locomotives"
|
||||
data={options}
|
||||
value={locomotiveIds}
|
||||
onChange={setLocomotiveIds}
|
||||
searchable
|
||||
error={
|
||||
locomotiveIds.length > 0 && locomotiveIds.length < 2
|
||||
? "Select at least two locomotives"
|
||||
: undefined
|
||||
}
|
||||
nothingFoundMessage="No available locomotives in this yard"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={setLocomotives.isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChangeLocomotivesModalProps {
|
||||
composition: TrainComposition | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainComposition } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Relocate the train to another yard. The consist moves as one unit — every
|
||||
* coupled locomotive and wagon follows, so their current yards always match
|
||||
* the train's.
|
||||
*/
|
||||
export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [yardId, setYardId] = useState("");
|
||||
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
|
||||
const setYard = useMutation(api.trainBuilder.setYard.mutationOptions());
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setYardId(composition?.currentYard?.id ?? "");
|
||||
}, [opened, composition]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!composition || !yardId) return;
|
||||
try {
|
||||
await setYard.mutateAsync({ id: composition.id, currentYardId: yardId });
|
||||
toast({ title: "Train relocated" });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Relocation failed",
|
||||
description: parseError(err, "Could not change the yard"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const memberCount =
|
||||
(composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Change yard — train {composition?.code}</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" icon={<MapPin size={16} />}>
|
||||
The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "}
|
||||
locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "}
|
||||
{composition?.totals.wagonCount ?? 0} wagon
|
||||
{(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles)
|
||||
are relocated so their current yard always matches the train's. Wagon moves are
|
||||
recorded in the movement ledger.
|
||||
</Alert>
|
||||
<Select
|
||||
label="New yard"
|
||||
placeholder="Select yard"
|
||||
data={(yardsQuery.data ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
}))}
|
||||
value={yardId || null}
|
||||
onChange={(v) => setYardId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={setYard.isPending}
|
||||
disabled={!yardId || yardId === composition?.currentYard?.id}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Relocate train
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChangeYardModalProps {
|
||||
composition: TrainComposition | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
DragDropContext,
|
||||
Draggable,
|
||||
Droppable,
|
||||
type DraggableProvided,
|
||||
type DraggableStateSnapshot,
|
||||
type DropResult,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { GripVertical, Trash2 } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
|
||||
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
|
||||
const PortalAwareRow = ({
|
||||
snapshot,
|
||||
children,
|
||||
}: {
|
||||
snapshot: DraggableStateSnapshot;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
if (snapshot.isDragging) {
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The train's ordered wagon consist. Drag to reorder (persisted on drop),
|
||||
* trash to detach a wagon back to the yard.
|
||||
*/
|
||||
export default function ConsistWagonList({
|
||||
wagons,
|
||||
editable,
|
||||
onReorder,
|
||||
onRemove,
|
||||
busy = false,
|
||||
}: ConsistWagonListProps) {
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const from = result.source.index;
|
||||
const to = result.destination.index;
|
||||
if (from === to) return;
|
||||
const next = [...wagons];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved!);
|
||||
onReorder(next.map((w) => w.id));
|
||||
};
|
||||
|
||||
if (!wagons.length) {
|
||||
return (
|
||||
<Text py="lg" ta="center" c="dimmed" size="sm">
|
||||
No wagons in the consist yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
||||
{(dropProvided) => (
|
||||
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{wagons.map((wagon, index) => (
|
||||
<Draggable
|
||||
key={wagon.id}
|
||||
draggableId={wagon.id}
|
||||
index={index}
|
||||
isDragDisabled={!editable || busy}
|
||||
>
|
||||
{(dragProvided, snapshot) => (
|
||||
<WagonRow
|
||||
wagon={wagon}
|
||||
index={index}
|
||||
dragProvided={dragProvided}
|
||||
snapshot={snapshot}
|
||||
editable={editable}
|
||||
busy={busy}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{dropProvided.placeholder}
|
||||
</Stack>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ConsistWagonListProps {
|
||||
wagons: TrainCompositionWagon[];
|
||||
editable: boolean;
|
||||
onReorder: (wagonIds: string[]) => void;
|
||||
onRemove: (wagonId: string) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
function WagonRow({
|
||||
wagon,
|
||||
index,
|
||||
dragProvided,
|
||||
snapshot,
|
||||
editable,
|
||||
busy,
|
||||
onRemove,
|
||||
}: {
|
||||
wagon: TrainCompositionWagon;
|
||||
index: number;
|
||||
dragProvided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
editable: boolean;
|
||||
busy: boolean;
|
||||
onRemove: (wagonId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<PortalAwareRow snapshot={snapshot}>
|
||||
<Group
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
...dragProvided.draggableProps.style,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
|
||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
||||
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{editable ? (
|
||||
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
||||
<GripVertical size={18} />
|
||||
</Box>
|
||||
) : null}
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
{editable ? (
|
||||
<Tooltip label="Detach wagon" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={busy}
|
||||
onClick={() => onRemove(wagon.id)}
|
||||
aria-label={`Detach wagon ${wagon.wagonNumber}`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</PortalAwareRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
|
||||
|
||||
/** Badge color per built-train lifecycle status (Mantine palette keys). */
|
||||
export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
|
||||
switch (status) {
|
||||
case "AVAILABLE":
|
||||
return "edr-green";
|
||||
case "SCHEDULED":
|
||||
return "blue";
|
||||
case "IN_SERVICE":
|
||||
return "teal";
|
||||
case "UNDER_MAINTENANCE":
|
||||
return "yellow";
|
||||
case "OUT_OF_SERVICE":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
|
||||
String(status)
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
|
||||
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
/**
|
||||
* Adjust the built train's consist from a schedule: trim free wagons (their
|
||||
* tare no longer rides — the fix when gross weight beats the pull limit) or
|
||||
* couple extra yard wagons while weight/length headroom remains. Changes are
|
||||
* permanent on the train and logged on the schedule.
|
||||
*/
|
||||
export default function AdjustConsistModal({
|
||||
scheduleId,
|
||||
opened,
|
||||
onClose,
|
||||
}: AdjustConsistModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||
const [addIds, setAddIds] = useState<string[]>([]);
|
||||
|
||||
const consistQuery = useQuery(
|
||||
api.trainScheduling.scheduleConsist.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
|
||||
const data = consistQuery.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds).
|
||||
const projection = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const removed = new Set(removeIds);
|
||||
const keptTare = data.wagons
|
||||
.filter((w) => !removed.has(w.id))
|
||||
.reduce((s, w) => s + tareOf(w), 0);
|
||||
const keptLength = data.wagons
|
||||
.filter((w) => !removed.has(w.id))
|
||||
.reduce((s, w) => s + lengthOf(w), 0);
|
||||
const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
|
||||
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
|
||||
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
|
||||
const gross = round2(data.totals.cargoTons + tare);
|
||||
return {
|
||||
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
|
||||
tare: round2(tare),
|
||||
gross,
|
||||
length: round2(length),
|
||||
grossPct: data.limits.pullCapTons
|
||||
? Math.round((gross / data.limits.pullCapTons) * 100)
|
||||
: null,
|
||||
lengthPct: data.limits.lengthCapMeters
|
||||
? Math.round((length / data.limits.lengthCapMeters) * 100)
|
||||
: null,
|
||||
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
|
||||
overLength:
|
||||
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
|
||||
};
|
||||
}, [data, removeIds, addIds]);
|
||||
|
||||
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||||
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!removeIds.length && !addIds.length) return;
|
||||
try {
|
||||
await adjust.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||||
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
|
||||
removeIds.length && addIds.length ? ", " : ""
|
||||
}${addIds.length ? `${addIds.length} added` : ""}`,
|
||||
});
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Adjustment failed",
|
||||
description: parseError(err, "Could not adjust the consist"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Text fw={600}>
|
||||
Adjust consist{data ? ` — train ${data.train.code}` : ""}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
size={860}
|
||||
centered
|
||||
>
|
||||
{consistQuery.isLoading || !data ? (
|
||||
<Text py="lg" ta="center" c="dimmed" size="sm">
|
||||
{consistQuery.isError
|
||||
? "This schedule has no built train to adjust."
|
||||
: "Loading consist…"}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{!data.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
The consist is frozen once the train is dispatched.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<LimitGauge
|
||||
label="Gross weight"
|
||||
detail={`${data.totals.cargoTons}T cargo + ${projection?.tare}T tare = ${projection?.gross}T of ${data.limits.pullCapTons}T (limit ${data.limits.maxPullWeightTons}T + ${data.limits.overageToleranceTons}T tolerance)`}
|
||||
pct={projection?.grossPct ?? null}
|
||||
over={projection?.overWeight ?? false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<LimitGauge
|
||||
label="Consist length"
|
||||
detail={`${projection?.length}m of ${data.limits.lengthCapMeters}m (limit ${data.limits.maxTrainLengthMeters}m + ${data.limits.overageToleranceMeters}m tolerance)`}
|
||||
pct={projection?.lengthPct ?? null}
|
||||
over={projection?.overLength ?? false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Minus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Trim coupled wagons ({data.totals.wagonCount})
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Only free (unloaded, unpinned) wagons can be detached. Detaching is
|
||||
permanent — the wagon returns to the yard as available.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.wagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={removeIds.includes(wagon.id)}
|
||||
disabled={!data.editable || !wagon.removable}
|
||||
badge={
|
||||
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
|
||||
}
|
||||
onToggle={toggle(setRemoveIds)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Plus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Couple yard wagons ({data.addableWagons.length} available)
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
AVAILABLE wagons standing in the train's yard. Blocked when they push
|
||||
gross weight or length past the locomotive limits incl. tolerance.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.addableWagons.length ? (
|
||||
data.addableWagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={addIds.includes(wagon.id)}
|
||||
disabled={!data.editable}
|
||||
badge={null}
|
||||
onToggle={toggle(setAddIds)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No available wagons in this yard
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{data.adjustments.length ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={4}>
|
||||
<Group gap={6}>
|
||||
<History size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Adjustment history
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={120} type="auto">
|
||||
<Stack gap={2}>
|
||||
{data.adjustments.map((log) => (
|
||||
<Group key={log.id} gap="xs">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={log.action === "ADD" ? "edr-green" : "red"}
|
||||
>
|
||||
{log.action === "ADD" ? "Added" : "Trimmed"}
|
||||
</Badge>
|
||||
<Text size="xs" ff="monospace">
|
||||
{log.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(log.occurredAt).toLocaleString()}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Projected consist: {projection?.wagonCount} wagons
|
||||
</Text>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
loading={adjust.isPending}
|
||||
disabled={
|
||||
!data.editable ||
|
||||
(!removeIds.length && !addIds.length) ||
|
||||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Apply{" "}
|
||||
{removeIds.length ? `−${removeIds.length}` : ""}
|
||||
{removeIds.length && addIds.length ? " / " : ""}
|
||||
{addIds.length ? `+${addIds.length}` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdjustConsistModalProps {
|
||||
scheduleId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function LimitGauge({
|
||||
label,
|
||||
detail,
|
||||
pct,
|
||||
over,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
pct: number | null;
|
||||
over: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c={over ? "red.7" : "edr-green.7"}>
|
||||
{pct != null ? `${pct}%` : "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={Math.min(pct ?? 0, 100)}
|
||||
size="md"
|
||||
radius="xl"
|
||||
color={over ? "red" : (pct ?? 0) > 85 ? "yellow" : "edr-green"}
|
||||
striped={over}
|
||||
animated={over}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{detail}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonRow({
|
||||
wagon,
|
||||
checked,
|
||||
disabled,
|
||||
badge,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: ConsistWagonRef;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
badge: string | null;
|
||||
onToggle: (id: string, checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p={6}
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
opacity: disabled && !badge ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
{badge ? (
|
||||
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -506,12 +506,19 @@ function TrackBed() {
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
locomotives,
|
||||
wagons,
|
||||
freightType,
|
||||
trainNumber,
|
||||
totalLengthMeters,
|
||||
}: {
|
||||
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
|
||||
/** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
|
||||
locomotives?: Array<{
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
maxPullWeightTons?: number | null;
|
||||
}> | null;
|
||||
wagons: DiagramWagonInput[];
|
||||
freightType?: string | null;
|
||||
trainNumber?: string | null;
|
||||
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
|
||||
}) {
|
||||
const { ref, width } = useElementSize();
|
||||
|
||||
const locos = useMemo(
|
||||
() => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
|
||||
[locomotives, locomotive],
|
||||
);
|
||||
|
||||
const normalized = useMemo(
|
||||
() => wagons.map((w) => normalizeWagon(w, freightType)),
|
||||
[wagons, freightType],
|
||||
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
|
||||
// ceiling the allocation engine spends from.
|
||||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||||
const grossWeight = totalWeight + totalTare;
|
||||
// Weakest locomotive caps the set — same rule the allocation engine applies.
|
||||
const pullLimits = locos
|
||||
.map((l) => Number(l.maxPullWeightTons))
|
||||
.filter((v) => Number.isFinite(v) && v > 0);
|
||||
const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
|
||||
return {
|
||||
total: normalized.length,
|
||||
assigned,
|
||||
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
|
||||
totalTare: Math.round(totalTare * 100) / 100,
|
||||
grossWeight: Math.round(grossWeight * 100) / 100,
|
||||
totalCapacity,
|
||||
pullUtil:
|
||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
||||
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
|
||||
: null,
|
||||
pullLimit,
|
||||
pullUtil: pullLimit
|
||||
? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
|
||||
: null,
|
||||
};
|
||||
}, [normalized, locomotive]);
|
||||
}, [normalized, locos]);
|
||||
|
||||
// cars-per-row from measured width; locomotive counts as one car
|
||||
// cars-per-row from measured width; each locomotive counts as one car
|
||||
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
|
||||
const cars = useMemo(
|
||||
() => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
|
||||
[normalized],
|
||||
() => [
|
||||
...locos.map((l) => ({ kind: "loco" as const, l })),
|
||||
...normalized.map((w) => ({ kind: "wagon" as const, w })),
|
||||
],
|
||||
[locos, normalized],
|
||||
);
|
||||
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
|
||||
|
||||
if (!locomotive && !wagons.length) return null;
|
||||
if (!locos.length && !wagons.length) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -658,8 +678,8 @@ export function TrainCompositionDiagram({
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
Locomotive load ·{" "}
|
||||
{stats.totalTare > 0
|
||||
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
|
||||
? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${stats.pullLimit}T`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
||||
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
|
||||
<Group key={carIndex} gap={0} wrap="nowrap" style={{ flexDirection: reversed ? "row-reverse" : "row" }}>
|
||||
{carIndex > 0 ? <Coupler /> : null}
|
||||
{car.kind === "loco" ? (
|
||||
locomotive ? (
|
||||
<LocomotiveCar
|
||||
code={locomotive.code ?? "LOCO"}
|
||||
name={locomotive.name}
|
||||
maxPullWeightTons={locomotive.maxPullWeightTons}
|
||||
/>
|
||||
) : null
|
||||
<LocomotiveCar
|
||||
code={car.l.code ?? "LOCO"}
|
||||
name={car.l.name}
|
||||
maxPullWeightTons={car.l.maxPullWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<WagonCar wagon={car.w} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
History,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Warehouse,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
WagonMovementRecord,
|
||||
WagonTransferRequest,
|
||||
} from "@/services/wagon.service";
|
||||
|
||||
export interface WagonTransferRequestsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PENDING = Freight.WagonTransferRequestStatus.Pending;
|
||||
const AVAILABLE = Freight.WagonStatus.Available;
|
||||
|
||||
const yardLabel = (y?: { label?: string; code?: string } | null) =>
|
||||
y?.label || y?.code || "—";
|
||||
const typeLabel = (t?: { code?: string; name?: string } | null) =>
|
||||
t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
|
||||
|
||||
/** Requester → destination + type + count summary line, reused in list and picker. */
|
||||
const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.fromYard)}
|
||||
</Text>
|
||||
<ArrowRight size={14} style={{ flexShrink: 0 }} />
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{yardLabel(r.toYard)}
|
||||
</Text>
|
||||
<Badge variant="light" color="grape" radius="sm">
|
||||
{r.quantity}× {typeLabel(r.wagonType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "gray",
|
||||
FULFILLED: "teal",
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-user transfer history. A staffer sees their OWN activity — the requests
|
||||
* they filed or fulfilled, and the individual wagons they moved. Holders of
|
||||
* `transfer_history_all` get an "All staff" toggle that widens the view; the
|
||||
* backend enforces the scope regardless of the toggle.
|
||||
*/
|
||||
function HistoryPanel({ opened }: { opened: boolean }) {
|
||||
const { user } = useAuth();
|
||||
const canSeeAll = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.wagons.transferHistoryAll,
|
||||
);
|
||||
const myId = (user as { id?: string } | null | undefined)?.id;
|
||||
const [allStaff, setAllStaff] = useState(false);
|
||||
const scopeAll = canSeeAll && allStaff;
|
||||
|
||||
const mine = useQuery({
|
||||
...api.wagonTransferRequests.history.queryOptions(),
|
||||
enabled: opened && !scopeAll,
|
||||
});
|
||||
const all = useQuery({
|
||||
...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
|
||||
enabled: opened && scopeAll,
|
||||
});
|
||||
const source = scopeAll ? all : mine;
|
||||
const requests = source.data?.requests ?? [];
|
||||
const movements: WagonMovementRecord[] = source.data?.movements ?? [];
|
||||
|
||||
const roleBadge = (r: WagonTransferRequest) => {
|
||||
if (myId && r.fulfilledByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
fulfilled
|
||||
</Badge>
|
||||
);
|
||||
if (myId && r.requestedByUserId === myId)
|
||||
return (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
requested
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{canSeeAll ? (
|
||||
<Group justify="flex-end">
|
||||
<Switch
|
||||
checked={allStaff}
|
||||
onChange={(e) => setAllStaff(e.currentTarget.checked)}
|
||||
label="All staff"
|
||||
color="edr-green"
|
||||
/>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{source.isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Requests{scopeAll ? "" : " you touched"}
|
||||
</Text>
|
||||
{requests.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No requests yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<RequestSummary r={r} />
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{roleBadge(r)}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status] ?? "gray"}
|
||||
>
|
||||
{r.status.toLowerCase()}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text fw={700} size="sm" mb={8}>
|
||||
Wagons moved
|
||||
</Text>
|
||||
{movements.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon moves yet.
|
||||
</Text>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={260}>
|
||||
<Stack gap={6}>
|
||||
{movements.map((m) => (
|
||||
<Card key={m.id} withBorder radius="md" padding="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{m.wagon?.wagonNumber ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{yardLabel(m.fromYard)} → {yardLabel(m.toYard)}
|
||||
</Text>
|
||||
{m.transferRequestId ? (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
from request
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{fmtDateTime(m.occurredAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
|
||||
* one to hand-pick exactly the requested number of wagons from the source yard
|
||||
* (of the requested type) and execute the move, or cancel the request.
|
||||
* A second tab shows per-user transfer history.
|
||||
*/
|
||||
const WagonTransferRequestsModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
}: WagonTransferRequestsModalProps) => {
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState<string | null>("queue");
|
||||
const [active, setActive] = useState<WagonTransferRequest | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: requests = [], isLoading } = useQuery({
|
||||
...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
// Available wagons of the requested type sitting in the request's source yard.
|
||||
const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
|
||||
...api.wagons.list.queryOptions({
|
||||
input: {
|
||||
filters: active
|
||||
? {
|
||||
currentYardId: active.fromYardId,
|
||||
wagonTypeId: active.wagonTypeId,
|
||||
status: AVAILABLE,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
}),
|
||||
enabled: opened && Boolean(active),
|
||||
});
|
||||
|
||||
const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
|
||||
const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
|
||||
|
||||
const showError = (err: unknown, fallback: string) => {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? fallback;
|
||||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||||
};
|
||||
|
||||
const openPicker = (r: WagonTransferRequest) => {
|
||||
setActive(r);
|
||||
setPicked(new Set());
|
||||
};
|
||||
const closePicker = () => {
|
||||
setActive(null);
|
||||
setPicked(new Set());
|
||||
};
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (active && next.size >= active.quantity) return prev; // cap at quantity
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const need = active?.quantity ?? 0;
|
||||
const shortfall = active ? Math.max(0, need - wagons.length) : 0;
|
||||
|
||||
const handleFulfill = async () => {
|
||||
if (!active || picked.size !== need) return;
|
||||
try {
|
||||
await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
|
||||
toast({
|
||||
title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel(
|
||||
active.toYard,
|
||||
)}`,
|
||||
});
|
||||
closePicker();
|
||||
} catch (err) {
|
||||
showError(err, "Transfer failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (r: WagonTransferRequest) => {
|
||||
try {
|
||||
await cancel.mutateAsync({ id: r.id });
|
||||
toast({ title: "Request cancelled" });
|
||||
} catch (err) {
|
||||
showError(err, "Cancel failed");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedWagons = useMemo(
|
||||
() => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
|
||||
[wagons],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="min(760px, 96vw)"
|
||||
radius="lg"
|
||||
centered
|
||||
overlayProps={{ blur: 2 }}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||
<Inbox size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Wagon Transfer Requests</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{active
|
||||
? "Pick the wagons to move, then transfer"
|
||||
: "OCC queue — pick wagons and complete each move"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Tabs value={tab} onChange={setTab} keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="queue" leftSection={<Inbox size={14} />}>
|
||||
Queue
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="queue">
|
||||
{!active ? (
|
||||
// ---- Pending queue ----
|
||||
isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : requests.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="xl">
|
||||
<Stack align="center" gap={6}>
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>No pending transfer requests</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
When staff request a yard-to-yard wagon move, it appears here for
|
||||
you to fulfil.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{requests.map((r) => (
|
||||
<Card key={r.id} withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<RequestSummary r={r} />
|
||||
{r.note ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
“{r.note}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<X size={14} />}
|
||||
loading={cancel.isPending}
|
||||
onClick={() => handleCancel(r)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={14} />}
|
||||
onClick={() => openPicker(r)}
|
||||
>
|
||||
Fulfil
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
) : (
|
||||
// ---- Wagon picker for the active request ----
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="var(--mantine-color-gray-0)">
|
||||
<RequestSummary r={active} />
|
||||
</Card>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Select wagons in {yardLabel(active.fromYard)}
|
||||
</Text>
|
||||
<Badge
|
||||
color={picked.size === need ? "teal" : "gray"}
|
||||
variant={picked.size === need ? "filled" : "light"}
|
||||
>
|
||||
{picked.size} / {need} selected
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{wagonsLoading ? (
|
||||
<Group justify="center" p="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : sortedWagons.length === 0 ? (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group gap={8} justify="center">
|
||||
<Warehouse size={16} />
|
||||
<Text size="sm" c="dimmed">
|
||||
No available wagons of this type in {yardLabel(active.fromYard)}.
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{shortfall > 0 ? (
|
||||
<Text size="xs" c="orange.7">
|
||||
Only {sortedWagons.length} available — {shortfall} short of the{" "}
|
||||
{need} requested.
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Stack gap={6}>
|
||||
{sortedWagons.map((w) => {
|
||||
const checked = picked.has(w.id);
|
||||
const atCap = !checked && picked.size >= need;
|
||||
return (
|
||||
<Card
|
||||
key={w.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="xs"
|
||||
onClick={() => !atCap && toggle(w.id)}
|
||||
style={{
|
||||
cursor: atCap ? "not-allowed" : "pointer",
|
||||
borderColor: checked
|
||||
? "var(--mantine-color-edr-green-4)"
|
||||
: undefined,
|
||||
opacity: atCap ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{/* Visual only — the Card's onClick owns the toggle so a
|
||||
click on the box doesn't fire both and cancel out. */}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
readOnly
|
||||
disabled={atCap}
|
||||
color="edr-green"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ChevronLeft size={16} />}
|
||||
onClick={closePicker}
|
||||
>
|
||||
Back to queue
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
loading={fulfill.isPending}
|
||||
disabled={picked.size !== need}
|
||||
onClick={handleFulfill}
|
||||
>
|
||||
Transfer {need} wagon{need === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
<HistoryPanel opened={opened} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WagonTransferRequestsModal;
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Select,
|
||||
Slider,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
@@ -126,11 +125,12 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
|
||||
const [transferYardId, setTransferYardId] = useState<string | null>(null);
|
||||
const [transferQty, setTransferQty] = useState(0);
|
||||
const [freeAfterMove, setFreeAfterMove] = useState(false);
|
||||
const [toAssignedQty, setToAssignedQty] = useState(0);
|
||||
const [toAvailableQty, setToAvailableQty] = useState(0);
|
||||
|
||||
const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions());
|
||||
const createRequest = useMutation(
|
||||
api.wagonTransferRequests.create.mutationOptions(),
|
||||
);
|
||||
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
|
||||
|
||||
const yardName = useMemo(() => {
|
||||
@@ -187,13 +187,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
() => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED),
|
||||
[matching],
|
||||
);
|
||||
// Available first, then assigned, then the rest — a partial move relocates
|
||||
// idle wagons before touching assigned ones.
|
||||
const transferPool = useMemo(
|
||||
() => [...availableWagons, ...assignedWagons, ...otherWagons],
|
||||
[availableWagons, assignedWagons, otherWagons],
|
||||
);
|
||||
|
||||
const total = matching.length;
|
||||
const availableCount = availableWagons.length;
|
||||
const assignedCount = assignedWagons.length;
|
||||
@@ -214,7 +207,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
useEffect(() => {
|
||||
setTransferYardId(null);
|
||||
setTransferQty(0);
|
||||
setFreeAfterMove(false);
|
||||
setToAssignedQty(0);
|
||||
setToAvailableQty(0);
|
||||
}, [yardId, typeId]);
|
||||
@@ -238,25 +230,27 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
toast({ title: fallback, description: String(message), variant: "destructive" });
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!transferYardId || transferQty < 1) return;
|
||||
const ids = transferPool.slice(0, transferQty).map((w) => w.id);
|
||||
if (!ids.length) return;
|
||||
// Request-only: the requester specifies count + destination; OCC later picks
|
||||
// the physical wagons and executes the move. No wagons are moved here.
|
||||
const handleRequest = async () => {
|
||||
if (!yardId || !typeId || !transferYardId || transferQty < 1) return;
|
||||
try {
|
||||
const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId });
|
||||
if (freeAfterMove) {
|
||||
await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE });
|
||||
}
|
||||
await createRequest.mutateAsync({
|
||||
fromYardId: yardId,
|
||||
toYardId: transferYardId,
|
||||
wagonTypeId: typeId,
|
||||
quantity: transferQty,
|
||||
});
|
||||
toast({
|
||||
title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${
|
||||
freeAfterMove ? " · set Available" : ""
|
||||
}`,
|
||||
title: `Requested ${transferQty} ${typeInfo.code(typeId)} wagon(s) · ${yardName(
|
||||
yardId,
|
||||
)} → ${yardName(transferYardId)}`,
|
||||
description: "OCC will pick the wagons and complete the move.",
|
||||
});
|
||||
setTransferQty(0);
|
||||
setTransferYardId(null);
|
||||
setFreeAfterMove(false);
|
||||
} catch (err) {
|
||||
showError(err, "Transfer failed");
|
||||
showError(err, "Request failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,7 +273,7 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
}
|
||||
};
|
||||
|
||||
const busy = transfer.isPending || setStatus.isPending;
|
||||
const busy = createRequest.isPending || setStatus.isPending;
|
||||
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
|
||||
|
||||
return (
|
||||
@@ -402,12 +396,15 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
{/* Transfer */}
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Card withBorder radius="md" h="100%" padding="lg">
|
||||
<Group gap="xs" mb="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="grape" radius="md" size="md">
|
||||
<ArrowRightLeft size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>Move to another yard</Text>
|
||||
<Text fw={700}>Request transfer to another yard</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Sends a request to OCC — they pick the wagons and complete the move.
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
@@ -424,12 +421,6 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
<Switch
|
||||
checked={freeAfterMove}
|
||||
onChange={(e) => setFreeAfterMove(e.currentTarget.checked)}
|
||||
label="Set moved wagons to Available"
|
||||
color="teal"
|
||||
/>
|
||||
{transferYardId && transferQty > 0 ? (
|
||||
<Card bg="var(--mantine-color-gray-0)" radius="md" padding="sm" withBorder>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
@@ -453,12 +444,13 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
) : null}
|
||||
<Button
|
||||
leftSection={<ArrowRightLeft size={16} />}
|
||||
onClick={handleTransfer}
|
||||
loading={transfer.isPending}
|
||||
onClick={handleRequest}
|
||||
loading={createRequest.isPending}
|
||||
disabled={busy || !transferYardId || transferQty < 1}
|
||||
color="edr-green"
|
||||
>
|
||||
Move {transferQty > 0 ? `${transferQty} ` : ""}wagon{transferQty === 1 ? "" : "s"}
|
||||
Request {transferQty > 0 ? `${transferQty} ` : ""}wagon
|
||||
{transferQty === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
|
||||
|
||||
import { useAccrualDashboard } from '@/hooks/useWarehouses';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
|
||||
|
||||
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
|
||||
CHARGING: { color: 'red', label: 'Charging' },
|
||||
WARNING: { color: 'orange', label: 'Free days ending' },
|
||||
OK: { color: 'teal', label: 'Within free days' },
|
||||
};
|
||||
|
||||
function money(amount: number, currency: string): string {
|
||||
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
}
|
||||
|
||||
function freeDaysLabel(row: AccrualDashboardRow): string {
|
||||
if (row.charging) return 'charging now';
|
||||
if (row.freeDaysLeft == null) return '—';
|
||||
return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
|
||||
* sorted so items already charging (or about to) surface first. Read-only.
|
||||
*/
|
||||
export function AccrualDashboard() {
|
||||
const { data: rows = [], isLoading } = useAccrualDashboard();
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const refresh = () =>
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
|
||||
|
||||
const ack = useMutation({
|
||||
mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
|
||||
warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
|
||||
onSuccess: (_r, v) => {
|
||||
toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
|
||||
});
|
||||
const unack = useMutation({
|
||||
mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Acknowledgement removed' });
|
||||
void refresh();
|
||||
},
|
||||
onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
|
||||
});
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const currency = rows[0]?.currency ?? 'USD';
|
||||
return {
|
||||
currency,
|
||||
charging: rows.filter((r) => r.alert === 'CHARGING').length,
|
||||
atRisk: rows.filter((r) => r.alert === 'WARNING').length,
|
||||
totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
|
||||
<StatCard
|
||||
icon={<DollarSign size={18} />}
|
||||
label="Accruing now"
|
||||
value={money(summary.totalAccruing, summary.currency)}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="Charging"
|
||||
value={summary.charging}
|
||||
color={summary.charging > 0 ? 'red' : 'gray'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock size={18} />}
|
||||
label="Free days ending (≤2d)"
|
||||
value={summary.atRisk}
|
||||
color={summary.atRisk > 0 ? 'orange' : 'gray'}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
{rows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No in-warehouse items are accruing fees.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Accrued</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Alert</Table.Th>
|
||||
<Table.Th ta="right" />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const meta = ALERT_META[row.alert];
|
||||
const busy = ack.isPending || unack.isPending;
|
||||
return (
|
||||
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{row.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
|
||||
{money(row.accruedAmount, row.currency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={row.charging ? 'red' : undefined}>
|
||||
{freeDaysLabel(row)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{row.acknowledged ? (
|
||||
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
|
||||
Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Menu shadow="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" loading={busy} aria-label="Accrual actions">
|
||||
<MoreVertical size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{row.acknowledged ? (
|
||||
<Menu.Item
|
||||
leftSection={<Bell size={14} />}
|
||||
onClick={() => unack.mutate(row.inventoryId)}
|
||||
>
|
||||
Un-acknowledge
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<Check size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId })}
|
||||
>
|
||||
Mark reviewed
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
|
||||
>
|
||||
Snooze 3 days
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<BellOff size={14} />}
|
||||
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
|
||||
>
|
||||
Snooze 7 days
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color={color} variant="light" size={40} radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={800} fz={20} lh={1.1} truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -80,14 +80,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||
[items, tab],
|
||||
);
|
||||
// Only arrived, not-yet-departed trucks can be loaded.
|
||||
// Any assigned, not-yet-departed truck can be loaded here — loading a truck at
|
||||
// the warehouse auto-marks it arrived on the backend, so assigned-but-not-yet-
|
||||
// arrived trucks are selectable too (labelled "assigned" until they arrive).
|
||||
const truckOptions = trucks
|
||||
.filter(
|
||||
(t) =>
|
||||
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||
!(t as { departedAt?: string }).departedAt,
|
||||
)
|
||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
||||
.map((t) => ({
|
||||
value: t.id,
|
||||
label: `${t.plateNumber} · ${t.driverName}${
|
||||
(t as { arrivedAt?: string }).arrivedAt ? '' : ' (assigned)'
|
||||
}`,
|
||||
}));
|
||||
|
||||
const loadMutation = useMutation({
|
||||
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
|
||||
@@ -125,7 +128,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
|
||||
const is40 = (n: string) =>
|
||||
(items.find((i) => i.containerNumber === n)?.containerSize ?? '').includes('40');
|
||||
|
||||
// A truck carries at most 2 containers, and a 40ft fills the truck (max 1).
|
||||
const toggle = (n: string) =>
|
||||
setSelected((s) => {
|
||||
if (s.includes(n)) return s.filter((x) => x !== n);
|
||||
const next = [...s, n];
|
||||
if (next.length > 2) {
|
||||
toast({ variant: 'destructive', title: 'A truck carries at most 2 containers' });
|
||||
return s;
|
||||
}
|
||||
if (next.length > 1 && next.some(is40)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'A 40ft container fills the truck',
|
||||
description: 'Load only one 40ft container per truck.',
|
||||
});
|
||||
return s;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -162,6 +186,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
<Table.Tr>
|
||||
<Table.Th />
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
@@ -182,6 +207,15 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
{i.containerSize ? (
|
||||
<Badge variant="light" color={i.containerSize.includes('40') ? 'grape' : 'blue'}>
|
||||
{i.containerSize}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text c="dimmed" size="sm">bulk</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
||||
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
|
||||
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
||||
@@ -221,11 +255,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
|
||||
{/* Multiselect → load onto a truck */}
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Text size="sm" c="dimmed">{selected.length} selected</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{selected.length} selected
|
||||
{(() => {
|
||||
const pending = items.filter((i) => !i.truckAssignmentId).length;
|
||||
return pending > 0 ? ` · ${pending} container${pending === 1 ? '' : 's'} pending assignment` : '';
|
||||
})()}
|
||||
</Text>
|
||||
<Group gap="sm" align="flex-end">
|
||||
<Select
|
||||
label="Load onto truck"
|
||||
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
|
||||
placeholder={truckOptions.length ? 'Select truck' : 'No truck assigned'}
|
||||
data={truckOptions}
|
||||
value={truckId}
|
||||
onChange={setTruckId}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import { openPdfBlob, saveBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
// One-click bundle: download every available document for the item (GRN +
|
||||
// gate clearance / release order + handover). Best-effort — docs that aren't
|
||||
// generatable yet for this item are skipped.
|
||||
const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const ref = item.booking?.reference ?? item.bookingId ?? item.id;
|
||||
const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
|
||||
{ name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
|
||||
{ name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
|
||||
{ name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
|
||||
];
|
||||
let saved = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const response = await job.fn();
|
||||
saveBlob(response.data, job.name);
|
||||
saved += 1;
|
||||
} catch {
|
||||
// Document not available for this item yet — skip it.
|
||||
}
|
||||
}
|
||||
setBusyId(null);
|
||||
if (saved === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'No documents available',
|
||||
description: 'This item has no GRN, gate clearance or handover document yet.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
|
||||
description: `Bundle for ${ref} (available documents only).`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||
const reference = item.booking?.reference;
|
||||
if (!reference) {
|
||||
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onHandoverDocument={openHandoverDocument}
|
||||
onDownloadBundle={downloadDocumentBundle}
|
||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
|
||||
@@ -121,6 +121,14 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
// EDR last-mile trucks assigned to this booking — surfaced even when the modal
|
||||
// is opened from the warehouse flow (which passes no truckPrefill prop), so an
|
||||
// assigned EDR truck no longer shows as "not assigned yet".
|
||||
const { data: lastMileTrucks = [] } = useQuery({
|
||||
queryKey: ['release-last-mile-trucks', bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||||
// total cargo weight of the containers selected as loaded on it.
|
||||
const { data: containerWeights = [] } = useQuery({
|
||||
@@ -176,6 +184,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||
|
||||
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
|
||||
// truck query resolves, auto-fill the first assigned EDR truck — without
|
||||
// overwriting anything the operator typed or the locked exit-step values.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPrefill || isExitStep) return;
|
||||
const first = lastMileTrucks[0];
|
||||
if (!first) return;
|
||||
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
|
||||
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
|
||||
setDriverName((p) => p || first.driverName || '');
|
||||
setDriverLicense((p) => p || first.driverLicense || '');
|
||||
setDriverPhone((p) => p || first.driverPhone || '');
|
||||
setTruckType((p) => p || first.truckType || '');
|
||||
setContainerNumbers((prev) =>
|
||||
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
|
||||
);
|
||||
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
|
||||
|
||||
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||
const assignedTruckOptions = [
|
||||
@@ -199,6 +225,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
})),
|
||||
...lastMileTrucks
|
||||
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
||||
.map((t) => ({
|
||||
value: (t.truckPlateNumber || t.vehicleId) as string,
|
||||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||||
trailerPlate: t.trailerPlateNumber ?? '',
|
||||
driverName: t.driverName ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
})),
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onDownloadBundle?: (item: WarehouseInventoryItem) => void;
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onHandoverDocument,
|
||||
onDownloadBundle,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadBundle && item.grnNumber && (
|
||||
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
|
||||
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
|
||||
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
|
||||
|
||||
/**
|
||||
* At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
|
||||
* on-site, items aging). Drop-in for any warehouse ops page header.
|
||||
*/
|
||||
export function WarehouseOpsKpiStrip() {
|
||||
const { data, isLoading } = useWarehouseOpsStats();
|
||||
|
||||
return (
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Received today",
|
||||
value: data?.receivedToday ?? 0,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
value: data?.pendingInspection ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Trucks on-site",
|
||||
value: data?.trucksOnSite ?? 0,
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
label: "Items aging (>7d)",
|
||||
value: data?.itemsAging ?? 0,
|
||||
icon: AlertTriangle,
|
||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||
hint: "In warehouse over 7 days",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -30,3 +30,5 @@ export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||
export { InspectionReportModal } from './InspectionReportModal';
|
||||
export { FeePreviewModal } from './FeePreviewModal';
|
||||
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
|
||||
export { AccrualDashboard } from './AccrualDashboard';
|
||||
|
||||
@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
|
||||
URL.revokeObjectURL(url);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Force a browser download of a blob under the given filename (no preview tab). */
|
||||
export function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// Delay revoke so the download has time to start (esp. for rapid multi-saves).
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ export const QUERY_KEYS = {
|
||||
] as const,
|
||||
locomotives: (routeId?: string) =>
|
||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||
availableTrains: (routeId?: string) =>
|
||||
["train-scheduling", "available-trains", routeId ?? "all"] as const,
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
schedules: (filters?: unknown) =>
|
||||
["train-scheduling", "schedules", filters ?? {}] as const,
|
||||
@@ -122,6 +124,12 @@ export const QUERY_KEYS = {
|
||||
["fleet", "list", resource] as const,
|
||||
},
|
||||
|
||||
TRAIN_BUILDER: {
|
||||
ROOT: ["train-builder"] as const,
|
||||
list: (filters?: unknown) => ["train-builder", "list", filters ?? {}] as const,
|
||||
composition: (id: string) => ["train-builder", "composition", id] as const,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
ROOT: ["vehicles"] as const,
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
|
||||
@@ -486,6 +486,7 @@ export const URL_CONSTANTS = {
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||
yardId
|
||||
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||
@@ -557,6 +558,9 @@ export const URL_CONSTANTS = {
|
||||
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
|
||||
FEE_PREVIEW: (inventoryId: string) =>
|
||||
`/warehouse-inventory/${inventoryId}/fee-preview`,
|
||||
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
|
||||
ACCRUAL_ACK: (inventoryId: string) =>
|
||||
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVOICES: {
|
||||
|
||||
@@ -144,6 +144,22 @@ export function useZoneOccupancy(yardId?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** At-a-glance warehouse ops counters for the KPI strip. */
|
||||
export function useWarehouseOpsStats() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'ops-stats'],
|
||||
queryFn: () => warehouseService.opsStats().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** Live per-item fee accrual (storage/demurrage) with alerts. */
|
||||
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
|
||||
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
|
||||
create: "edr_freight_app:wagons:create",
|
||||
update: "edr_freight_app:wagons:update",
|
||||
delete: "edr_freight_app:wagons:delete",
|
||||
transferRequest: "edr_freight_app:wagons:transfer_request",
|
||||
transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
|
||||
transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
|
||||
},
|
||||
trains: {
|
||||
view: "edr_freight_app:trains:view",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -324,6 +325,21 @@ export default function NewBookingPage() {
|
||||
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
|
||||
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
|
||||
|
||||
// 20ft containers ride two per wagon, so a booking must hold an even number
|
||||
// of them — odd counts would leave half a wagon waiting on a co-loader
|
||||
// (cross-booking consolidation is disabled for now).
|
||||
const twentyFtCount = useMemo(() => {
|
||||
const sizeById = new Map<string, string>();
|
||||
for (const group of refData?.containers ?? []) {
|
||||
for (const type of group.types) sizeById.set(type.id, group.size);
|
||||
}
|
||||
return lines.reduce((sum, l) => {
|
||||
const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : "";
|
||||
return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
|
||||
}, 0);
|
||||
}, [refData?.containers, lines]);
|
||||
const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
|
||||
|
||||
// ---- validation ----
|
||||
const lineValid = (l: ContainerLine) =>
|
||||
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
|
||||
@@ -344,7 +360,7 @@ export default function NewBookingPage() {
|
||||
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||
(freightType === "BULK"
|
||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||
: allLinesValid);
|
||||
: allLinesValid && !hasOdd20ft);
|
||||
|
||||
const updateLine = (key: string, patch: Partial<ContainerLine>) =>
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||||
@@ -842,6 +858,20 @@ export default function NewBookingPage() {
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{hasOdd20ft ? (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />} radius="md" mt="md">
|
||||
<Text size="sm" fw={600}>
|
||||
Odd number of 20ft containers ({twentyFtCount})
|
||||
</Text>
|
||||
<Text size="xs" mt={4}>
|
||||
20ft containers travel two per wagon, so they must be booked in
|
||||
even numbers. Add one more 20ft container or remove one — e.g.
|
||||
book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "}
|
||||
{twentyFtCount}.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap="sm" mt="lg">
|
||||
<Button
|
||||
size="md"
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -75,6 +82,8 @@ interface ClearanceRow {
|
||||
freightType: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
/** Full ordered corridor across the contract's route legs (origin → … → destination). */
|
||||
routeStops: string[];
|
||||
contractKind: string;
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
@@ -93,6 +102,25 @@ function yardLabel(
|
||||
return yard.label ?? yard.name ?? yard.code ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain the contract's ordered route legs into one corridor of stops —
|
||||
* origin of the first leg, then each leg's destination (Djibouti → Adama →
|
||||
* Dire Dawa). A leg whose origin differs from the previous destination inserts
|
||||
* that stop too, so gapped route lists stay readable.
|
||||
*/
|
||||
function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
|
||||
const stops: string[] = [];
|
||||
for (const r of routes) {
|
||||
const origin = yardLabel(r.originYard);
|
||||
const destination = yardLabel(r.destinationYard);
|
||||
if (stops.length === 0 || stops[stops.length - 1] !== origin) {
|
||||
stops.push(origin);
|
||||
}
|
||||
stops.push(destination);
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
destinationLabel: yardLabel(last?.destinationYard),
|
||||
routeStops: contractRouteStops(routes),
|
||||
contractKind: contract.contractKind,
|
||||
serviceTypeName: contract.serviceType?.serviceName ?? "—",
|
||||
customs:
|
||||
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.originLabel}
|
||||
</Text>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={120}>
|
||||
{r.destinationLabel}
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
{(r.routeStops.length >= 2
|
||||
? r.routeStops
|
||||
: [r.originLabel, r.destinationLabel]
|
||||
).map((stop, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
<Text size="sm" fw={500}>
|
||||
{stop}
|
||||
</Text>
|
||||
</Fragment>
|
||||
))}
|
||||
</Group>
|
||||
<Group gap={8} align="center">
|
||||
<DirectionIcon direction={r.tradeDirection} />
|
||||
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onRebook={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||
}
|
||||
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
onRebook,
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
|
||||
canCreateBooking: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
onRebook: (row: ShipmentBookingRow) => void;
|
||||
onViewContract: (contractId: string) => void;
|
||||
}) {
|
||||
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
|
||||
!r.bookingCreated &&
|
||||
r.status === "CLEARANCE_READY";
|
||||
|
||||
// A customs shipment whose booking lost its slot — GL rebooks it (customer
|
||||
// can't self-rebook a customs booking). Copies the expired booking's cargo.
|
||||
const isRebookable = (r: ShipmentBookingRow) =>
|
||||
canCreateBooking &&
|
||||
Boolean(r.contractId) &&
|
||||
r.customs &&
|
||||
r.status === "EXPIRED";
|
||||
|
||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const bookable = isBookable(r);
|
||||
const rebookable = isRebookable(r);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
@@ -896,6 +950,17 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="grape"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
) : null}
|
||||
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -919,6 +984,14 @@ function ShipmentBookingsTable({
|
||||
Create booking
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{rebookable ? (
|
||||
<Menu.Item
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={() => onRebook(r)}
|
||||
>
|
||||
Rebook (GL)
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{r.contractId ? (
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Plus, Warehouse } from "lucide-react";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -15,6 +15,7 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -48,6 +49,7 @@ const FleetResourcePage = () => {
|
||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||
const [transferRequestsOpen, setTransferRequestsOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
@@ -373,15 +375,26 @@ const FleetResourcePage = () => {
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
{slug === "wagons" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Inbox size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setTransferRequestsOpen(true)}
|
||||
>
|
||||
Transfer Requests
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
@@ -605,6 +618,13 @@ const FleetResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonTransferRequestsModal
|
||||
opened={transferRequestsOpen}
|
||||
onClose={() => setTransferRequestsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
|
||||
@@ -666,8 +666,12 @@ const LastMilePage = () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Assign failed", variant: "destructive" });
|
||||
onError: (e: unknown) => {
|
||||
// Surface the backend reason (e.g. "Truck … has no assigned driver …").
|
||||
const raw = (e as { response?: { data?: { message?: string | string[] } } })?.response?.data
|
||||
?.message;
|
||||
const description = Array.isArray(raw) ? raw.join(", ") : raw;
|
||||
toast({ title: "Assign failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ interface CargoNode extends RuleEngineRecord {
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||
unitOfMeasure?: string | null;
|
||||
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
|
||||
wagonTypeId?: string | null;
|
||||
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
||||
wagonTypes?: { id: string; code?: string; name?: string }[];
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
@@ -82,16 +82,19 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// Wagon type that carries this (bulk) commodity — drives train-scheduling
|
||||
// wagon resolution. Optional: leave "None" for grouping categories and
|
||||
// container/legacy cargo; set it on scheduled bulk commodities.
|
||||
// Wagon types that can carry this (bulk) commodity — drive train-scheduling
|
||||
// wagon resolution (the plan uses whichever type the train/yard has).
|
||||
// Optional: leave empty for grouping categories and container/legacy cargo;
|
||||
// set them on scheduled bulk commodities.
|
||||
// Options injected at render from useWagonTypeOptions.
|
||||
name: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
type: "select",
|
||||
name: "wagonTypeIds",
|
||||
label: "Wagon types",
|
||||
type: "multiselect",
|
||||
optional: true,
|
||||
placeholder: "Select wagon type (bulk cargo)",
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
|
||||
placeholder: "Select wagon types (bulk cargo)",
|
||||
options: [],
|
||||
getInitialValue: (record) =>
|
||||
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
|
||||
},
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
@@ -119,19 +122,13 @@ const CargoTypesPage = () => {
|
||||
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
|
||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeId"
|
||||
? {
|
||||
...field,
|
||||
options: [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
...(wagonTypeOptions ?? []),
|
||||
],
|
||||
}
|
||||
field.name === "wagonTypeIds"
|
||||
? { ...field, options: wagonTypeOptions ?? [] }
|
||||
: field,
|
||||
),
|
||||
[wagonTypeOptions],
|
||||
|
||||
@@ -153,7 +153,9 @@ const RuleEngineResourcePage = () => {
|
||||
config?.formFields.some((f) => f.name === "rateId"),
|
||||
);
|
||||
const usesWagonTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "wagonTypeId"),
|
||||
config?.formFields.some(
|
||||
(f) => f.name === "wagonTypeId" || f.name === "wagonTypeIds",
|
||||
),
|
||||
);
|
||||
|
||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||
@@ -206,6 +208,13 @@ const RuleEngineResourcePage = () => {
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "wagonTypeIds") {
|
||||
return {
|
||||
...field,
|
||||
type: "multiselect" as const,
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user