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:
Hagernesh
2026-07-17 09:52:28 +00:00
parent ebba7a6a37
commit ce3ef15e7c
9 changed files with 305 additions and 0 deletions

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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),
}));
}
}

View File

@@ -45,6 +45,7 @@ export class YardsService {
label: dto.label,
country: dto.country,
isActive: dto.isActive ?? true,
hasFacility: dto.hasFacility ?? false,
displayOrder,
});
}