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; /** Containers need a reach stacker/gantry — not every facility has one. */ handlesContainer: boolean; handlesBulk: boolean; /** * The same capability split by side of the trip — loading onto a train and * receiving off one need different ground. Always false where the coarse * `handles*` flag for that type is false. */ hasContainerFacilityOrigin: boolean; hasBulkFacilityOrigin: boolean; hasContainerFacilityDestination: boolean; hasBulkFacilityDestination: boolean; } /** Which side of the trip a yard is being considered for. */ export type YardSide = 'ORIGIN' | 'DESTINATION'; /** * The four per-side capability flags as stored — NOT gated on the coarse * `handles*` switches. The yards config page edits the stored values; gating * is applied only when the flows resolve capability (see `toInfo`). */ export interface YardSideFlags { hasContainerFacilityOrigin: boolean; hasBulkFacilityOrigin: boolean; hasContainerFacilityDestination: boolean; hasBulkFacilityDestination: boolean; } /** * 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 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) {} 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", f.has_container_facility_origin AS "hasContainerFacilityOrigin", f.has_bulk_facility_origin AS "hasBulkFacilityOrigin", f.has_container_facility_destination AS "hasContainerFacilityDestination", f.has_bulk_facility_destination AS "hasBulkFacilityDestination" 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; hasContainerFacilityOrigin: boolean | null; hasBulkFacilityOrigin: boolean | null; hasContainerFacilityDestination: boolean | null; hasBulkFacilityDestination: boolean | null; }): YardFacilityInfo { // No facility record means no capability, whatever the flag says. const hasFacility = Boolean(row.hasFacility); const handlesContainer = hasFacility && Boolean(row.handlesContainer); const handlesBulk = hasFacility && Boolean(row.handlesBulk); return { yardId: row.yardId, yardCode: row.yardCode, yardLabel: row.yardLabel, hasFacility, hasWarehouse: hasFacility && Boolean(row.hasWarehouse), handlesContainer, handlesBulk, // Gated on the coarse flag so the two can't contradict each other: a // per-side flag left set on a type the facility no longer handles at all // never resurrects that type. hasContainerFacilityOrigin: handlesContainer && Boolean(row.hasContainerFacilityOrigin), hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin), hasContainerFacilityDestination: handlesContainer && Boolean(row.hasContainerFacilityDestination), hasBulkFacilityDestination: handlesBulk && Boolean(row.hasBulkFacilityDestination), }; } /** 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 = 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: Parameters[0]) => this.toInfo(r)); } /** Stored per-side flags for a set of yards, keyed by yard id. Yards with no facility record are absent. */ async sideFlagsForYards(yardIds: string[]): Promise> { if (yardIds.length === 0) return new Map(); const rows: Array = await this.dataSource.query( `SELECT yard_id AS "yardId", has_container_facility_origin AS "hasContainerFacilityOrigin", has_bulk_facility_origin AS "hasBulkFacilityOrigin", has_container_facility_destination AS "hasContainerFacilityDestination", has_bulk_facility_destination AS "hasBulkFacilityDestination" FROM freight.yard_facilities WHERE deleted_at IS NULL AND yard_id = ANY($1)`, [yardIds], ); return new Map( rows.map((r) => [ r.yardId, { hasContainerFacilityOrigin: r.hasContainerFacilityOrigin, hasBulkFacilityOrigin: r.hasBulkFacilityOrigin, hasContainerFacilityDestination: r.hasContainerFacilityDestination, hasBulkFacilityDestination: r.hasBulkFacilityDestination, }, ]), ); } /** * Write per-side flags from the yards config form, creating the facility * record if the yard doesn't have one yet (backoffice-created yards don't). * Flags left undefined keep their stored value; on first insert they default * false — an unconfigured facility offers nothing. */ async upsertSideFlags(yardId: string, flags: Partial): Promise { await this.dataSource.query( `INSERT INTO freight.yard_facilities (yard_id, has_container_facility_origin, has_bulk_facility_origin, has_container_facility_destination, has_bulk_facility_destination) VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false), COALESCE($5, false)) ON CONFLICT (yard_id) WHERE deleted_at IS NULL DO UPDATE SET has_container_facility_origin = COALESCE($2, yard_facilities.has_container_facility_origin), has_bulk_facility_origin = COALESCE($3, yard_facilities.has_bulk_facility_origin), has_container_facility_destination = COALESCE($4, yard_facilities.has_container_facility_destination), has_bulk_facility_destination = COALESCE($5, yard_facilities.has_bulk_facility_destination), updated_at = now()`, [ yardId, flags.hasContainerFacilityOrigin ?? null, flags.hasBulkFacilityOrigin ?? null, flags.hasContainerFacilityDestination ?? null, flags.hasBulkFacilityDestination ?? null, ], ); } /** * 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; } /** * Can this facility take this cargo on this side of the trip? The rule behind * the contract's origin/destination yard pickers — keep it here so the API * and the forms can't drift apart on what is offerable. */ canHandleFreightOnSide( facility: YardFacilityInfo | null, freightType: string | null | undefined, side: YardSide, ): boolean { if (!facility?.hasFacility) return false; const isContainer = String(freightType).toUpperCase() === 'CONTAINER'; if (side === 'ORIGIN') { return isContainer ? facility.hasContainerFacilityOrigin : facility.hasBulkFacilityOrigin; } return isContainer ? facility.hasContainerFacilityDestination : facility.hasBulkFacilityDestination; } }