mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
expose per-side facility flags
This commit is contained in:
@@ -26,6 +26,38 @@ export class CreateYardDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasFacility?: boolean;
|
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' })
|
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export interface YardFacilityInfo {
|
|||||||
/** Which side of the trip a yard is being considered for. */
|
/** Which side of the trip a yard is being considered for. */
|
||||||
export type YardSide = 'ORIGIN' | 'DESTINATION';
|
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.
|
* 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));
|
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
|
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||||
* so callers can't get it subtly wrong.
|
* 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,
|
update: async (_id: string, d: Partial<Yard>) => d as Yard,
|
||||||
} as never,
|
} as never,
|
||||||
{ resolveCreateOrder: async () => 1 } as never,
|
{ resolveCreateOrder: async () => 1 } as never,
|
||||||
|
{
|
||||||
|
sideFlagsForYards: async () => new Map(),
|
||||||
|
upsertSideFlags: async () => undefined,
|
||||||
|
} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('duplicate yard labels are rejected', () => {
|
describe('duplicate yard labels are rejected', () => {
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ import { UpdateYardDto } from '../dto/update-yard.dto';
|
|||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||||
import { DisplayOrderService } from './display-order.service';
|
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()
|
@Injectable()
|
||||||
export class YardsService {
|
export class YardsService {
|
||||||
@@ -15,18 +33,34 @@ export class YardsService {
|
|||||||
@Inject(YARDS_REPOSITORY)
|
@Inject(YARDS_REPOSITORY)
|
||||||
private readonly repository: IYardsRepository,
|
private readonly repository: IYardsRepository,
|
||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
|
private readonly facilities: YardFacilitiesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List yards — standard paginated envelope with server-side search. */
|
/** List yards — standard paginated envelope with server-side search. */
|
||||||
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<YardWithSideFlags>> {
|
||||||
return this.repository.findPaged(query);
|
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. */
|
/** Get a yard by ID. */
|
||||||
async findById(id: string): Promise<Yard> {
|
async findById(id: string): Promise<YardWithSideFlags> {
|
||||||
const entity = await this.repository.findById(id);
|
const entity = await this.repository.findById(id);
|
||||||
if (!entity) throw new NotFoundException(`Yard ${id} not found`);
|
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. */
|
/** Create a yard. */
|
||||||
@@ -43,7 +77,7 @@ export class YardsService {
|
|||||||
insertAfterId: dto.insertAfterId,
|
insertAfterId: dto.insertAfterId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.create({
|
const yard = await this.repository.create({
|
||||||
code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
@@ -51,15 +85,28 @@ export class YardsService {
|
|||||||
hasFacility: dto.hasFacility ?? false,
|
hasFacility: dto.hasFacility ?? false,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flags = this.pickSideFlags(dto);
|
||||||
|
if (flags) await this.facilities.upsertSideFlags(yard.id, flags);
|
||||||
|
return this.findById(yard.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update a yard. */
|
/** Update a yard. */
|
||||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, 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`);
|
// Per-side flags live on yard_facilities, not the yards row — split them out.
|
||||||
return updated;
|
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). */
|
/** 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import {
|
||||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
useAllExternalUsers,
|
||||||
|
userTypeEnum,
|
||||||
|
} from "@/super-admin/hooks/useExternalUsers";
|
||||||
import {
|
import {
|
||||||
ALL_TRADE_DIRECTIONS,
|
ALL_TRADE_DIRECTIONS,
|
||||||
TRADE_DIRECTION_LABELS,
|
TRADE_DIRECTION_LABELS,
|
||||||
@@ -34,17 +36,12 @@ type EmployeeRow = {
|
|||||||
* and overview to the checked directions. Admins always bypass the scope.
|
* and overview to the checked directions. Admins always bypass the scope.
|
||||||
*/
|
*/
|
||||||
export default function TradeAccessPage() {
|
export default function TradeAccessPage() {
|
||||||
const { user } = useAuth();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const organizationId =
|
const { data: usersResponse, isLoading: usersLoading } = useAllExternalUsers({
|
||||||
user?.employee && user.employee.length > 0
|
userType: userTypeEnum.employee,
|
||||||
? user.employee[0].organizationId
|
take: 3000,
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({
|
|
||||||
organizationId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: configs, isLoading: configsLoading } = useQuery({
|
const { data: configs, isLoading: configsLoading } = useQuery({
|
||||||
@@ -76,12 +73,12 @@ export default function TradeAccessPage() {
|
|||||||
}, [configs]);
|
}, [configs]);
|
||||||
|
|
||||||
const rows: EmployeeRow[] = useMemo(() => {
|
const rows: EmployeeRow[] = useMemo(() => {
|
||||||
const items = employeesResponseByOrg?.items ?? [];
|
const items = usersResponse?.items ?? [];
|
||||||
const mapped = items
|
const mapped = items
|
||||||
.map((item: { user?: { id?: string; name?: { en?: string }; email?: string; username?: string } }) => ({
|
.map((u) => ({
|
||||||
userId: item.user?.id ?? "",
|
userId: u.id ?? "",
|
||||||
name: item.user?.name?.en ?? item.user?.username ?? "—",
|
name: u.name?.en ?? u.username ?? "—",
|
||||||
email: item.user?.email ?? "",
|
email: u.email ?? "",
|
||||||
}))
|
}))
|
||||||
.filter((r: EmployeeRow) => r.userId);
|
.filter((r: EmployeeRow) => r.userId);
|
||||||
const term = search.trim().toLowerCase();
|
const term = search.trim().toLowerCase();
|
||||||
@@ -91,7 +88,7 @@ export default function TradeAccessPage() {
|
|||||||
r.name.toLowerCase().includes(term) ||
|
r.name.toLowerCase().includes(term) ||
|
||||||
r.email.toLowerCase().includes(term),
|
r.email.toLowerCase().includes(term),
|
||||||
);
|
);
|
||||||
}, [employeesResponseByOrg, search]);
|
}, [usersResponse, search]);
|
||||||
|
|
||||||
// No row yet = unrestricted, so render as all three checked.
|
// No row yet = unrestricted, so render as all three checked.
|
||||||
const directionsFor = (userId: string): TradeDirection[] =>
|
const directionsFor = (userId: string): TradeDirection[] =>
|
||||||
@@ -105,7 +102,7 @@ export default function TradeAccessPage() {
|
|||||||
saveMutation.mutate({ userId, directions: next });
|
saveMutation.mutate({ userId, directions: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
const loading = isLoadingEmployeesByOrg || configsLoading;
|
const loading = usersLoading || configsLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
|
|||||||
@@ -691,6 +691,30 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
accessorKey: "hasFacility",
|
accessorKey: "hasFacility",
|
||||||
format: "boolean",
|
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" },
|
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
||||||
activeColumn,
|
activeColumn,
|
||||||
],
|
],
|
||||||
@@ -710,6 +734,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
description:
|
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.",
|
"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" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export enum userTypeEnum {
|
|||||||
external = "external_organization",
|
external = "external_organization",
|
||||||
individual = "individual",
|
individual = "individual",
|
||||||
externalUsers = "external_organization,individual",
|
externalUsers = "external_organization,individual",
|
||||||
|
employee = "employee",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalQueryParams {
|
export interface ExternalQueryParams {
|
||||||
|
|||||||
Reference in New Issue
Block a user