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). */