Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts
Hagernesh a5973de6e1 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>
2026-07-20 07:13:51 +00:00

101 lines
3.6 KiB
TypeScript

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;
/** Containers need a reach stacker/gantry — not every facility has one. */
handlesContainer: boolean;
handlesBulk: boolean;
}
/**
* Which yards can handle cargo, and what kind.
*
* A yard is a load/unload point when `yards.has_facility` is set; the matching
* `yard_facilities` record says what it can actually do — whether it stores cargo
* (storage/demurrage), and which freight types its equipment can lift. Containers
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
* 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()
export class YardFacilitiesService {
constructor(private readonly dataSource: DataSource) {}
private readonly SELECT = `
SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse",
f.handles_container AS "handlesContainer",
f.handles_bulk AS "handlesBulk"
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`;
private toInfo(row: {
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
handlesContainer: boolean | null;
handlesBulk: boolean | null;
}): YardFacilityInfo {
// No facility record means no capability, whatever the flag says.
const hasFacility = Boolean(row.hasFacility);
return {
yardId: row.yardId,
yardCode: row.yardCode,
yardLabel: row.yardLabel,
hasFacility,
hasWarehouse: 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. */
async listFacilityYards(): Promise<YardFacilityInfo[]> {
const rows = await this.dataSource.query(
`${this.SELECT}
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: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
}
/**
* Can this facility lift this cargo? Keeps the freight-type rule in one place
* 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;
}
}