From f28b6faf2961c9c66fafff9892206c4b73fdb2f4 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 11:36:32 +0000 Subject: [PATCH] feat(operations-reporting): plan station targets per cargo category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...000000000-OperationsTargetCargoCategory.ts | 51 ++++++++++++ .../dto/create-operations-target.dto.ts | 10 +++ .../entities/operations-target.entity.ts | 33 ++++++++ .../operations-targets.service.ts | 82 +++++++++++++++++-- .../src/pages/ruleEngine/config/resources.ts | 48 +++++++---- 5 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts diff --git a/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts b/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts new file mode 100644 index 000000000..c77087044 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3590000000000-OperationsTargetCargoCategory.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A station's plan is per station AND per cargo type, not per station. + * + * The OCC monthly report plans "Nagad–Mojo multimodal container 122,010 t" and + * "Nagad–Mojo fertilizer 18,000 t" as separate lines against the same station, + * which the single `dimension_key` column cannot express: a station-keyed target + * would apply the whole station's plan to each of its cargo types. + * + * `cargo_category` is nullable, so `cargo_category` and `container_class` + * targets are unaffected — they leave it null and stay keyed on + * `dimension_key` alone. The uniqueness index moves to include it, since + * (station, category) is now the slot. + */ +export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface { + name = "OperationsTargetCargoCategory3590000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.operations_targets + ADD COLUMN IF NOT EXISTS cargo_category varchar(60); + `); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`); + + // COALESCE rather than a plain column list: a partial unique index treats + // NULLs as distinct, which would let the same category target be entered + // twice over. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets ( + period_type, period_start, metric, dimension, dimension_key, + COALESCE(cargo_category, '') + ) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot + ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key) + WHERE deleted_at IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts index 11a23fa87..2d8f5beb0 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts index f62224853..aec11ae24 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts @@ -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 = { + TEU: 'TEU', + TRAINSET: 'Trainsets', + VOLUME_TONS: 'Volume (tons)', +}; + +export const TARGET_DIMENSION_LABELS: Record = { + cargo_category: 'Cargo category', + station: 'Station', + container_class: 'Container class', +}; + +export const TARGET_PERIOD_LABELS: Record = { + 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 Nagad–Mojo container and Nagad–Mojo 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; } diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts index 6a2706f29..c33054aa5 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -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> = { + 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, ) {} - findAll(query: ListOperationsTargetsQueryDto): Promise> { + /** 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', @@ -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 { @@ -87,8 +153,9 @@ export class OperationsTargetsService { async create(dto: CreateOperationsTargetDto): Promise { 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 { @@ -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 { @@ -136,6 +205,7 @@ export class OperationsTargetsService { metric: slot.metric, dimension: slot.dimension, dimensionKey: slot.dimensionKey, + cargoCategory: slot.cargoCategory ?? IsNull(), deletedAt: IsNull(), }, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 2f8fedd29..cc899322a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -671,14 +671,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ "Planned TEU, trainsets and tonnage per period — the Plan column in the operations reports", searchPlaceholder: "Search by category, station or note...", supportsSearch: true, - cardTitleKey: "dimensionKey", + cardTitleKey: "appliesToLabel", cardSubtitleKey: "periodStart", columns: [ + // The *Label columns are readable twins the API sends alongside the stored + // codes (see OperationsTargetsService.toRow) — the codes themselves are + // enums the reports join on and stay out of the grid. { id: "periodStart", header: "Period start", accessorKey: "periodStart", format: "date" }, - { id: "periodType", header: "Period", accessorKey: "periodType" }, - { id: "metric", header: "Metric", accessorKey: "metric" }, - { id: "dimension", header: "Dimension", accessorKey: "dimension" }, - { id: "dimensionKey", header: "Applies to", accessorKey: "dimensionKey" }, + { id: "periodLabel", header: "Period", accessorKey: "periodLabel" }, + { id: "metricLabel", header: "Metric", accessorKey: "metricLabel" }, + { id: "dimensionLabel", header: "Plan by", accessorKey: "dimensionLabel" }, + { id: "appliesToLabel", header: "Applies to", accessorKey: "appliesToLabel" }, + { id: "cargoCategoryLabel", header: "Cargo category", accessorKey: "cargoCategoryLabel" }, { id: "plannedValue", header: "Plan", accessorKey: "plannedValue", format: "number" }, ], formFields: [ @@ -710,12 +714,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ label: "Period start", type: "date", required: true, - description: - "Any date inside the period — it is snapped to the start of the week, month, quarter or year on save.", + description: "Any date inside the period — snapped to its start on save.", }, { name: "dimension", - label: "Applies to", + label: "Plan by", type: "select", required: true, options: [ @@ -726,22 +729,39 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ }, { name: "dimensionKey", - label: "Category / station code", + label: "Applies to", type: "select", required: true, - placeholder: "Select what the target applies to", + placeholder: "Select", // The valid keys depend on the chosen dimension, and must match what the - // reports emit exactly — a typo here is a target the report never finds. + // reports emit exactly — a mismatch here is a target the report never + // finds. Station options are the live yard codes, injected by + // RuleEngineResourcePage. optionsFromValues: (values) => { const dimension = String(values.dimension ?? ""); if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES; if (dimension === "station") return []; return OPERATIONS_CARGO_CATEGORIES; }, - description: - "Station targets are keyed on the yard code (KALITY, MOJO, NAGAD…) — type it exactly as it appears on Yards.", }, - { name: "plannedValue", label: "Planned value", type: "number", required: true }, + { + name: "cargoCategory", + label: "Cargo category", + type: "select", + required: true, + // A station's plan is per station AND per cargo type — the OCC report + // plans Nagad-Mojo container and Nagad-Mojo fertilizer separately. The + // other two dimensions already carry the category in the key above. + showWhen: { field: "dimension", equals: ["station"] }, + options: OPERATIONS_CARGO_CATEGORIES, + }, + { + name: "plannedValue", + label: "Planned value", + type: "number", + required: true, + description: "TEU, trainsets or tonnes — whichever the metric above is.", + }, { name: "note", label: "Note", type: "text", optional: true }, ], },