mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +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). */
|
||||
|
||||
Reference in New Issue
Block a user