mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
222 lines
9.0 KiB
TypeScript
222 lines
9.0 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;
|
|
/**
|
|
* The same capability split by side of the trip — loading onto a train and
|
|
* receiving off one need different ground. Always false where the coarse
|
|
* `handles*` flag for that type is false.
|
|
*/
|
|
hasContainerFacilityOrigin: boolean;
|
|
hasBulkFacilityOrigin: boolean;
|
|
hasContainerFacilityDestination: boolean;
|
|
hasBulkFacilityDestination: boolean;
|
|
}
|
|
|
|
/** Which side of the trip a yard is being considered for. */
|
|
export type YardSide = 'ORIGIN' | 'DESTINATION';
|
|
|
|
/**
|
|
* The four per-side capability flags as stored — NOT gated on the coarse
|
|
* `handles*` switches. The yards config page edits the stored values; gating
|
|
* is applied only when the flows resolve capability (see `toInfo`).
|
|
*/
|
|
export interface YardSideFlags {
|
|
hasContainerFacilityOrigin: boolean;
|
|
hasBulkFacilityOrigin: boolean;
|
|
hasContainerFacilityDestination: boolean;
|
|
hasBulkFacilityDestination: 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",
|
|
f.has_container_facility_origin AS "hasContainerFacilityOrigin",
|
|
f.has_bulk_facility_origin AS "hasBulkFacilityOrigin",
|
|
f.has_container_facility_destination AS "hasContainerFacilityDestination",
|
|
f.has_bulk_facility_destination AS "hasBulkFacilityDestination"
|
|
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;
|
|
hasContainerFacilityOrigin: boolean | null;
|
|
hasBulkFacilityOrigin: boolean | null;
|
|
hasContainerFacilityDestination: boolean | null;
|
|
hasBulkFacilityDestination: boolean | null;
|
|
}): YardFacilityInfo {
|
|
// No facility record means no capability, whatever the flag says.
|
|
const hasFacility = Boolean(row.hasFacility);
|
|
const handlesContainer = hasFacility && Boolean(row.handlesContainer);
|
|
const handlesBulk = hasFacility && Boolean(row.handlesBulk);
|
|
return {
|
|
yardId: row.yardId,
|
|
yardCode: row.yardCode,
|
|
yardLabel: row.yardLabel,
|
|
hasFacility,
|
|
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
|
handlesContainer,
|
|
handlesBulk,
|
|
// Gated on the coarse flag so the two can't contradict each other: a
|
|
// per-side flag left set on a type the facility no longer handles at all
|
|
// never resurrects that type.
|
|
hasContainerFacilityOrigin:
|
|
handlesContainer && Boolean(row.hasContainerFacilityOrigin),
|
|
hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin),
|
|
hasContainerFacilityDestination:
|
|
handlesContainer && Boolean(row.hasContainerFacilityDestination),
|
|
hasBulkFacilityDestination:
|
|
handlesBulk && Boolean(row.hasBulkFacilityDestination),
|
|
};
|
|
}
|
|
|
|
/** 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));
|
|
}
|
|
|
|
/** Stored per-side flags for a set of yards, keyed by yard id. Yards with no facility record are absent. */
|
|
async sideFlagsForYards(yardIds: string[]): Promise<Map<string, YardSideFlags>> {
|
|
if (yardIds.length === 0) return new Map();
|
|
const rows: Array<YardSideFlags & { yardId: string }> = await this.dataSource.query(
|
|
`SELECT yard_id AS "yardId",
|
|
has_container_facility_origin AS "hasContainerFacilityOrigin",
|
|
has_bulk_facility_origin AS "hasBulkFacilityOrigin",
|
|
has_container_facility_destination AS "hasContainerFacilityDestination",
|
|
has_bulk_facility_destination AS "hasBulkFacilityDestination"
|
|
FROM freight.yard_facilities
|
|
WHERE deleted_at IS NULL AND yard_id = ANY($1)`,
|
|
[yardIds],
|
|
);
|
|
return new Map(
|
|
rows.map((r) => [
|
|
r.yardId,
|
|
{
|
|
hasContainerFacilityOrigin: r.hasContainerFacilityOrigin,
|
|
hasBulkFacilityOrigin: r.hasBulkFacilityOrigin,
|
|
hasContainerFacilityDestination: r.hasContainerFacilityDestination,
|
|
hasBulkFacilityDestination: r.hasBulkFacilityDestination,
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Write per-side flags from the yards config form, creating the facility
|
|
* record if the yard doesn't have one yet (backoffice-created yards don't).
|
|
* Flags left undefined keep their stored value; on first insert they default
|
|
* false — an unconfigured facility offers nothing.
|
|
*/
|
|
async upsertSideFlags(yardId: string, flags: Partial<YardSideFlags>): Promise<void> {
|
|
await this.dataSource.query(
|
|
`INSERT INTO freight.yard_facilities
|
|
(yard_id, has_container_facility_origin, has_bulk_facility_origin,
|
|
has_container_facility_destination, has_bulk_facility_destination)
|
|
VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false), COALESCE($5, false))
|
|
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
|
DO UPDATE SET
|
|
has_container_facility_origin = COALESCE($2, yard_facilities.has_container_facility_origin),
|
|
has_bulk_facility_origin = COALESCE($3, yard_facilities.has_bulk_facility_origin),
|
|
has_container_facility_destination = COALESCE($4, yard_facilities.has_container_facility_destination),
|
|
has_bulk_facility_destination = COALESCE($5, yard_facilities.has_bulk_facility_destination),
|
|
updated_at = now()`,
|
|
[
|
|
yardId,
|
|
flags.hasContainerFacilityOrigin ?? null,
|
|
flags.hasBulkFacilityOrigin ?? null,
|
|
flags.hasContainerFacilityDestination ?? null,
|
|
flags.hasBulkFacilityDestination ?? null,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Can this facility take this cargo on this side of the trip? The rule behind
|
|
* the contract's origin/destination yard pickers — keep it here so the API
|
|
* and the forms can't drift apart on what is offerable.
|
|
*/
|
|
canHandleFreightOnSide(
|
|
facility: YardFacilityInfo | null,
|
|
freightType: string | null | undefined,
|
|
side: YardSide,
|
|
): boolean {
|
|
if (!facility?.hasFacility) return false;
|
|
const isContainer = String(freightType).toUpperCase() === 'CONTAINER';
|
|
if (side === 'ORIGIN') {
|
|
return isContainer
|
|
? facility.hasContainerFacilityOrigin
|
|
: facility.hasBulkFacilityOrigin;
|
|
}
|
|
return isContainer
|
|
? facility.hasContainerFacilityDestination
|
|
: facility.hasBulkFacilityDestination;
|
|
}
|
|
}
|