mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(yards): record which yards can load/unload cargo
Intercity cargo is loaded at its origin yard and unloaded at its destination, but
only some yards have the equipment. EDR's facilities are Indode, Sebeta, Modjo,
Adama and Dire Dawa — and the set grows, so it has to be data.
- yards.has_facility marks a yard as a load/unload point; the new yard_facilities
record says what it can do. Only Indode stores cargo (has_warehouse), so only it
accrues storage/demurrage — the rest just move cargo on and off the train.
- facility_handling_events records each load/unload and carries its GRN.
warehouse_inventory cannot: its warehouse/yard/zone are NOT NULL, so a facility
without a warehouse could never have a row. inventory_id links to the storage
record when there is one.
- YardFacilitiesService.facilityForYard is the single resolver the handling flows
share, so they cannot drift on what a facility is.
- The seeder flags EXISTING yards and creates none. The codes are historical and
do not read like the facility names — Indode is KALITY ("Gelan Multi Purpose
Port (Indode)") and Sebeta is LEGACY_DEST — so it maps by code. Creating fresh
INDODE/SEBETA yards would have split data that routes and bookings already
reference.
Negad is deliberately absent: NAGAD ("DCT/SGDT") is in Djibouti while
NEGAD_FY_BCC is in Ethiopia and inactive, and which one is the intercity facility
is unsettled.
No behaviour change yet — nothing reads has_facility until the gate lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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).
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user