From a73faecbe32fb5e545bf0cc55d7fcc9001bbc3a5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 10:24:53 +0000 Subject: [PATCH] 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 {