mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
feat(intercity): raise a GRN when a facility loads or unloads cargo
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) <noreply@anthropic.com>
This commit is contained in:
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
|
||||||
|
*
|
||||||
|
* 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}`;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { DataSource, EntityManager, In } from 'typeorm';
|
|||||||
import { Freight } from '@edr/types';
|
import { Freight } from '@edr/types';
|
||||||
|
|
||||||
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
||||||
|
import { FacilityHandlingService } from './facility-handling.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
@@ -45,6 +46,7 @@ export class BookingJourneyService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
private readonly yardFacilities: YardFacilitiesService,
|
private readonly yardFacilities: YardFacilitiesService,
|
||||||
|
private readonly facilityHandling: FacilityHandlingService,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -75,6 +77,16 @@ export class BookingJourneyService {
|
|||||||
loadedByUserId: userId ?? null,
|
loadedByUserId: userId ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
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
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||||
@@ -116,6 +128,17 @@ export class BookingJourneyService {
|
|||||||
} as never);
|
} as never);
|
||||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||||
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
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).
|
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { BookingsModule } from '../bookings/bookings.module';
|
|||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.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 { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Route } from '../routes/entities/route.entity';
|
import { Route } from '../routes/entities/route.entity';
|
||||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
@@ -41,6 +43,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
|
FacilityHandlingEvent,
|
||||||
Locomotive,
|
Locomotive,
|
||||||
WagonType,
|
WagonType,
|
||||||
TrainSet,
|
TrainSet,
|
||||||
@@ -81,6 +84,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
BookingSplitService,
|
BookingSplitService,
|
||||||
IntercityService,
|
IntercityService,
|
||||||
BookingJourneyService,
|
BookingJourneyService,
|
||||||
|
FacilityHandlingService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TrainSchedulingService,
|
TrainSchedulingService,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
|
import { generateGrnNumber } from '../../common/grn.util';
|
||||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.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 {
|
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
return generateGrnNumber(direction, referenceId, date);
|
||||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
|
||||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
|
||||||
|
|||||||
Reference in New Issue
Block a user