import { PaginatedResponse } from '@edr/types'; import { BadRequestException, 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, TargetDimension, TargetMetric, TargetPeriodType, } from './entities/operations-target.entity'; import { CARGO_CATEGORIES, CONTAINER_CLASSES, } from '../reports/operations-classification'; const MS_PER_DAY = 86_400_000; /** * Normalises any date inside a bucket to the bucket's first day, matching the * bucket expression the reports group by (`PERIOD_UNITS` in * `reports/revenue-classification.ts`). Week starts Monday, the same as * `date_trunc('week', …)` and ISO week numbering. * * The four units Postgres has no `date_trunc` for are anchored to the calendar * year, exactly as their SQL twins are: half-years at Jan/Jul, nine-months at * Jan/Oct, ninety-days at day 1/91/181/271. **This function and * `PERIOD_UNITS[...].truncOn` must agree** — a target whose `period_start` is * not a real block start plans against a bucket boundary that does not exist. * * 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 'day': break; 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 'half_year': d.setUTCMonth(Math.floor(d.getUTCMonth() / 6) * 6, 1); break; case 'nine_month': // Two blocks a year, not 1.33: Jan–Sep, then a short Oct–Dec. d.setUTCMonth(Math.floor(d.getUTCMonth() / 9) * 9, 1); break; case 'ninety_day': { // Day-of-year, zero-based, so this matches SQL's 1-based `(doy - 1) / 90`. // Capped at block 3 for the same reason the SQL caps it: uncapped, the // last days of December become a 5-day stub block of their own. const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1); const dayIndex = Math.floor((d.getTime() - yearStart) / MS_PER_DAY); d.setTime(yearStart + Math.min(Math.floor(dayIndex / 90), 3) * 90 * MS_PER_DAY); 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; /** * The keys a target may be stored against, per dimension. A report matches a * target by this exact string, so a key outside the set here is a plan no * report can ever find — and nothing downstream would ever say so. `station` is * absent on purpose: yard codes are admin-managed rows, resolved live. * * `UNCLASSIFIED` is accepted for `cargo_category` even though the admin form * does not offer it, because `CARGO_CATEGORY_EXPR` does emit it — rejecting a * key the reports can match would be stricter than the reports themselves. */ const KEYS_BY_DIMENSION: Record, Set> = { cargo_category: new Set(CARGO_CATEGORIES.map((o) => o.value)), container_class: new Set(CONTAINER_CLASSES.map((o) => o.value)), }; /** The columns that decide which report row a target lines up with. */ type TargetSlot = Pick< OperationsTarget, 'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory' >; @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 slot = await this.resolveSlot(dto); await this.assertSlotFree(slot); return this.repository.save(this.repository.create({ ...dto, ...slot })); } async update(id: string, dto: UpdateOperationsTargetDto): Promise { const current = await this.findById(id); const slot = await this.resolveSlot({ periodType: dto.periodType ?? current.periodType, periodStart: dto.periodStart ?? current.periodStart, metric: dto.metric ?? current.metric, dimension: dto.dimension ?? current.dimension, dimensionKey: dto.dimensionKey ?? current.dimensionKey, // An absent key means "unchanged" only while the dimension still wants a // category at all — `resolveSlot` drops it when the dimension no longer // does, which is the whole point of routing both paths through it. cargoCategory: dto.cargoCategory !== undefined ? dto.cargoCategory : current.cargoCategory, }); await this.assertSlotFree(slot, id); await this.repository.update(id, { ...slot, ...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}), ...(dto.note !== undefined ? { note: dto.note } : {}), }); return this.findById(id); } /** * Everything that decides which report row a target lines up with, resolved * in one place so `create` and `update` cannot drift apart. * * `cargoCategory` is **derived from the dimension, never carried over**. A * station's plan is per station AND per cargo type; the other two dimensions * already carry the category in `dimensionKey`. A stale category left on a * row whose dimension has moved on is not cosmetic — it survives the * `COALESCE(cargo_category, '')` unique index alongside the legitimate * null-category row, `plannedRowsSql` groups by it, and the two plan rows * then both join the same operated row: the category lists twice, each time * carrying the full operated tonnage, while the summary tiles stay correct. */ private async resolveSlot(input: { periodType: TargetPeriodType; periodStart: string; metric: TargetMetric; dimension: TargetDimension; dimensionKey: string; cargoCategory?: string | null; }): Promise { const periodStart = normalisePeriodStart(input.periodType, input.periodStart); await this.assertDimensionKey(input.dimension, input.dimensionKey); const base = { periodType: input.periodType, periodStart, metric: input.metric, dimension: input.dimension, dimensionKey: input.dimensionKey, }; if (input.dimension !== 'station') { return { ...base, cargoCategory: null }; } const cargoCategory = input.cargoCategory || null; if (!cargoCategory) { throw new BadRequestException( 'A station target needs a cargo category — the plan is per station and per cargo type. ' + 'Without one the report has nothing to match it against.', ); } if (!KEYS_BY_DIMENSION.cargo_category.has(cargoCategory)) { throw new BadRequestException( `"${cargoCategory}" is not a cargo category the reports produce. ` + `Expected one of: ${[...KEYS_BY_DIMENSION.cargo_category].join(', ')}`, ); } return { ...base, cargoCategory }; } /** * A `dimensionKey` the reports never emit is a plan that silently never * joins — the row lists fine and its label falls back to the raw key, so * nothing downstream ever reports the mistake. Cheaper to reject on write. */ private async assertDimensionKey(dimension: TargetDimension, key: string): Promise { if (dimension === 'station') { const yards = await this.yardLabels(); if (!yards.has(key)) { throw new BadRequestException( `"${key}" is not a known station code. A station target is keyed on ` + '`yards.code`, which is what the reports match against.', ); } return; } const allowed = KEYS_BY_DIMENSION[dimension]; if (!allowed.has(key)) { throw new BadRequestException( `"${key}" is not a ${TARGET_DIMENSION_LABELS[dimension].toLowerCase()} the reports ` + `produce. Expected one of: ${[...allowed].join(', ')}`, ); } } 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`, ); } } }