From dfd27efb6bbc4268a872b62ac8836101cf159136 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 08:20:18 +0000 Subject: [PATCH] expose per-side facility flags --- .../rule-engine/dto/create-yard.dto.ts | 32 +++++++++ .../services/yard-facilities.service.ts | 67 +++++++++++++++++++ .../services/yards.duplicate-label.spec.ts | 4 ++ .../rule-engine/services/yards.service.ts | 63 ++++++++++++++--- .../pages/configuration/TradeAccessPage.tsx | 31 ++++----- .../src/pages/ruleEngine/config/resources.ts | 52 ++++++++++++++ .../src/super-admin/hooks/useExternalUsers.ts | 1 + 7 files changed, 225 insertions(+), 25 deletions(-) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 295e9e72b..94dbd2fb8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -26,6 +26,38 @@ export class CreateYardDto { @IsBoolean() hasFacility?: boolean; + @ApiPropertyOptional({ + default: false, + description: 'Facility can load containers onto a train (contract origin side)', + }) + @IsOptional() + @IsBoolean() + hasContainerFacilityOrigin?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'Facility can load bulk onto a train (contract origin side)', + }) + @IsOptional() + @IsBoolean() + hasBulkFacilityOrigin?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'Facility can receive containers off a train (contract destination side)', + }) + @IsOptional() + @IsBoolean() + hasContainerFacilityDestination?: boolean; + + @ApiPropertyOptional({ + default: false, + description: 'Facility can receive bulk off a train (contract destination side)', + }) + @IsOptional() + @IsBoolean() + hasBulkFacilityDestination?: boolean; + @ApiPropertyOptional({ default: 1, description: 'UI display sort order' }) @IsOptional() @IsInt() diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index 2579c8d22..9602125bb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -27,6 +27,18 @@ export interface YardFacilityInfo { /** 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. * @@ -116,6 +128,61 @@ export class YardFacilitiesService { return rows.map((r: Parameters[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> { + if (yardIds.length === 0) return new Map(); + const rows: Array = 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): Promise { + 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. diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts index 8affab199..0ec9fe282 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts @@ -16,6 +16,10 @@ const service = (): YardsService => update: async (_id: string, d: Partial) => d as Yard, } as never, { resolveCreateOrder: async () => 1 } as never, + { + sideFlagsForYards: async () => new Map(), + upsertSideFlags: async () => undefined, + } as never, ); describe('duplicate yard labels are rejected', () => { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 7dd29ded1..995a901ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -8,6 +8,24 @@ import { UpdateYardDto } from '../dto/update-yard.dto'; import { Yard } from '../entities/yard.entity'; import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; import { DisplayOrderService } from './display-order.service'; +import { YardFacilitiesService, YardSideFlags } from './yard-facilities.service'; + +/** Yard rows the config page lists/edits carry the stored per-side facility flags. */ +export type YardWithSideFlags = Yard & YardSideFlags; + +const SIDE_FLAG_KEYS = [ + 'hasContainerFacilityOrigin', + 'hasBulkFacilityOrigin', + 'hasContainerFacilityDestination', + 'hasBulkFacilityDestination', +] as const; + +const NO_FLAGS: YardSideFlags = { + hasContainerFacilityOrigin: false, + hasBulkFacilityOrigin: false, + hasContainerFacilityDestination: false, + hasBulkFacilityDestination: false, +}; @Injectable() export class YardsService { @@ -15,18 +33,34 @@ export class YardsService { @Inject(YARDS_REPOSITORY) private readonly repository: IYardsRepository, private readonly displayOrder: DisplayOrderService, + private readonly facilities: YardFacilitiesService, ) {} /** List yards — standard paginated envelope with server-side search. */ - async findAll(query: ListYardsQueryDto): Promise> { - return this.repository.findPaged(query); + async findAll(query: ListYardsQueryDto): Promise> { + const page = await this.repository.findPaged(query); + const flags = await this.facilities.sideFlagsForYards(page.items.map((y) => y.id)); + return { + ...page, + items: page.items.map((y) => ({ ...y, ...NO_FLAGS, ...flags.get(y.id) })), + }; } /** Get a yard by ID. */ - async findById(id: string): Promise { + async findById(id: string): Promise { const entity = await this.repository.findById(id); if (!entity) throw new NotFoundException(`Yard ${id} not found`); - return entity; + const flags = await this.facilities.sideFlagsForYards([id]); + return { ...entity, ...NO_FLAGS, ...flags.get(id) }; + } + + /** The per-side facility flags present in the dto, or null when none were sent. */ + private pickSideFlags(dto: Partial): Partial | null { + const flags: Partial = {}; + for (const key of SIDE_FLAG_KEYS) { + if (dto[key] !== undefined) flags[key] = dto[key]; + } + return Object.keys(flags).length > 0 ? flags : null; } /** Create a yard. */ @@ -43,7 +77,7 @@ export class YardsService { insertAfterId: dto.insertAfterId, }); - return this.repository.create({ + const yard = await this.repository.create({ code, label: dto.label, country: dto.country, @@ -51,15 +85,28 @@ export class YardsService { hasFacility: dto.hasFacility ?? false, displayOrder, }); + + const flags = this.pickSideFlags(dto); + if (flags) await this.facilities.upsertSideFlags(yard.id, flags); + return this.findById(yard.id); } /** Update a yard. */ async update(id: string, dto: UpdateYardDto): Promise { await this.findById(id); if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id); - const updated = await this.repository.update(id, dto); - if (!updated) throw new NotFoundException(`Yard ${id} not found`); - return updated; + + // Per-side flags live on yard_facilities, not the yards row — split them out. + const flags = this.pickSideFlags(dto); + const yardDto = { ...dto }; + for (const key of SIDE_FLAG_KEYS) delete yardDto[key]; + + if (Object.keys(yardDto).length > 0) { + const updated = await this.repository.update(id, yardDto); + if (!updated) throw new NotFoundException(`Yard ${id} not found`); + } + if (flags) await this.facilities.upsertSideFlags(id, flags); + return this.findById(id); } /** No two active yards may share a label (case/whitespace-insensitive). */ diff --git a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx index ffcbdc604..2c447d72d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx @@ -2,8 +2,10 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { useAuth } from "@/auth/useAuth"; -import { useEmployees } from "@/user-management/hooks/useEmployees"; +import { + useAllExternalUsers, + userTypeEnum, +} from "@/super-admin/hooks/useExternalUsers"; import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS, @@ -34,17 +36,12 @@ type EmployeeRow = { * and overview to the checked directions. Admins always bypass the scope. */ export default function TradeAccessPage() { - const { user } = useAuth(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); - const organizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - - const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({ - organizationId, + const { data: usersResponse, isLoading: usersLoading } = useAllExternalUsers({ + userType: userTypeEnum.employee, + take: 3000, }); const { data: configs, isLoading: configsLoading } = useQuery({ @@ -76,12 +73,12 @@ export default function TradeAccessPage() { }, [configs]); const rows: EmployeeRow[] = useMemo(() => { - const items = employeesResponseByOrg?.items ?? []; + const items = usersResponse?.items ?? []; const mapped = items - .map((item: { user?: { id?: string; name?: { en?: string }; email?: string; username?: string } }) => ({ - userId: item.user?.id ?? "", - name: item.user?.name?.en ?? item.user?.username ?? "—", - email: item.user?.email ?? "", + .map((u) => ({ + userId: u.id ?? "", + name: u.name?.en ?? u.username ?? "—", + email: u.email ?? "", })) .filter((r: EmployeeRow) => r.userId); const term = search.trim().toLowerCase(); @@ -91,7 +88,7 @@ export default function TradeAccessPage() { r.name.toLowerCase().includes(term) || r.email.toLowerCase().includes(term), ); - }, [employeesResponseByOrg, search]); + }, [usersResponse, search]); // No row yet = unrestricted, so render as all three checked. const directionsFor = (userId: string): TradeDirection[] => @@ -105,7 +102,7 @@ export default function TradeAccessPage() { saveMutation.mutate({ userId, directions: next }); }; - const loading = isLoadingEmployeesByOrg || configsLoading; + const loading = usersLoading || configsLoading; return (
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 6e816e772..1702a8af1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -691,6 +691,30 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ accessorKey: "hasFacility", format: "boolean", }, + { + id: "hasContainerFacilityOrigin", + header: "Container origin", + accessorKey: "hasContainerFacilityOrigin", + format: "boolean", + }, + { + id: "hasContainerFacilityDestination", + header: "Container dest.", + accessorKey: "hasContainerFacilityDestination", + format: "boolean", + }, + { + id: "hasBulkFacilityOrigin", + header: "Bulk origin", + accessorKey: "hasBulkFacilityOrigin", + format: "boolean", + }, + { + id: "hasBulkFacilityDestination", + header: "Bulk dest.", + accessorKey: "hasBulkFacilityDestination", + format: "boolean", + }, { id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" }, activeColumn, ], @@ -710,6 +734,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ description: "This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.", }, + { + name: "hasContainerFacilityOrigin", + label: "Container facility — origin", + type: "boolean", + description: "Can load containers onto a train. Offered as a contract origin for container freight.", + showIf: (values) => Boolean(values.hasFacility), + }, + { + name: "hasContainerFacilityDestination", + label: "Container facility — destination", + type: "boolean", + description: "Can receive containers off a train. Offered as a contract destination for container freight.", + showIf: (values) => Boolean(values.hasFacility), + }, + { + name: "hasBulkFacilityOrigin", + label: "Bulk facility — origin", + type: "boolean", + description: "Can load bulk cargo onto a train. Offered as a contract origin for bulk freight.", + showIf: (values) => Boolean(values.hasFacility), + }, + { + name: "hasBulkFacilityDestination", + label: "Bulk facility — destination", + type: "boolean", + description: "Can receive bulk cargo off a train. Offered as a contract destination for bulk freight.", + showIf: (values) => Boolean(values.hasFacility), + }, { name: "isActive", label: "Active", type: "boolean" }, ], }, diff --git a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useExternalUsers.ts b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useExternalUsers.ts index 8f120b037..3631bb006 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useExternalUsers.ts +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useExternalUsers.ts @@ -19,6 +19,7 @@ export enum userTypeEnum { external = "external_organization", individual = "individual", externalUsers = "external_organization,individual", + employee = "employee", } export interface ExternalQueryParams {