Files
edr-platform/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts
Nathnael f28b6faf29 feat(operations-reporting): plan station targets per cargo category
The OCC report plans a station lane per cargo type — Nagad–Mojo container
and Nagad–Mojo fertilizer are separate numbers — but a target's identity
was period + metric + dimension + dimensionKey, so the two collided on
one slot. `cargo_category` is now part of the row and of the uniqueness
check; it stays null for cargo_category and container_class targets,
whose dimensionKey already carries the category.

The config grid showed raw codes (VOLUME_TONS, cargo_category, a yard
code). The list read now sends readable twins alongside the stored codes,
which stay exactly as they are because the reports join on them — the
same shape YardDistancesService uses. Labels resolve per dimension rather
than from one merged map: CONTAINER_EXPORT exists in both vocabularies
and reads differently in each, and merging them gave every cargo-category
row the container-class wording.
2026-08-20 11:36:32 +00:00

219 lines
8.1 KiB
TypeScript

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<string, Map<string, string>> = {
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<OperationsTarget>,
) {}
/** Yard code → label, for station targets. Reference data, read per list. */
private async yardLabels(): Promise<Map<string, string>> {
const rows = await this.repository.manager.query<Array<{ code: string; label: string }>>(
`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<string, string>): 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<PaginatedResponse<OperationsTargetRow>> {
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}%` }),
),
);
}
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<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);
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<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,
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<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' | 'cargoCategory'
>,
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,
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`,
);
}
}
}