mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
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.
219 lines
8.1 KiB
TypeScript
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|