mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Targets could only be committed weekly, monthly, quarterly or yearly, so a figure the business quotes per half-year or per 90 days had to be split by hand into buckets it was never expressed in. The reports already re-gather a target into whatever grain the viewer asks for; this just lets the plan be entered at the grain it was agreed in. Adds day, half-year, nine-month and 90-day, matching the report units added alongside. normalisePeriodStart snaps each to its block start with the same calendar-year anchoring the SQL uses — Jan/Jul for half-years, Jan/Oct for nine-months, days 1/91/181/271 for 90-day blocks, including the same cap on the fourth block so late December does not snap into a stub of its own. That agreement is the load-bearing part. The unique index is keyed on period_start, and a target snapped to a boundary the report does not bucket on is a plan measured against a period that does not exist. The two halves live in different files and different languages, so the spec pins the boundaries rather than trusting them to stay in step. Adds operations-targets.service.spec.ts, which the module had none of: every period type, idempotency, the leap year, and the block-four cap.
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
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<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;
|
||
|
||
/**
|
||
* 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<Exclude<TargetDimension, 'station'>, Set<string>> = {
|
||
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<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 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<OperationsTarget> {
|
||
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<TargetSlot> {
|
||
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<void> {
|
||
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<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`,
|
||
);
|
||
}
|
||
}
|
||
}
|