From a5973de6e152adeea935758dca9ed84fda78fb86 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 11:57:27 +0000 Subject: [PATCH] feat(intercity): a facility only handles the cargo its equipment can lift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Containers need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them. Bulk needs far less and is handled at all five facilities. Having a facility was previously enough to load anything, so a container booking through Sebeta or Adama would have been accepted and then had nothing to lift it. - yard_facilities gains handles_container / handles_bulk, both defaulting true so a facility handles everything unless told otherwise; the seeder states the real capability. - The intercity gate now refuses cargo a facility cannot lift, saying which type, not just "no facility". canHandleFreight keeps that rule in the resolver so callers cannot get it subtly wrong. - The intercity list resolves each end against the booking's own freight type, so the view flags a container booking routed through a bulk-only yard while the train is still coming rather than when the load is refused. Import/export untouched — the gate is still DOMESTIC-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2320000000000-YardFacilityFreightTypes.ts | 30 +++++ .../entities/yard-facility.entity.ts | 11 ++ .../services/yard-facilities.service.ts | 119 ++++++++++-------- .../booking-journey.service.ts | 13 +- .../train-scheduling/intercity.service.ts | 16 ++- .../src/seed/yard-facilities.seeder.ts | 33 +++-- .../src/pages/warehouses/IntercityPage.tsx | 32 +++-- 7 files changed, 176 insertions(+), 78 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts diff --git a/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts new file mode 100644 index 000000000..d6e7ae273 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A facility handles what its equipment can handle. Containers need a reach + * stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs + * far less, so all five facilities load and unload it. + * + * Both default true — a facility handles everything unless someone says + * otherwise, which keeps existing rows working and makes the seeder the place + * where the real capability is stated. + */ +export class YardFacilityFreightTypes2320000000000 implements MigrationInterface { + name = 'YardFacilityFreightTypes2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS handles_container, + DROP COLUMN IF EXISTS handles_bulk + `); + } +} 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 index ba5a0d671..5712eed03 100644 --- 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 @@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity { @Column({ name: 'has_warehouse', type: 'boolean', default: false }) hasWarehouse!: boolean; + /** + * Containers need a reach stacker or gantry, so only the equipped facilities + * (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled + * everywhere. + */ + @Column({ name: 'handles_container', type: 'boolean', default: true }) + handlesContainer!: boolean; + + @Column({ name: 'handles_bulk', type: 'boolean', default: true }) + handlesBulk!: boolean; + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) equipmentNotes?: string | null; 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 index f32b0129a..4b69c328f 100644 --- 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 @@ -10,80 +10,91 @@ export interface YardFacilityInfo { hasFacility: boolean; /** The facility stores cargo — enables the warehouse flow (storage, demurrage). */ hasWarehouse: boolean; + /** Containers need a reach stacker/gantry — not every facility has one. */ + handlesContainer: boolean; + handlesBulk: boolean; } /** - * Which yards can handle cargo, and how. + * Which yards can handle cargo, and what kind. * * 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. + * `yard_facilities` record says what it can actually do — whether it stores cargo + * (storage/demurrage), and which freight types its equipment can lift. Containers + * need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them; + * bulk is handled at all five. + * + * This is the single resolver the journey and handling flows use, so they can't + * drift on what a facility is or what it can lift. */ @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; + private readonly SELECT = ` + SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse", + f.handles_container AS "handlesContainer", + f.handles_bulk AS "handlesBulk" + 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`; + + private toInfo(row: { + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + handlesContainer: boolean | null; + handlesBulk: boolean | null; + }): YardFacilityInfo { + // No facility record means no capability, whatever the flag says. + const hasFacility = Boolean(row.hasFacility); 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), + hasFacility, + hasWarehouse: hasFacility && Boolean(row.hasWarehouse), + handlesContainer: hasFacility && Boolean(row.handlesContainer), + handlesBulk: hasFacility && Boolean(row.handlesBulk), }; } + /** Resolve a yard's handling capability. Null when the yard doesn't exist. */ + async facilityForYard(yardId: string): Promise { + const [row] = await this.dataSource.query( + `${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`, + [yardId], + ); + return row ? this.toInfo(row) : null; + } + /** 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 + const rows = await this.dataSource.query( + `${this.SELECT} + 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), - })); + return rows.map((r: Parameters[0]) => this.toInfo(r)); + } + + /** + * Can this facility lift this cargo? Keeps the freight-type rule in one place + * so callers can't get it subtly wrong. + */ + canHandleFreight( + facility: YardFacilityInfo | null, + freightType: string | null | undefined, + ): boolean { + if (!facility?.hasFacility) return false; + return String(freightType).toUpperCase() === 'CONTAINER' + ? facility.handlesContainer + : facility.handlesBulk; } } 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 6f366d060..fb140158c 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 @@ -352,10 +352,19 @@ export class BookingJourneyService { ): Promise { if (booking.tradeDirection !== 'DOMESTIC') return; const facility = await this.yardFacilities.facilityForYard(yardId); + const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'; + 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.`, + `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`, + ); + } + // A facility only handles what its equipment can lift: containers need a + // reach stacker/gantry, bulk does not. + if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) { + throw new BadRequestException( + `${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` + + `an intercity booking cannot be ${where} here.`, ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index af0f410a6..b35650e16 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -67,10 +67,18 @@ export class IntercityService { ts.status AS "scheduleStatus", oy.id AS "originYardId", COALESCE(oy.label, oy.code) AS "origin", - oy.has_facility AS "originHasFacility", + -- Can that end actually handle THIS booking's cargo? A container + -- booking needs a facility with a stacker; bulk needs any facility. + (oy.has_facility AND COALESCE( + CASE WHEN b.freight_type = 'CONTAINER' + THEN ofac.handles_container ELSE ofac.handles_bulk END, false)) + AS "originHasFacility", dy.id AS "destinationYardId", COALESCE(dy.label, dy.code) AS "destination", - dy.has_facility AS "destinationHasFacility", + (dy.has_facility AND COALESCE( + CASE WHEN b.freight_type = 'CONTAINER' + THEN dfac.handles_container ELSE dfac.handles_bulk END, false)) + AS "destinationHasFacility", -- Where the train actually is, so the operator knows if the cargo -- can be worked right now. cp.yard_id AS "trainAtYardId", @@ -80,6 +88,10 @@ export class IntercityService { LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.yard_facilities ofac + ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true + LEFT JOIN freight.yard_facilities dfac + ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true LEFT JOIN freight.train_schedules ts ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL LEFT JOIN LATERAL ( diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts index 47754bd93..1822a877f 100644 --- a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -16,12 +16,19 @@ import { DataSource } from 'typeorm'; * 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 }, +const FACILITY_YARDS: Array<{ + code: string; + facility: string; + hasWarehouse: boolean; + handlesContainer: boolean; +}> = [ + // Containers need a reach stacker or gantry — only these three are equipped. + // Bulk needs far less, so every facility handles it. + { code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true }, + { code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true }, + { code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true }, + { code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false }, + { code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false }, ]; @Injectable() @@ -35,7 +42,7 @@ export class YardFacilitiesSeeder { * yards — a missing code is logged and skipped rather than invented. */ async run(): Promise { - for (const { code, facility, hasWarehouse } of FACILITY_YARDS) { + for (const { code, facility, hasWarehouse, handlesContainer } 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], @@ -53,11 +60,15 @@ export class YardFacilitiesSeeder { ); await this.dataSource.query( - `INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes) - VALUES ($1, $2, $3) + `INSERT INTO freight.yard_facilities + (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes) + VALUES ($1, $2, $3, true, $4) 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`], + DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, + handles_container = EXCLUDED.handles_container, + handles_bulk = EXCLUDED.handles_bulk, + updated_at = NOW()`, + [yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`], ); } this.logger.log( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx index 6a45be0f2..bce1f52e3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) => const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT"; const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED"; -/** Yards with no equipment can never load/unload — surface it before the train arrives. */ -function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) { +/** + * A yard that can't handle THIS booking's cargo can never work it — surface that + * while the train is still coming, not when the load is refused. Containers need + * a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of + * them. + */ +function FacilityCell({ + yard, + has, + freightType, +}: { + yard: string | null; + has: boolean | null; + freightType: string | null; +}) { if (!yard) return ; if (has) return {yard}; return ( @@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { {r.customer ?? "—"} - + {atOrigin(r) && isWaiting(r) && ( train here @@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { - + {atDestination(r) && isRiding(r) && ( train here @@ -215,7 +228,7 @@ export default function IntercityPage() { } label="Completed" value={done.length} /> } - label="No facility" + label="Cannot handle" value={blocked.length} color={blocked.length > 0 ? "red" : undefined} /> @@ -229,8 +242,9 @@ export default function IntercityPage() { title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`} mb="md" > - Their origin or destination yard has no load/unload facility. Mark the yard as - a facility in Configuration → Yards, or the cargo can never be worked there. + Their origin or destination yard cannot handle that cargo — no facility, or no + equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at + any facility. Adjust the yard in Configuration → Yards. )}