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/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/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/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 426bd37be..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 @@ -9,6 +9,8 @@ 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 { 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'; @@ -43,6 +45,8 @@ export class BookingJourneyService { constructor( @InjectDataSource() private readonly dataSource: DataSource, + private readonly yardFacilities: YardFacilitiesService, + private readonly facilityHandling: FacilityHandlingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -63,6 +67,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) => { @@ -72,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 @@ -99,6 +114,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. @@ -112,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). @@ -306,6 +333,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 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 { 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(', ')}`, + ); + } +} 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" }, ], },