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(', ')}`, + ); + } +}