expose per-side facility flags

This commit is contained in:
Marshal
2026-08-03 08:20:18 +00:00
parent f4fd469643
commit dfd27efb6b
7 changed files with 225 additions and 25 deletions

View File

@@ -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()

View File

@@ -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<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.

View File

@@ -16,6 +16,10 @@ const service = (): YardsService =>
update: async (_id: string, d: Partial<Yard>) => 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', () => {

View File

@@ -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<PaginatedResponse<Yard>> {
return this.repository.findPaged(query);
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<YardWithSideFlags>> {
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<Yard> {
async findById(id: string): Promise<YardWithSideFlags> {
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<CreateYardDto>): Partial<YardSideFlags> | null {
const flags: Partial<YardSideFlags> = {};
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<Yard> {
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). */

View File

@@ -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 (
<div className="space-y-4 p-4">

View File

@@ -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" },
],
},

View File

@@ -19,6 +19,7 @@ export enum userTypeEnum {
external = "external_organization",
individual = "individual",
externalUsers = "external_organization,individual",
employee = "employee",
}
export interface ExternalQueryParams {