feat(intercity): a facility only handles the cargo its equipment can lift

Containers need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa
take them. Bulk needs far less and is handled at all five facilities. Having a
facility was previously enough to load anything, so a container booking through
Sebeta or Adama would have been accepted and then had nothing to lift it.

- yard_facilities gains handles_container / handles_bulk, both defaulting true so
  a facility handles everything unless told otherwise; the seeder states the real
  capability.
- The intercity gate now refuses cargo a facility cannot lift, saying which type,
  not just "no facility". canHandleFreight keeps that rule in the resolver so
  callers cannot get it subtly wrong.
- The intercity list resolves each end against the booking's own freight type, so
  the view flags a container booking routed through a bulk-only yard while the
  train is still coming rather than when the load is refused.

Import/export untouched — the gate is still DOMESTIC-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-17 11:57:27 +00:00
parent 096e69ba43
commit a5973de6e1
7 changed files with 176 additions and 78 deletions

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A facility handles what its equipment can handle. Containers need a reach
* stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs
* far less, so all five facilities load and unload it.
*
* Both default true — a facility handles everything unless someone says
* otherwise, which keeps existing rows working and makes the seeder the place
* where the real capability is stated.
*/
export class YardFacilityFreightTypes2320000000000 implements MigrationInterface {
name = 'YardFacilityFreightTypes2320000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
DROP COLUMN IF EXISTS handles_container,
DROP COLUMN IF EXISTS handles_bulk
`);
}
}

View File

@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
@Column({ name: 'has_warehouse', type: 'boolean', default: false }) @Column({ name: 'has_warehouse', type: 'boolean', default: false })
hasWarehouse!: boolean; hasWarehouse!: boolean;
/**
* Containers need a reach stacker or gantry, so only the equipped facilities
* (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled
* everywhere.
*/
@Column({ name: 'handles_container', type: 'boolean', default: true })
handlesContainer!: boolean;
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
handlesBulk!: boolean;
@Column({ name: 'equipment_notes', type: 'text', nullable: true }) @Column({ name: 'equipment_notes', type: 'text', nullable: true })
equipmentNotes?: string | null; equipmentNotes?: string | null;

View File

@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
hasFacility: boolean; hasFacility: boolean;
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */ /** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
hasWarehouse: boolean; hasWarehouse: boolean;
/** Containers need a reach stacker/gantry — not every facility has one. */
handlesContainer: boolean;
handlesBulk: boolean;
} }
/** /**
* Which yards can handle cargo, and how. * Which yards can handle cargo, and what kind.
* *
* A yard is a load/unload point when `yards.has_facility` is set; the matching * 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 * `yard_facilities` record says what it can actually do — whether it stores cargo
* warehouse move cargo on and off the train and nothing more — no storage, no * (storage/demurrage), and which freight types its equipment can lift. Containers
* demurrage. This is the single resolver the journey and handling flows use, so * need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
* they can't drift on what a facility is. * bulk is handled at all five.
*
* This is the single resolver the journey and handling flows use, so they can't
* drift on what a facility is or what it can lift.
*/ */
@Injectable() @Injectable()
export class YardFacilitiesService { export class YardFacilitiesService {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */ private readonly SELECT = `
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> { SELECT y.id AS "yardId",
const [row]: Array<{ y.code AS "yardCode",
yardId: string; y.label AS "yardLabel",
yardCode: string | null; y.has_facility AS "hasFacility",
yardLabel: string | null; f.has_warehouse AS "hasWarehouse",
hasFacility: boolean; f.handles_container AS "handlesContainer",
hasWarehouse: boolean | null; f.handles_bulk AS "handlesBulk"
}> = await this.dataSource.query( FROM freight.yards y
`SELECT y.id AS "yardId", LEFT JOIN freight.yard_facilities f
y.code AS "yardCode", ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
y.label AS "yardLabel",
y.has_facility AS "hasFacility", private toInfo(row: {
f.has_warehouse AS "hasWarehouse" yardId: string;
FROM freight.yards y yardCode: string | null;
LEFT JOIN freight.yard_facilities f yardLabel: string | null;
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true hasFacility: boolean;
WHERE y.id = $1 AND y.deleted_at IS NULL`, hasWarehouse: boolean | null;
[yardId], handlesContainer: boolean | null;
); handlesBulk: boolean | null;
if (!row) return null; }): YardFacilityInfo {
// No facility record means no capability, whatever the flag says.
const hasFacility = Boolean(row.hasFacility);
return { return {
yardId: row.yardId, yardId: row.yardId,
yardCode: row.yardCode, yardCode: row.yardCode,
yardLabel: row.yardLabel, yardLabel: row.yardLabel,
hasFacility: Boolean(row.hasFacility), hasFacility,
// No facility record means no warehouse, whatever the flag says. hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse), handlesContainer: hasFacility && Boolean(row.handlesContainer),
handlesBulk: hasFacility && Boolean(row.handlesBulk),
}; };
} }
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
const [row] = await this.dataSource.query(
`${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`,
[yardId],
);
return row ? this.toInfo(row) : null;
}
/** Every yard that can load/unload, for pickers and the intercity queues. */ /** Every yard that can load/unload, for pickers and the intercity queues. */
async listFacilityYards(): Promise<YardFacilityInfo[]> { async listFacilityYards(): Promise<YardFacilityInfo[]> {
const rows: Array<{ const rows = await this.dataSource.query(
yardId: string; `${this.SELECT}
yardCode: string | null; WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
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`, ORDER BY y.display_order ASC, y.label ASC`,
); );
return rows.map((r) => ({ return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
yardId: r.yardId, }
yardCode: r.yardCode,
yardLabel: r.yardLabel, /**
hasFacility: true, * Can this facility lift this cargo? Keeps the freight-type rule in one place
hasWarehouse: Boolean(r.hasWarehouse), * so callers can't get it subtly wrong.
})); */
canHandleFreight(
facility: YardFacilityInfo | null,
freightType: string | null | undefined,
): boolean {
if (!facility?.hasFacility) return false;
return String(freightType).toUpperCase() === 'CONTAINER'
? facility.handlesContainer
: facility.handlesBulk;
} }
} }

View File

@@ -352,10 +352,19 @@ export class BookingJourneyService {
): Promise<void> { ): Promise<void> {
if (booking.tradeDirection !== 'DOMESTIC') return; if (booking.tradeDirection !== 'DOMESTIC') return;
const facility = await this.yardFacilities.facilityForYard(yardId); const facility = await this.yardFacilities.facilityForYard(yardId);
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
if (!facility?.hasFacility) { if (!facility?.hasFacility) {
throw new BadRequestException( throw new BadRequestException(
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` + `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`, );
}
// A facility only handles what its equipment can lift: containers need a
// reach stacker/gantry, bulk does not.
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
throw new BadRequestException(
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
`an intercity booking cannot be ${where} here.`,
); );
} }
} }

View File

@@ -67,10 +67,18 @@ export class IntercityService {
ts.status AS "scheduleStatus", ts.status AS "scheduleStatus",
oy.id AS "originYardId", oy.id AS "originYardId",
COALESCE(oy.label, oy.code) AS "origin", COALESCE(oy.label, oy.code) AS "origin",
oy.has_facility AS "originHasFacility", -- Can that end actually handle THIS booking's cargo? A container
-- booking needs a facility with a stacker; bulk needs any facility.
(oy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
AS "originHasFacility",
dy.id AS "destinationYardId", dy.id AS "destinationYardId",
COALESCE(dy.label, dy.code) AS "destination", COALESCE(dy.label, dy.code) AS "destination",
dy.has_facility AS "destinationHasFacility", (dy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
AS "destinationHasFacility",
-- Where the train actually is, so the operator knows if the cargo -- Where the train actually is, so the operator knows if the cargo
-- can be worked right now. -- can be worked right now.
cp.yard_id AS "trainAtYardId", cp.yard_id AS "trainAtYardId",
@@ -80,6 +88,10 @@ export class IntercityService {
LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.yard_facilities ofac
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
LEFT JOIN freight.yard_facilities dfac
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
LEFT JOIN freight.train_schedules ts LEFT JOIN freight.train_schedules ts
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (

View File

@@ -16,12 +16,19 @@ import { DataSource } from 'typeorm';
* Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet * Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet
* settled which is the intercity facility. * settled which is the intercity facility.
*/ */
const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [ const FACILITY_YARDS: Array<{
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true }, code: string;
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false }, facility: string;
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false }, hasWarehouse: boolean;
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false }, handlesContainer: boolean;
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false }, }> = [
// Containers need a reach stacker or gantry — only these three are equipped.
// Bulk needs far less, so every facility handles it.
{ code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true },
{ code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true },
{ code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true },
{ code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false },
{ code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false },
]; ];
@Injectable() @Injectable()
@@ -35,7 +42,7 @@ export class YardFacilitiesSeeder {
* yards — a missing code is logged and skipped rather than invented. * yards — a missing code is logged and skipped rather than invented.
*/ */
async run(): Promise<void> { async run(): Promise<void> {
for (const { code, facility, hasWarehouse } of FACILITY_YARDS) { for (const { code, facility, hasWarehouse, handlesContainer } of FACILITY_YARDS) {
const [yard]: Array<{ id: string }> = await this.dataSource.query( const [yard]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`, `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`,
[code], [code],
@@ -53,11 +60,15 @@ export class YardFacilitiesSeeder {
); );
await this.dataSource.query( await this.dataSource.query(
`INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes) `INSERT INTO freight.yard_facilities
VALUES ($1, $2, $3) (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
VALUES ($1, $2, $3, true, $4)
ON CONFLICT (yard_id) WHERE deleted_at IS NULL ON CONFLICT (yard_id) WHERE deleted_at IS NULL
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`, DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
[yard.id, hasWarehouse, `${facility} load/unload facility`], handles_container = EXCLUDED.handles_container,
handles_bulk = EXCLUDED.handles_bulk,
updated_at = NOW()`,
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
); );
} }
this.logger.log( this.logger.log(

View File

@@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) =>
const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT"; const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED"; const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED";
/** Yards with no equipment can never load/unload — surface it before the train arrives. */ /**
function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) { * A yard that can't handle THIS booking's cargo can never work it — surface that
* while the train is still coming, not when the load is refused. Containers need
* a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of
* them.
*/
function FacilityCell({
yard,
has,
freightType,
}: {
yard: string | null;
has: boolean | null;
freightType: string | null;
}) {
if (!yard) return <Text size="sm"></Text>; if (!yard) return <Text size="sm"></Text>;
if (has) return <Text size="sm">{yard}</Text>; if (has) return <Text size="sm">{yard}</Text>;
return ( return (
<Tooltip <Tooltip
label="This yard has no load/unload facility — cargo cannot be handled here" label={`${yard} cannot handle ${(freightType ?? "this").toLowerCase()} cargo — no facility here, or no equipment for it`}
withArrow withArrow
multiline multiline
w={240} w={260}
> >
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<AlertTriangle size={13} color="var(--mantine-color-red-6)" /> <AlertTriangle size={13} color="var(--mantine-color-red-6)" />
@@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Td>{r.customer ?? "—"}</Table.Td> <Table.Td>{r.customer ?? "—"}</Table.Td>
<Table.Td> <Table.Td>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<FacilityCell yard={r.origin} has={r.originHasFacility} /> <FacilityCell yard={r.origin} has={r.originHasFacility} freightType={r.freightType} />
{atOrigin(r) && isWaiting(r) && ( {atOrigin(r) && isWaiting(r) && (
<Badge size="xs" color="edr-green" variant="light"> <Badge size="xs" color="edr-green" variant="light">
train here train here
@@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<FacilityCell yard={r.destination} has={r.destinationHasFacility} /> <FacilityCell yard={r.destination} has={r.destinationHasFacility} freightType={r.freightType} />
{atDestination(r) && isRiding(r) && ( {atDestination(r) && isRiding(r) && (
<Badge size="xs" color="edr-green" variant="light"> <Badge size="xs" color="edr-green" variant="light">
train here train here
@@ -215,7 +228,7 @@ export default function IntercityPage() {
<Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} /> <Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} />
<Stat <Stat
icon={<AlertTriangle size={18} />} icon={<AlertTriangle size={18} />}
label="No facility" label="Cannot handle"
value={blocked.length} value={blocked.length}
color={blocked.length > 0 ? "red" : undefined} color={blocked.length > 0 ? "red" : undefined}
/> />
@@ -229,8 +242,9 @@ export default function IntercityPage() {
title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`} title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
mb="md" mb="md"
> >
Their origin or destination yard has no load/unload facility. Mark the yard as Their origin or destination yard cannot handle that cargo no facility, or no
a facility in Configuration Yards, or the cargo can never be worked there. equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at
any facility. Adjust the yard in Configuration Yards.
</Alert> </Alert>
)} )}