mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #763 from Tria-plc/intercity-load-unload
Intercity load unload Data model — yards.has_facility, yard_facilities, facility_handling_events, resolver, seeder The gate — intercity load/unload refused where there's no facility GRN raised on every facility load/unload; storage only at Indode Yards config toggle — adding a facility is now a toggle, not a code change
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<YardFacilityInfo | null> {
|
||||
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<YardFacilityInfo[]> {
|
||||
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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export class YardsService {
|
||||
label: dto.label,
|
||||
country: dto.country,
|
||||
isActive: dto.isActive ?? true,
|
||||
hasFacility: dto.hasFacility ?? false,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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<string> {
|
||||
|
||||
67
apps/edr-freight-api/src/seed/yard-facilities.seeder.ts
Normal file
67
apps/edr-freight-api/src/seed/yard-facilities.seeder.ts
Normal file
@@ -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<void> {
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user