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, TARGET_DIMENSION_LABELS, TARGET_METRIC_LABELS, TARGET_PERIOD_LABELS, TargetPeriodType, } from './entities/operations-target.entity'; import { CARGO_CATEGORIES, CONTAINER_CLASSES, } from '../reports/operations-classification'; /** * 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); } /** * Flat row shape for the backoffice config grid: the stored codes stay put — * the reports join on them — and readable twins ride alongside, the same way * `YardDistancesService` adds `fromYardLabel`. */ export type OperationsTargetRow = OperationsTarget & { metricLabel: string; dimensionLabel: string; periodLabel: string; appliesToLabel: string; cargoCategoryLabel: string; }; /** * Resolved per dimension, not from one merged map: `CONTAINER_EXPORT` is in * both vocabularies and reads differently in each ("Export container" as a * cargo category, "Full export container" as a container class). Merging them * silently gave every cargo-category row the container-class wording. */ const LABELS_BY_DIMENSION: Record> = { cargo_category: new Map(CARGO_CATEGORIES.map((o) => [o.value, o.label])), container_class: new Map(CONTAINER_CLASSES.map((o) => [o.value, o.label])), }; const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category; @Injectable() export class OperationsTargetsService { constructor( @InjectRepository(OperationsTarget) private readonly repository: Repository, ) {} /** Yard code → label, for station targets. Reference data, read per list. */ private async yardLabels(): Promise> { const rows = await this.repository.manager.query>( `SELECT code, label FROM freight.yards WHERE deleted_at IS NULL`, ); return new Map(rows.map((r) => [r.code, r.label])); } private toRow(target: OperationsTarget, yards: Map): OperationsTargetRow { const appliesToLabel = target.dimension === 'station' ? (yards.get(target.dimensionKey) ?? target.dimensionKey) : (LABELS_BY_DIMENSION[target.dimension]?.get(target.dimensionKey) ?? target.dimensionKey); return Object.assign(target, { metricLabel: TARGET_METRIC_LABELS[target.metric] ?? target.metric, dimensionLabel: TARGET_DIMENSION_LABELS[target.dimension] ?? target.dimension, periodLabel: TARGET_PERIOD_LABELS[target.periodType] ?? target.periodType, appliesToLabel, // Only station targets carry one, and it is always a cargo category. cargoCategoryLabel: target.cargoCategory ? (CARGO_CATEGORY_LABELS.get(target.cargoCategory) ?? target.cargoCategory) : '', }); } async findAll( query: ListOperationsTargetsQueryDto, ): Promise> { const sortable: Record = { 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}%` }), ), ); } const [page, yards] = await Promise.all([paginateQuery(qb, query), this.yardLabels()]); return { ...page, items: page.items.map((t) => this.toRow(t, yards)) }; } async findById(id: string): Promise { 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 { const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart); const cargoCategory = dto.cargoCategory ?? null; await this.assertSlotFree({ ...dto, periodStart, cargoCategory }); return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory })); } async update(id: string, dto: UpdateOperationsTargetDto): Promise { 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, cargoCategory: dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null, }; 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 { 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' | 'cargoCategory' >, ignoreId?: string, ): Promise { const existing = await this.repository.findOne({ where: { periodType: slot.periodType, periodStart: slot.periodStart, metric: slot.metric, dimension: slot.dimension, dimensionKey: slot.dimensionKey, cargoCategory: slot.cargoCategory ?? IsNull(), 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`, ); } } }