mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
feat(WIP): filtering, exporting and more reports
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateOperationsTargetDto } from './create-operations-target.dto';
|
||||
|
||||
export class UpdateOperationsTargetDto extends PartialType(CreateOperationsTargetDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user