From ce3ef15e7c27ba3cb9c5741676f654eac27c4212 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 09:52:28 +0000 Subject: [PATCH 1/4] feat(yards): record which yards can load/unload cargo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercity cargo is loaded at its origin yard and unloaded at its destination, but only some yards have the equipment. EDR's facilities are Indode, Sebeta, Modjo, Adama and Dire Dawa — and the set grows, so it has to be data. - yards.has_facility marks a yard as a load/unload point; the new yard_facilities record says what it can do. Only Indode stores cargo (has_warehouse), so only it accrues storage/demurrage — the rest just move cargo on and off the train. - facility_handling_events records each load/unload and carries its GRN. warehouse_inventory cannot: its warehouse/yard/zone are NOT NULL, so a facility without a warehouse could never have a row. inventory_id links to the storage record when there is one. - YardFacilitiesService.facilityForYard is the single resolver the handling flows share, so they cannot drift on what a facility is. - The seeder flags EXISTING yards and creates none. The codes are historical and do not read like the facility names — Indode is KALITY ("Gelan Multi Purpose Port (Indode)") and Sebeta is LEGACY_DEST — so it maps by code. Creating fresh INDODE/SEBETA yards would have split data that routes and bookings already reference. Negad is deliberately absent: NAGAD ("DCT/SGDT") is in Djibouti while NEGAD_FY_BCC is in Ethiopia and inactive, and which one is the intercity facility is unsettled. No behaviour change yet — nothing reads has_facility until the gate lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/edr-freight-api/src/app.module.ts | 7 ++ .../2290000000000-YardFacilities.ts | 85 ++++++++++++++++++ .../rule-engine/dto/create-yard.dto.ts | 9 ++ .../entities/yard-facility.entity.ts | 34 +++++++ .../rule-engine/entities/yard.entity.ts | 8 ++ .../modules/rule-engine/rule-engine.module.ts | 5 ++ .../services/yard-facilities.service.ts | 89 +++++++++++++++++++ .../rule-engine/services/yards.service.ts | 1 + .../src/seed/yard-facilities.seeder.ts | 67 ++++++++++++++ 9 files changed, 305 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts create mode 100644 apps/edr-freight-api/src/seed/yard-facilities.seeder.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 72a5952d7..d9a7d20cb 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -59,6 +59,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; +import { YardFacilitiesSeeder } from "./seed/yard-facilities.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"; @@ -196,6 +197,7 @@ import { LoggerMiddleware } from "./logger.middleware"; EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, + YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, @@ -221,6 +223,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, @@ -258,6 +261,10 @@ export class AppModule implements OnApplicationBootstrap { // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); + // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, + // Dire Dawa). Idempotent; creates no yards. + await this.yardFacilitiesSeeder.run(); + // Dropdown settings are not seeded on boot; run them with // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). diff --git a/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts new file mode 100644 index 000000000..620eebc14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2290000000000-YardFacilities.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its + * destination yard, but only some yards have the equipment to do it. EDR's + * load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad — + * and the set grows, so it must be data, not a constant. + * + * `yards.has_facility` marks a yard as a load/unload point; `yard_facilities` + * holds what that facility can do. Only a facility with `has_warehouse` (Indode + * today) stores cargo, and therefore accrues storage/demurrage — the rest just + * move it on and off the train. + * + * `facility_handling_events` records each load/unload and carries its GRN. + * warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so + * a facility with no warehouse could never have a row. `inventory_id` links to the + * storage record when the facility does have a warehouse. + */ +export class YardFacilities2290000000000 implements MigrationInterface { + name = 'YardFacilities2290000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yards + ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_facilities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + has_warehouse boolean NOT NULL DEFAULT false, + equipment_notes text NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + // One facility record per yard. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard" + ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.facility_handling_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id), + yard_id uuid NOT NULL REFERENCES freight.yards(id), + train_schedule_id uuid NULL REFERENCES freight.train_schedules(id), + event_type varchar(10) NOT NULL, + grn_number varchar(60) NULL, + quantity numeric(14, 3) NULL, + weight_tons numeric(14, 3) NULL, + inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id), + performed_by varchar(120) NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_booking" + ON freight.facility_handling_events (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard" + ON freight.facility_handling_events (yard_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn" + ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`); + await queryRunner.query(` + ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility + `); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 53583e3e8..295e9e72b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -17,6 +17,15 @@ export class CreateYardDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'This yard can load/unload cargo. Intercity bookings may only be loaded at their origin and unloaded at their destination when it is a facility.', + }) + @IsOptional() + @IsBoolean() + hasFacility?: boolean; + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) @IsOptional() @IsInt() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts new file mode 100644 index 000000000..ba5a0d671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * What a yard's load/unload facility can do. One record per yard flagged + * `has_facility`. + * + * `hasWarehouse` is the line that matters: a facility with a warehouse (Indode + * today) stores cargo and therefore accrues storage/demurrage through the normal + * warehouse flow; the rest only move cargo on and off the train, so they record + * the handling event and its GRN and nothing else. + */ +@Entity({ schema: 'freight', name: 'yard_facilities' }) +@Index(['yardId']) +export class YardFacility extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** Cargo can be stored here — enables the warehouse flow (storage, demurrage). */ + @Column({ name: 'has_warehouse', type: 'boolean', default: false }) + hasWarehouse!: boolean; + + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) + equipmentNotes?: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 3f7f1ae97..808a102e5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -22,6 +22,14 @@ export class Yard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** + * This yard has the equipment to load/unload cargo. Intercity bookings can only + * be loaded at their origin and unloaded at their destination where this is + * true. What the facility can do lives on the YardFacility record. + */ + @Column({ name: 'has_facility', type: 'boolean', default: false }) + hasFacility!: boolean; + @Column({ name: 'display_order', type: 'int', default: 1 }) displayOrder!: number; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 39a5450ef..3a342ef62 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -24,6 +24,7 @@ import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; +import { YardFacility } from './entities/yard-facility.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -57,6 +58,7 @@ import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; +import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; @@ -79,6 +81,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceType, WeightLimitRule, Yard, + YardFacility, ShippingLine, Rate, ApprovalRule, @@ -130,6 +133,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesService, WeightLimitRulesService, YardsService, + YardFacilitiesService, ShippingLinesService, RatesService, ApprovalRulesService, @@ -144,6 +148,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. WeightLimitRulesService, PriorityConfigsService, YardsService, + YardFacilitiesService, ShippingLinesService, RatesService, ApprovalRulesService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts new file mode 100644 index 000000000..f32b0129a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** A yard's load/unload capability, resolved for the handling flows. */ +export interface YardFacilityInfo { + yardId: string; + yardCode: string | null; + yardLabel: string | null; + /** The yard can load/unload cargo at all. */ + hasFacility: boolean; + /** The facility stores cargo — enables the warehouse flow (storage, demurrage). */ + hasWarehouse: boolean; +} + +/** + * Which yards can handle cargo, and how. + * + * A yard is a load/unload point when `yards.has_facility` is set; the matching + * `yard_facilities` record says whether it also stores cargo. Facilities without a + * warehouse move cargo on and off the train and nothing more — no storage, no + * demurrage. This is the single resolver the journey and handling flows use, so + * they can't drift on what a facility is. + */ +@Injectable() +export class YardFacilitiesService { + constructor(private readonly dataSource: DataSource) {} + + /** Resolve a yard's handling capability. Null when the yard doesn't exist. */ + async facilityForYard(yardId: string): Promise { + const [row]: Array<{ + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + }> = await this.dataSource.query( + `SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse" + FROM freight.yards y + LEFT JOIN freight.yard_facilities f + ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true + WHERE y.id = $1 AND y.deleted_at IS NULL`, + [yardId], + ); + if (!row) return null; + return { + yardId: row.yardId, + yardCode: row.yardCode, + yardLabel: row.yardLabel, + hasFacility: Boolean(row.hasFacility), + // No facility record means no warehouse, whatever the flag says. + hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse), + }; + } + + /** Every yard that can load/unload, for pickers and the intercity queues. */ + async listFacilityYards(): Promise { + const rows: Array<{ + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + }> = await this.dataSource.query( + `SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse" + FROM freight.yards y + LEFT JOIN freight.yard_facilities f + ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true + WHERE y.deleted_at IS NULL + AND y.is_active = true + AND y.has_facility = true + ORDER BY y.display_order ASC, y.label ASC`, + ); + return rows.map((r) => ({ + yardId: r.yardId, + yardCode: r.yardCode, + yardLabel: r.yardLabel, + hasFacility: true, + hasWarehouse: Boolean(r.hasWarehouse), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 47b1f05bc..69eab608a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -45,6 +45,7 @@ export class YardsService { label: dto.label, country: dto.country, isActive: dto.isActive ?? true, + hasFacility: dto.hasFacility ?? false, displayOrder, }); } diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts new file mode 100644 index 000000000..47754bd93 --- /dev/null +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** + * EDR's load/unload facilities, mapped onto the yards that already represent them. + * + * The codes are historical and don't read like the facility names, so map by code + * and never by label: Indode is `KALITY` ("Gelan Multi Purpose Port (Indode)") and + * Sebeta is `LEGACY_DEST` ("Sebeta"). Creating fresh INDODE/SEBETA yards would + * split data that existing routes and bookings already point at. + * + * Only Indode stores cargo, so it is the only facility with a warehouse — the rest + * move cargo on and off the train, which is why they accrue no storage/demurrage. + * + * Negad is deliberately absent: there are two candidates (`NAGAD` "DCT/SGDT" in + * Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet + * settled which is the intercity facility. + */ +const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [ + { code: 'KALITY', facility: 'Indode', hasWarehouse: true }, + { code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false }, + { code: 'MOJO', facility: 'Modjo', hasWarehouse: false }, + { code: 'ADAMA', facility: 'Adama', hasWarehouse: false }, + { code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false }, +]; + +@Injectable() +export class YardFacilitiesSeeder { + private readonly logger = new Logger(YardFacilitiesSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + /** + * Idempotent: flags existing yards and upserts their facility record. Creates no + * yards — a missing code is logged and skipped rather than invented. + */ + async run(): Promise { + for (const { code, facility, hasWarehouse } of FACILITY_YARDS) { + const [yard]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`, + [code], + ); + if (!yard) { + this.logger.warn(`Yard ${code} (${facility}) not found — skipping facility flag`); + continue; + } + + await this.dataSource.query( + `UPDATE freight.yards + SET has_facility = true, updated_at = NOW() + WHERE id = $1 AND has_facility = false`, + [yard.id], + ); + + await this.dataSource.query( + `INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes) + VALUES ($1, $2, $3) + ON CONFLICT (yard_id) WHERE deleted_at IS NULL + DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`, + [yard.id, hasWarehouse, `${facility} load/unload facility`], + ); + } + this.logger.log( + `Yard facilities seeded: ${FACILITY_YARDS.map((f) => f.facility).join(', ')}`, + ); + } +} From a411e0bdd0859eb19a566c12f63ab3d85d6edeba Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 09:55:06 +0000 Subject: [PATCH 2/4] feat(intercity): only load/unload where the yard has a facility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercity cargo rides a passing train and is handled at the booking's own yards, so those yards need the equipment to do it — a train stopping somewhere is not the same as somewhere being able to load it. Loading at an origin or unloading at a destination without has_facility is now refused, naming the yard. The check sits inside loadBooking/unloadBooking rather than the intercity controller wrapper, because recording a checkpoint auto-unloads every booking destined at that yard (autoUnloadAtYard) and would otherwise route around it. Import/export are untouched: their cargo is handled at the route's terminal ports, not at an arbitrary mid-corridor yard, so the gate returns early for anything that isn't DOMESTIC. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../booking-journey.service.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 426bd37be..601ba91ee 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -9,6 +9,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In } from 'typeorm'; import { Freight } from '@edr/types'; +import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; import { Booking } from '../bookings/entities/booking.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -43,6 +44,7 @@ export class BookingJourneyService { constructor( @InjectDataSource() private readonly dataSource: DataSource, + private readonly yardFacilities: YardFacilitiesService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -63,6 +65,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -99,6 +102,7 @@ export class BookingJourneyService { ); } await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination'); // Intercity has no clearance/delivery tail — unloading completes it. Import/ // export continue into clearance, keyed on the booking's own arrival. @@ -306,6 +310,33 @@ export class BookingJourneyService { }); } + /** + * INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the + * booking's own yards, so those yards need the equipment to do it — a train + * stopping somewhere is not the same as somewhere being able to load it. + * + * Import/export are untouched: their cargo is handled at the route's terminal + * ports, not at an arbitrary mid-corridor yard, and gating them here would + * block existing traffic. + * + * Lives here rather than in the controller so the checkpoint-driven + * autoUnloadAtYard path cannot route around it. + */ + private async assertYardCanHandleCargo( + booking: Booking, + yardId: string, + side: 'origin' | 'destination', + ): Promise { + if (booking.tradeDirection !== 'DOMESTIC') return; + const facility = await this.yardFacilities.facilityForYard(yardId); + if (!facility?.hasFacility) { + throw new BadRequestException( + `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` + + `${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`, + ); + } + } + /** * The train is "at" a yard when the latest recorded checkpoint is that yard, * or — for a booking boarding at the train's own origin — when the train has From a73faecbe32fb5e545bf0cc55d7fcc9001bbc3a5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 10:24:53 +0000 Subject: [PATCH 3/4] feat(intercity): raise a GRN when a facility loads or unloads cargo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every facility raises a GRN — the goods changed hands, whether or not anyone stores them. What differs is what happens next: Indode has a warehouse, so cargo left there goes through the existing warehouse flow and accrues storage and demurrage; Sebeta, Modjo, Adama and Dire Dawa only move cargo between train and truck, so the handling event and its GRN are the whole record. facility_handling_events carries that record because warehouse_inventory cannot: its warehouse/yard/zone are NOT NULL, so a facility with equipment but no warehouse could never have a row there. inventory_id links the storage record when the facility does keep the cargo, which is what ties an Indode handover to its demurrage. generateGrnNumber moves to common/grn.util.ts so a GRN raised at a facility is indistinguishable from one raised in a warehouse — the two live in different tables, and a second generator would let the formats drift. Recording is best-effort: the cargo moved regardless, so paperwork must never fail the journey. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/edr-freight-api/src/common/grn.util.ts | 13 +++ .../booking-journey.service.ts | 23 +++++ .../facility-handling-event.entity.ts | 56 +++++++++++ .../facility-handling.service.ts | 97 +++++++++++++++++++ .../train-scheduling.module.ts | 4 + .../warehouses/warehouse-inventory.service.ts | 6 +- 6 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-api/src/common/grn.util.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts new file mode 100644 index 000000000..5cae30302 --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -0,0 +1,13 @@ +/** + * Goods Received Note number: `GRN---`. + * + * Shared so a GRN raised at a load/unload facility is indistinguishable from one + * raised in a warehouse — the two live in different tables + * (facility_handling_events vs warehouse_inventory), and a second generator would + * eventually let their formats drift apart. + */ +export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 601ba91ee..6f366d060 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -10,6 +10,7 @@ import { DataSource, EntityManager, In } from 'typeorm'; import { Freight } from '@edr/types'; import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; +import { FacilityHandlingService } from './facility-handling.service'; import { Booking } from '../bookings/entities/booking.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -45,6 +46,7 @@ export class BookingJourneyService { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly yardFacilities: YardFacilitiesService, + private readonly facilityHandling: FacilityHandlingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -75,6 +77,16 @@ export class BookingJourneyService { loadedByUserId: userId ?? null, } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + // The facility handed the cargo over — raise its GRN. No-ops for yards + // without a facility (import/export terminals), which keep their own flow. + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: booking.originYardId, + trainScheduleId: scheduleId, + eventType: 'LOAD', + performedBy: userId ?? null, + occurredAt: now, + }); }); // Customer tracking: cargo is on the train — loading milestones plus the @@ -116,6 +128,17 @@ export class BookingJourneyService { } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); + // The facility took the cargo off the train — raise its GRN. Where the + // facility also stores cargo (Indode), the event links the storage record + // that storage/demurrage accrue against. + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: booking.destinationYardId, + trainScheduleId: scheduleId, + eventType: 'UNLOAD', + performedBy: userId ?? null, + occurredAt: now, + }); }); // Customer tracking: THIS booking arrived (train may still be rolling). diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts new file mode 100644 index 000000000..cd8807b8a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/facility-handling-event.entity.ts @@ -0,0 +1,56 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const FACILITY_HANDLING_EVENT_TYPES = ['LOAD', 'UNLOAD'] as const; +export type FacilityHandlingEventType = (typeof FACILITY_HANDLING_EVENT_TYPES)[number]; + +/** + * Cargo loaded onto or unloaded off a train at a yard's facility, and the GRN + * raised for it. + * + * This exists because warehouse_inventory can't do the job: its + * warehouse/yard/zone are NOT NULL, so a facility that only has equipment and no + * warehouse (Sebeta, Modjo, Adama, Dire Dawa) could never have a row there — + * yet it still hands cargo over and still needs a GRN. + * + * `inventoryId` links to the warehouse record when the facility does store cargo + * (Indode), which is what makes storage and demurrage accrue there and nowhere + * else. + */ +@Entity({ schema: 'freight', name: 'facility_handling_events' }) +@Index(['bookingId']) +@Index(['yardId']) +export class FacilityHandlingEvent extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + /** The facility yard where the cargo was handled. */ + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + /** The train the cargo came off / went onto. */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'event_type', type: 'varchar', length: 10 }) + eventType!: FacilityHandlingEventType; + + @Column({ name: 'grn_number', type: 'varchar', length: 60, nullable: true }) + grnNumber?: string | null; + + @Column({ name: 'quantity', type: 'numeric', precision: 14, scale: 3, nullable: true }) + quantity?: number | null; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + weightTons?: number | null; + + /** Set only when the facility stores cargo (has_warehouse) — the storage record. */ + @Column({ name: 'inventory_id', type: 'uuid', nullable: true }) + inventoryId?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) + occurredAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts new file mode 100644 index 000000000..c0e0b7c5f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +import { generateGrnNumber } from '../../common/grn.util'; +import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { + FacilityHandlingEvent, + FacilityHandlingEventType, +} from './entities/facility-handling-event.entity'; + +/** + * Records cargo being loaded/unloaded at a yard's facility, and raises its GRN. + * + * Every facility raises a GRN — the goods changed hands, whether or not anyone + * stores them. What differs is what happens next: a facility with a warehouse + * (Indode) keeps the cargo, so it goes through the normal warehouse flow and + * accrues storage/demurrage; the rest only move it between train and truck, so + * the event and its GRN are the whole record. + * + * Best-effort by design: a failure here must not undo a load/unload that + * physically happened. + */ +@Injectable() +export class FacilityHandlingService { + private readonly logger = new Logger(FacilityHandlingService.name); + + constructor(private readonly yardFacilities: YardFacilitiesService) {} + + /** + * Write the handling event and mint its GRN. Returns the GRN, or null when the + * yard has no facility (nothing to record) or the write failed. + */ + async recordHandling( + manager: EntityManager, + input: { + booking: Booking; + yardId: string; + trainScheduleId?: string | null; + eventType: FacilityHandlingEventType; + performedBy?: string | null; + occurredAt?: Date; + }, + ): Promise { + const { booking, yardId, eventType } = input; + try { + const facility = await this.yardFacilities.facilityForYard(yardId); + if (!facility?.hasFacility) return null; + + const occurredAt = input.occurredAt ?? new Date(); + const grnNumber = generateGrnNumber( + booking.tradeDirection ?? 'DOMESTIC', + booking.id, + occurredAt, + ); + + // Link the storage record when this facility keeps cargo — that link is + // what ties an Indode handover to its storage/demurrage. + let inventoryId: string | null = null; + if (facility.hasWarehouse) { + const [inv]: Array<{ id: string }> = await manager.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [booking.id], + ); + inventoryId = inv?.id ?? null; + } + + const repo = manager.getRepository(FacilityHandlingEvent); + await repo.save( + repo.create({ + bookingId: booking.id, + yardId, + trainScheduleId: input.trainScheduleId ?? null, + eventType, + grnNumber, + weightTons: Number(booking.cargoTotalWeightVgm) || null, + inventoryId, + performedBy: input.performedBy ?? null, + occurredAt, + }), + ); + + this.logger.log( + `GRN ${grnNumber} raised on ${eventType} at ${facility.yardCode ?? yardId} for booking ${booking.reference ?? booking.id}`, + ); + return grnNumber; + } catch (err) { + // The cargo moved regardless — never fail the journey over the paperwork. + this.logger.error( + `Facility ${eventType} record failed for booking ${booking.id} at yard ${yardId}: ${String(err)}`, + ); + return null; + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 1e1eb1695..fa8623b4a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -7,6 +7,8 @@ import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { FacilityHandlingService } from './facility-handling.service'; +import { FacilityHandlingEvent } from './entities/facility-handling-event.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Route } from '../routes/entities/route.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; @@ -41,6 +43,7 @@ import { ContractsModule } from '../contracts/contracts.module'; @Module({ imports: [ TypeOrmModule.forFeature([ + FacilityHandlingEvent, Locomotive, WagonType, TrainSet, @@ -81,6 +84,7 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingSplitService, IntercityService, BookingJourneyService, + FacilityHandlingService, ], exports: [ TrainSchedulingService, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index c489b6f89..1549caf0b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { generateGrnNumber } from '../../common/grn.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; @@ -5073,10 +5074,9 @@ export class WarehouseInventoryService { } } + /** Shared with the facility handling flow — see common/grn.util.ts. */ private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); - const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + return generateGrnNumber(direction, referenceId, date); } private async generateReleaseReference(item: WarehouseInventory): Promise { From c3ba9999c5d4944ee0e94edb66bcc2c49d16c28e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 10:32:39 +0000 Subject: [PATCH 4/4] feat(yards): let staff mark a yard as a load/unload facility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which yards can handle cargo is data, not a constant — the set grows. The Yards config screen now carries the flag, so adding a facility is a toggle rather than a code change. Config-driven, so this is a column plus a form field. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/ruleEngine/config/resources.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 902d23427..a39a62b66 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -477,6 +477,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ codeColumn("code"), { id: "label", header: "Label", accessorKey: "label" }, { id: "country", header: "Country", accessorKey: "country" }, + { + id: "hasFacility", + header: "Facility", + accessorKey: "hasFacility", + format: "boolean", + }, { id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" }, activeColumn, ], @@ -489,6 +495,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ required: true, options: YARD_COUNTRIES, }, + { + name: "hasFacility", + label: "Has load/unload facility", + type: "boolean", + description: + "This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.", + }, { name: "isActive", label: "Active", type: "boolean" }, ], },