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.
This commit is contained in:
Nathnael
2026-08-20 11:36:32 +00:00
parent 2ec818e590
commit f28b6faf29
5 changed files with 204 additions and 20 deletions

View File

@@ -56,6 +56,16 @@ export class CreateOperationsTargetDto {
@Min(0)
plannedValue!: number;
@ApiPropertyOptional({
description:
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.',
example: 'CONTAINER_IMPORT_MULTIMODAL',
})
@IsOptional()
@IsString()
@MaxLength(60)
cargoCategory?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -13,6 +13,30 @@ export type TargetMetric = (typeof TARGET_METRICS)[number];
export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const;
export type TargetDimension = (typeof TARGET_DIMENSIONS)[number];
/**
* How each stored code reads on screen. The columns are enums the reports match
* on, so the stored values must stay exactly as they are — these exist for the
* admin grid, which otherwise shows `VOLUME_TONS` and `cargo_category` verbatim.
*/
export const TARGET_METRIC_LABELS: Record<TargetMetric, string> = {
TEU: 'TEU',
TRAINSET: 'Trainsets',
VOLUME_TONS: 'Volume (tons)',
};
export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
cargo_category: 'Cargo category',
station: 'Station',
container_class: 'Container class',
};
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
week: 'Weekly',
month: 'Monthly',
quarter: 'Quarterly',
year: 'Yearly',
};
/**
* The planned side of every "Plan / Operated / Implement Rate" table in the
* operations reporting spec. One row is one planned number: a period, a metric,
@@ -57,6 +81,15 @@ export class OperationsTarget extends BaseEntity {
})
plannedValue!: number;
/**
* Only for `station` targets, where the plan is per station AND per cargo
* type — the OCC report plans NagadMojo container and NagadMojo fertilizer
* separately. Null on `cargo_category` and `container_class` targets, whose
* `dimensionKey` already carries the category.
*/
@Column({ name: 'cargo_category', type: 'varchar', length: 60, nullable: true })
cargoCategory?: string | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
}

View File

@@ -7,7 +7,17 @@ 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, TargetPeriodType } from './entities/operations-target.entity';
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
@@ -40,6 +50,32 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string
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(
@@ -47,7 +83,36 @@ export class OperationsTargetsService {
private readonly repository: Repository<OperationsTarget>,
) {}
findAll(query: ListOperationsTargetsQueryDto): Promise<PaginatedResponse<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',
@@ -76,7 +141,8 @@ export class OperationsTargetsService {
);
}
return paginateQuery(qb, query);
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> {
@@ -87,8 +153,9 @@ export class OperationsTargetsService {
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
await this.assertSlotFree({ ...dto, periodStart });
return this.repository.save(this.repository.create({ ...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> {
@@ -101,6 +168,8 @@ export class OperationsTargetsService {
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);
@@ -125,7 +194,7 @@ export class OperationsTargetsService {
private async assertSlotFree(
slot: Pick<
OperationsTarget,
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey'
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory'
>,
ignoreId?: string,
): Promise<void> {
@@ -136,6 +205,7 @@ export class OperationsTargetsService {
metric: slot.metric,
dimension: slot.dimension,
dimensionKey: slot.dimensionKey,
cargoCategory: slot.cargoCategory ?? IsNull(),
deletedAt: IsNull(),
},
});