mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
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:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,16 @@ export class CreateOperationsTargetDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
plannedValue!: number;
|
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()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -13,6 +13,30 @@ export type TargetMetric = (typeof TARGET_METRICS)[number];
|
|||||||
export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const;
|
export const TARGET_DIMENSIONS = ['cargo_category', 'station', 'container_class'] as const;
|
||||||
export type TargetDimension = (typeof TARGET_DIMENSIONS)[number];
|
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
|
* 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,
|
* operations reporting spec. One row is one planned number: a period, a metric,
|
||||||
@@ -57,6 +81,15 @@ export class OperationsTarget extends BaseEntity {
|
|||||||
})
|
})
|
||||||
plannedValue!: number;
|
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 })
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,17 @@ import { paginateQuery } from '../../common/utils/pagination.util';
|
|||||||
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
|
import { CreateOperationsTargetDto } from './dto/create-operations-target.dto';
|
||||||
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
|
import { ListOperationsTargetsQueryDto } from './dto/list-operations-targets-query.dto';
|
||||||
import { UpdateOperationsTargetDto } from './dto/update-operations-target.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
|
* 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);
|
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()
|
@Injectable()
|
||||||
export class OperationsTargetsService {
|
export class OperationsTargetsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -47,7 +83,36 @@ export class OperationsTargetsService {
|
|||||||
private readonly repository: Repository<OperationsTarget>,
|
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> = {
|
const sortable: Record<string, string> = {
|
||||||
periodStart: 'target.period_start',
|
periodStart: 'target.period_start',
|
||||||
metric: 'target.metric',
|
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> {
|
async findById(id: string): Promise<OperationsTarget> {
|
||||||
@@ -87,8 +153,9 @@ export class OperationsTargetsService {
|
|||||||
|
|
||||||
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
|
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
|
||||||
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
|
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
|
||||||
await this.assertSlotFree({ ...dto, periodStart });
|
const cargoCategory = dto.cargoCategory ?? null;
|
||||||
return this.repository.save(this.repository.create({ ...dto, periodStart }));
|
await this.assertSlotFree({ ...dto, periodStart, cargoCategory });
|
||||||
|
return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
|
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
|
||||||
@@ -101,6 +168,8 @@ export class OperationsTargetsService {
|
|||||||
metric: dto.metric ?? current.metric,
|
metric: dto.metric ?? current.metric,
|
||||||
dimension: dto.dimension ?? current.dimension,
|
dimension: dto.dimension ?? current.dimension,
|
||||||
dimensionKey: dto.dimensionKey ?? current.dimensionKey,
|
dimensionKey: dto.dimensionKey ?? current.dimensionKey,
|
||||||
|
cargoCategory:
|
||||||
|
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null,
|
||||||
};
|
};
|
||||||
await this.assertSlotFree(next, id);
|
await this.assertSlotFree(next, id);
|
||||||
|
|
||||||
@@ -125,7 +194,7 @@ export class OperationsTargetsService {
|
|||||||
private async assertSlotFree(
|
private async assertSlotFree(
|
||||||
slot: Pick<
|
slot: Pick<
|
||||||
OperationsTarget,
|
OperationsTarget,
|
||||||
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey'
|
'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory'
|
||||||
>,
|
>,
|
||||||
ignoreId?: string,
|
ignoreId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -136,6 +205,7 @@ export class OperationsTargetsService {
|
|||||||
metric: slot.metric,
|
metric: slot.metric,
|
||||||
dimension: slot.dimension,
|
dimension: slot.dimension,
|
||||||
dimensionKey: slot.dimensionKey,
|
dimensionKey: slot.dimensionKey,
|
||||||
|
cargoCategory: slot.cargoCategory ?? IsNull(),
|
||||||
deletedAt: IsNull(),
|
deletedAt: IsNull(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -671,14 +671,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
"Planned TEU, trainsets and tonnage per period — the Plan column in the operations reports",
|
"Planned TEU, trainsets and tonnage per period — the Plan column in the operations reports",
|
||||||
searchPlaceholder: "Search by category, station or note...",
|
searchPlaceholder: "Search by category, station or note...",
|
||||||
supportsSearch: true,
|
supportsSearch: true,
|
||||||
cardTitleKey: "dimensionKey",
|
cardTitleKey: "appliesToLabel",
|
||||||
cardSubtitleKey: "periodStart",
|
cardSubtitleKey: "periodStart",
|
||||||
columns: [
|
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: "periodStart", header: "Period start", accessorKey: "periodStart", format: "date" },
|
||||||
{ id: "periodType", header: "Period", accessorKey: "periodType" },
|
{ id: "periodLabel", header: "Period", accessorKey: "periodLabel" },
|
||||||
{ id: "metric", header: "Metric", accessorKey: "metric" },
|
{ id: "metricLabel", header: "Metric", accessorKey: "metricLabel" },
|
||||||
{ id: "dimension", header: "Dimension", accessorKey: "dimension" },
|
{ id: "dimensionLabel", header: "Plan by", accessorKey: "dimensionLabel" },
|
||||||
{ id: "dimensionKey", header: "Applies to", accessorKey: "dimensionKey" },
|
{ id: "appliesToLabel", header: "Applies to", accessorKey: "appliesToLabel" },
|
||||||
|
{ id: "cargoCategoryLabel", header: "Cargo category", accessorKey: "cargoCategoryLabel" },
|
||||||
{ id: "plannedValue", header: "Plan", accessorKey: "plannedValue", format: "number" },
|
{ id: "plannedValue", header: "Plan", accessorKey: "plannedValue", format: "number" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
@@ -710,12 +714,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
label: "Period start",
|
label: "Period start",
|
||||||
type: "date",
|
type: "date",
|
||||||
required: true,
|
required: true,
|
||||||
description:
|
description: "Any date inside the period — snapped to its start on save.",
|
||||||
"Any date inside the period — it is snapped to the start of the week, month, quarter or year on save.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dimension",
|
name: "dimension",
|
||||||
label: "Applies to",
|
label: "Plan by",
|
||||||
type: "select",
|
type: "select",
|
||||||
required: true,
|
required: true,
|
||||||
options: [
|
options: [
|
||||||
@@ -726,22 +729,39 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "dimensionKey",
|
name: "dimensionKey",
|
||||||
label: "Category / station code",
|
label: "Applies to",
|
||||||
type: "select",
|
type: "select",
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: "Select what the target applies to",
|
placeholder: "Select",
|
||||||
// The valid keys depend on the chosen dimension, and must match what the
|
// 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) => {
|
optionsFromValues: (values) => {
|
||||||
const dimension = String(values.dimension ?? "");
|
const dimension = String(values.dimension ?? "");
|
||||||
if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES;
|
if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES;
|
||||||
if (dimension === "station") return [];
|
if (dimension === "station") return [];
|
||||||
return OPERATIONS_CARGO_CATEGORIES;
|
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 },
|
{ name: "note", label: "Note", type: "text", optional: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user