mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
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:
@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
|
||||
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
||||
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 })
|
||||
equipmentNotes?: string | null;
|
||||
|
||||
|
||||
@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
|
||||
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 how.
|
||||
* 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 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.
|
||||
* `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) {}
|
||||
|
||||
/** 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;
|
||||
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: Boolean(row.hasFacility),
|
||||
// No facility record means no warehouse, whatever the flag says.
|
||||
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
|
||||
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: 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
|
||||
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) => ({
|
||||
yardId: r.yardId,
|
||||
yardCode: r.yardCode,
|
||||
yardLabel: r.yardLabel,
|
||||
hasFacility: true,
|
||||
hasWarehouse: Boolean(r.hasWarehouse),
|
||||
}));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user