mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
expose per-side facility flags
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum userTypeEnum {
|
||||
external = "external_organization",
|
||||
individual = "individual",
|
||||
externalUsers = "external_organization,individual",
|
||||
employee = "employee",
|
||||
}
|
||||
|
||||
export interface ExternalQueryParams {
|
||||
|
||||
Reference in New Issue
Block a user