feat(WIP): filtering, exporting and more reports

This commit is contained in:
Nathnael
2026-08-19 13:51:18 +00:00
parent e964a9b8f4
commit fb21ad1541
56 changed files with 3750 additions and 27 deletions

View File

@@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsDateString,
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import {
TARGET_DIMENSIONS,
TARGET_METRICS,
TARGET_PERIOD_TYPES,
TargetDimension,
TargetMetric,
TargetPeriodType,
} from '../entities/operations-target.entity';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
export class CreateOperationsTargetDto {
@ApiProperty({ enum: TARGET_PERIOD_TYPES })
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
periodType!: TargetPeriodType;
@ApiProperty({
example: '2026-08-01',
description: 'Any date inside the bucket — normalised to the bucket start on write.',
})
@IsDateString()
periodStart!: string;
@ApiProperty({ enum: TARGET_METRICS })
@IsIn(TARGET_METRICS as unknown as string[])
metric!: TargetMetric;
@ApiProperty({ enum: TARGET_DIMENSIONS })
@IsIn(TARGET_DIMENSIONS as unknown as string[])
dimension!: TargetDimension;
@ApiProperty({
example: 'CONTAINER_IMPORT_MULTIMODAL',
description: 'Category key, container-class key or yard code — not a display label.',
})
@IsString()
@MaxLength(60)
dimensionKey!: string;
@ApiProperty({ example: 1200 })
@Transform(toNumber)
@IsNumber()
@Min(0)
plannedValue!: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
TARGET_DIMENSIONS,
TARGET_METRICS,
TARGET_PERIOD_TYPES,
TargetDimension,
TargetMetric,
TargetPeriodType,
} from '../entities/operations-target.entity';
export class ListOperationsTargetsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: TARGET_PERIOD_TYPES })
@IsOptional()
@IsIn(TARGET_PERIOD_TYPES as unknown as string[])
periodType?: TargetPeriodType;
@ApiPropertyOptional({ enum: TARGET_METRICS })
@IsOptional()
@IsIn(TARGET_METRICS as unknown as string[])
metric?: TargetMetric;
@ApiPropertyOptional({ enum: TARGET_DIMENSIONS })
@IsOptional()
@IsIn(TARGET_DIMENSIONS as unknown as string[])
dimension?: TargetDimension;
}

View File

@@ -0,0 +1,118 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
/**
* Every field optional — the backoffice form PATCHes only what changed. A
* standard of zero is rejected: it would make every implement-rate division
* blow up or read as infinite achievement.
*/
export class UpdateOperationsStandardsDto {
@ApiPropertyOptional({ example: 10 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
stationStandardHoursEthiopia?: number;
@ApiPropertyOptional({ example: 13 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
stationStandardHoursDjibouti?: number;
@ApiPropertyOptional({ example: 65 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursContainer?: number;
@ApiPropertyOptional({ example: 88 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursBulkDmp?: number;
@ApiPropertyOptional({ example: 96 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursBulkNagad?: number;
@ApiPropertyOptional({ example: 96 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
cycleStandardHoursBulkBcc?: number;
@ApiPropertyOptional({ example: 21 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
defaultLegStandardHours?: number;
@ApiPropertyOptional({ example: 30 })
@IsOptional()
@Transform(toNumber)
@IsInt()
@Min(0)
delayToleranceMinutes?: number;
@ApiPropertyOptional({ example: 20 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsFull20ft?: number;
@ApiPropertyOptional({ example: 40 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsFull40ft?: number;
@ApiPropertyOptional({ example: 2.24 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsEmpty20ft?: number;
@ApiPropertyOptional({ example: 3.88 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsEmpty40ft?: number;
@ApiPropertyOptional({ example: 70 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsPerWagonGeneral?: number;
@ApiPropertyOptional({ example: 38 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0.01)
chargedTonsPerWagonPerishable?: number;
@ApiPropertyOptional({ example: 50 })
@IsOptional()
@Transform(toNumber)
@IsInt()
@Min(1)
defaultFullTrainsetWagons?: number;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateOperationsTargetDto } from './create-operations-target.dto';
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}

View File

@@ -0,0 +1,185 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity } from 'typeorm';
/**
* numeric comes back from pg as a string. Every value here is arithmetic in a
* report expression, so convert on read rather than making each caller do it.
*/
const asNumber = {
to: (value: number) => value,
from: (value: string | null) => (value === null ? null : Number(value)),
};
/**
* Single-row table holding the railway's operating standards — the numbers the
* operations reports measure actual performance against. Same single-row shape
* as `logo_settings` and `exchange_settings`; the app never inserts a second row.
*
* These live in the database rather than in a constants file because the
* business treats them as tunable (the corridor standard is explicitly
* described as "flexible"), and a planner must be able to change one without a
* deployment.
*/
@Entity({ schema: 'freight', name: 'operations_standards' })
export class OperationsStandard extends BaseEntity {
/** Standard time a train may stand at an Ethiopian station, in hours. */
@Column({
name: 'station_standard_hours_ethiopia',
type: 'numeric',
precision: 6,
scale: 2,
default: 10,
transformer: asNumber,
})
stationStandardHoursEthiopia!: number;
/** Standard time a train may stand at a Djibouti station, in hours. */
@Column({
name: 'station_standard_hours_djibouti',
type: 'numeric',
precision: 6,
scale: 2,
default: 13,
transformer: asNumber,
})
stationStandardHoursDjibouti!: number;
/** Container turn-around cycle: 10 + 21 + 13 + 21. */
@Column({
name: 'cycle_standard_hours_container',
type: 'numeric',
precision: 6,
scale: 2,
default: 65,
transformer: asNumber,
})
cycleStandardHoursContainer!: number;
/** Bulk cycle via DMP: 13 + 21 + 33 + 21. */
@Column({
name: 'cycle_standard_hours_bulk_dmp',
type: 'numeric',
precision: 6,
scale: 2,
default: 88,
transformer: asNumber,
})
cycleStandardHoursBulkDmp!: number;
/** Bulk cycle via Negad freight yard: 13 + 21 + 41 + 21. */
@Column({
name: 'cycle_standard_hours_bulk_nagad',
type: 'numeric',
precision: 6,
scale: 2,
default: 96,
transformer: asNumber,
})
cycleStandardHoursBulkNagad!: number;
/** Bulk cycle via BCC: 13 + 21 + 41 + 21. */
@Column({
name: 'cycle_standard_hours_bulk_bcc',
type: 'numeric',
precision: 6,
scale: 2,
default: 96,
transformer: asNumber,
})
cycleStandardHoursBulkBcc!: number;
/**
* Standard running time for one corridor leg, used when the yard pair has no
* `yard_distances.standard_hours` of its own.
*/
@Column({
name: 'default_leg_standard_hours',
type: 'numeric',
precision: 6,
scale: 2,
default: 21,
transformer: asNumber,
})
defaultLegStandardHours!: number;
/** Grace on top of the leg standard before a train counts as delayed. */
@Column({ name: 'delay_tolerance_minutes', type: 'int', default: 30 })
delayToleranceMinutes!: number;
/** Charged tonnage per laden 20ft container. */
@Column({
name: 'charged_tons_full_20ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 20,
transformer: asNumber,
})
chargedTonsFull20ft!: number;
/** Charged tonnage per laden 40ft container. */
@Column({
name: 'charged_tons_full_40ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 40,
transformer: asNumber,
})
chargedTonsFull40ft!: number;
/** Charged tonnage per empty 20ft container. */
@Column({
name: 'charged_tons_empty_20ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 2.24,
transformer: asNumber,
})
chargedTonsEmpty20ft!: number;
/** Charged tonnage per empty 40ft container. */
@Column({
name: 'charged_tons_empty_40ft',
type: 'numeric',
precision: 8,
scale: 2,
default: 3.88,
transformer: asNumber,
})
chargedTonsEmpty40ft!: number;
/** Charged tonnage per wagon of steel, fertilizer, rice, sugar, livestock. */
@Column({
name: 'charged_tons_per_wagon_general',
type: 'numeric',
precision: 8,
scale: 2,
default: 70,
transformer: asNumber,
})
chargedTonsPerWagonGeneral!: number;
/** Charged tonnage per wagon of vegetables, milk, meat and other perishables. */
@Column({
name: 'charged_tons_per_wagon_perishable',
type: 'numeric',
precision: 8,
scale: 2,
default: 38,
transformer: asNumber,
})
chargedTonsPerWagonPerishable!: number;
/**
* Wagons in a full trainset when the cargo type has no
* `cargo_types.full_trainset_wagons` of its own.
*/
@Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 })
defaultFullTrainsetWagons!: number;
/** IAM user id of the last operator to change a standard. */
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/** Planning buckets the reports offer. Mirrors the reports' period filter. */
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const;
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
/** What is being planned. */
export const TARGET_METRICS = ['TEU', 'TRAINSET', 'VOLUME_TONS'] as const;
export type TargetMetric = (typeof TARGET_METRICS)[number];
/** Which axis `dimensionKey` names. */
export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const;
export type TargetDimension = (typeof TARGET_DIMENSIONS)[number];
/**
* The planned side of every "Plan / Operated / Implement Rate" table in the
* operations reporting spec. One row is one planned number: a period, a metric,
* and the dimension value it applies to.
*
* `dimensionKey` holds a category key (not a label) — the same keys
* `operations-classification.ts` emits, so a report can join on it directly.
*
* Uniqueness on the five-column slot is a partial index in the database
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a soft-deleted
* target can be re-created — the same choice `yard_distances` makes.
*/
@Entity({ schema: 'freight', name: 'operations_targets' })
@Index(['metric', 'periodType', 'periodStart'])
export class OperationsTarget extends BaseEntity {
@Column({ name: 'period_type', type: 'varchar', length: 10 })
periodType!: TargetPeriodType;
/** First day of the bucket, normalised on write (Monday, 1st, quarter start). */
@Column({ name: 'period_start', type: 'date' })
periodStart!: string;
@Column({ name: 'metric', type: 'varchar', length: 20 })
metric!: TargetMetric;
@Column({ name: 'dimension', type: 'varchar', length: 20 })
dimension!: TargetDimension;
/** Category key, container-class key, or yard code — never a display label. */
@Column({ name: 'dimension_key', type: 'varchar', length: 60 })
dimensionKey!: string;
@Column({
name: 'planned_value',
type: 'numeric',
precision: 14,
scale: 3,
transformer: {
to: (value: number) => value,
from: (value: string | null) => (value === null ? null : Number(value)),
},
})
plannedValue!: number;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
}

View File

@@ -0,0 +1,26 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OperationsStandard } from './entities/operations-standard.entity';
import { OperationsTarget } from './entities/operations-target.entity';
import { OperationsStandardsController } from './operations-standards.controller';
import { OperationsStandardsService } from './operations-standards.service';
import { OperationsTargetsController } from './operations-targets.controller';
import { OperationsTargetsService } from './operations-targets.service';
/**
* Reference data behind the operations reports: the railway's operating
* standards (one settings row) and the planned targets the reports compare
* actuals against.
*
* Global because the reports module reads the standards row on every run and
* has no other reason to import this.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
controllers: [OperationsStandardsController, OperationsTargetsController],
providers: [OperationsStandardsService, OperationsTargetsService],
exports: [OperationsStandardsService, OperationsTargetsService],
})
export class OperationsReportingModule {}

View File

@@ -0,0 +1,30 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
import { OperationsStandardsService } from './operations-standards.service';
@ApiTags('operations-standards')
@ApiBearerAuth()
@Controller('operations-standards')
export class OperationsStandardsController {
constructor(private readonly service: OperationsStandardsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: 'Standard times and charged-tonnage factors used by the operations reports' })
get() {
return this.service.get();
}
@Patch()
@BookingStaff([FREIGHT_PERMS.settings.operationsStandards.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: 'Change one or more operating standards' })
update(@Body() dto: UpdateOperationsStandardsDto, @CurrentUser() user: TCurrentUser) {
return this.service.update(dto, user?.id ?? null);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { UpdateOperationsStandardsDto } from './dto/update-operations-standards.dto';
import { OperationsStandard } from './entities/operations-standard.entity';
/**
* Owns the single `operations_standards` row — the times and tonnage factors
* every operations report measures actual performance against.
*
* The migration seeds the row, but `get()` creates it on demand as well: a
* report that cannot read a standard would have to fall back to a hardcoded
* number, which is exactly what putting these in the database was meant to
* avoid.
*/
@Injectable()
export class OperationsStandardsService {
constructor(
@InjectRepository(OperationsStandard)
private readonly repository: Repository<OperationsStandard>,
) {}
async get(): Promise<OperationsStandard> {
const existing = await this.repository.findOne({
where: { deletedAt: IsNull() },
order: { createdAt: 'ASC' },
});
if (existing) return existing;
// Every column has a database default, so an empty insert is the seed row.
return this.repository.save(this.repository.create({}));
}
async update(
dto: UpdateOperationsStandardsDto,
userId: string | null,
): Promise<OperationsStandard> {
const current = await this.get();
await this.repository.update(current.id, { ...dto, updatedById: userId });
return this.get();
}
}

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
import { OperationsTargetsService } from './operations-targets.service';
@ApiTags('operations-targets')
@Controller('operations-targets')
@ApiBearerAuth()
export class OperationsTargetsController {
constructor(private readonly service: OperationsTargetsService) {}
@Get()
@RuleEngineView('operations-targets')
@ApiOperation({ summary: 'List planned operational targets' })
findAll(@Query() query: ListOperationsTargetsQueryDto) {
return this.service.findAll(query);
}
@Get(':id')
@RuleEngineView('operations-targets')
@ApiOperation({ summary: 'Get a target by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineCreate('operations-targets')
@ApiOperation({ summary: 'Create a planned target' })
create(@Body() dto: CreateOperationsTargetDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('operations-targets')
@ApiOperation({ summary: 'Update a planned target' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateOperationsTargetDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('operations-targets')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a planned target' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,148 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, IsNull, Repository } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
import { UpdateOperationsTargetDto } from './dto/update-operations-target.dto';
import { OperationsTarget, TargetPeriodType } from './entities/operations-target.entity';
/**
* Normalises any date inside a bucket to the bucket's first day, matching
* Postgres `date_trunc` — which is what the reports group by. Week starts
* Monday, the same as `date_trunc('week', …)` and ISO week numbering.
*
* Done in UTC throughout: the stored column is a bare `date`, and running the
* arithmetic in local time would shift a 1st-of-month target into the previous
* month for anyone east of Greenwich.
*/
export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string {
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
switch (periodType) {
case 'week': {
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
const offset = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - offset);
break;
}
case 'month':
d.setUTCDate(1);
break;
case 'quarter':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
break;
case 'year':
d.setUTCMonth(0, 1);
break;
}
return d.toISOString().slice(0, 10);
}
@Injectable()
export class OperationsTargetsService {
constructor(
@InjectRepository(OperationsTarget)
private readonly repository: Repository<OperationsTarget>,
) {}
findAll(query: ListOperationsTargetsQueryDto): Promise<PaginatedResponse<OperationsTarget>> {
const sortable: Record<string, string> = {
periodStart: 'target.period_start',
metric: 'target.metric',
dimension: 'target.dimension',
dimensionKey: 'target.dimension_key',
plannedValue: 'target.planned_value',
createdAt: 'target.created_at',
};
const sortBy = sortable[query.sortBy ?? ''] ?? sortable.periodStart;
const qb = this.repository
.createQueryBuilder('target')
.orderBy(sortBy, query.sortOrder ?? 'DESC')
.addOrderBy('target.dimension_key', 'ASC');
if (query.periodType) qb.andWhere('target.period_type = :pt', { pt: query.periodType });
if (query.metric) qb.andWhere('target.metric = :m', { m: query.metric });
if (query.dimension) qb.andWhere('target.dimension = :d', { d: query.dimension });
if (query.search) {
qb.andWhere(
new Brackets((w) =>
w
.where('target.dimension_key ILIKE :s', { s: `%${query.search}%` })
.orWhere('target.note ILIKE :s', { s: `%${query.search}%` }),
),
);
}
return paginateQuery(qb, query);
}
async findById(id: string): Promise<OperationsTarget> {
const found = await this.repository.findOne({ where: { id } });
if (!found) throw new NotFoundException(`Operations target ${id} not found`);
return found;
}
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
await this.assertSlotFree({ ...dto, periodStart });
return this.repository.save(this.repository.create({ ...dto, periodStart }));
}
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
const current = await this.findById(id);
const periodType = dto.periodType ?? current.periodType;
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart);
const next = {
periodType,
periodStart,
metric: dto.metric ?? current.metric,
dimension: dto.dimension ?? current.dimension,
dimensionKey: dto.dimensionKey ?? current.dimensionKey,
};
await this.assertSlotFree(next, id);
await this.repository.update(id, {
...next,
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
...(dto.note !== undefined ? { note: dto.note } : {}),
});
return this.findById(id);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
/**
* One planned number per (period, metric, dimension value). The database
* enforces this too — the check is here to turn a 23505 into a message that
* says which slot is taken.
*/
private async assertSlotFree(
slot: Pick<
OperationsTarget,
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey'
>,
ignoreId?: string,
): Promise<void> {
const existing = await this.repository.findOne({
where: {
periodType: slot.periodType,
periodStart: slot.periodStart,
metric: slot.metric,
dimension: slot.dimension,
dimensionKey: slot.dimensionKey,
deletedAt: IsNull(),
},
});
if (existing && existing.id !== ignoreId) {
throw new ConflictException(
`A ${slot.metric} target for ${slot.dimensionKey} in the ${slot.periodType} starting ${slot.periodStart} already exists`,
);
}
}
}