Merge pull request #1381 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-21 22:48:14 +03:00
committed by GitHub
16 changed files with 1182 additions and 180 deletions

View File

@@ -58,13 +58,18 @@ export class CreateOperationsTargetDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.', 'Station targets only: which cargo category this station plan covers. Ignored for the ' +
'other dimensions, whose key already carries the category.',
example: 'CONTAINER_IMPORT_MULTIMODAL', example: 'CONTAINER_IMPORT_MULTIMODAL',
}) })
@IsOptional() @IsOptional()
// `'' ?? null` is `''`, and an empty string matches neither the unique
// index's `COALESCE(cargo_category, '')` nor the report's join — it reads as
// a category that does not exist. Blank means absent.
@Transform(({ value }) => (value === '' ? null : value))
@IsString() @IsString()
@MaxLength(60) @MaxLength(60)
cargoCategory?: string; cargoCategory?: string | null;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()

View File

@@ -1,8 +1,26 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from 'typeorm';
/** Planning buckets the reports offer. Mirrors the reports' period filter. */ /**
export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const; * Planning buckets the reports offer. Mirrors the reports' period filter
* (`PERIOD_UNITS` in `reports/revenue-classification.ts`) — a planner must be
* able to commit a number at whatever grain the business quotes it, and the
* report then re-gathers it into whatever grain the viewer asks for.
*
* All eight anchor to the calendar year. `nine_month` and `ninety_day` are the
* two that do not divide it evenly: their last block of a year is short (OctDec
* and the 56 days after day 360). That is inherent to the unit, not a bug.
*/
export const TARGET_PERIOD_TYPES = [
'day',
'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
] as const;
export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number]; export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number];
/** What is being planned. */ /** What is being planned. */
@@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
}; };
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = { export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
day: 'Daily',
week: 'Weekly', week: 'Weekly',
month: 'Monthly', month: 'Monthly',
quarter: 'Quarterly', quarter: 'Quarterly',
half_year: 'Half-yearly',
nine_month: 'Nine-monthly',
ninety_day: '90-day',
year: 'Yearly', year: 'Yearly',
}; };

View File

@@ -1,4 +1,4 @@
import { Global, Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { OperationsStandard } from './entities/operations-standard.entity'; import { OperationsStandard } from './entities/operations-standard.entity';
@@ -13,10 +13,11 @@ import { OperationsTargetsService } from './operations-targets.service';
* standards (one settings row) and the planned targets the reports compare * standards (one settings row) and the planned targets the reports compare
* actuals against. * actuals against.
* *
* Global because the reports module reads the standards row on every run and * Not global, and deliberately so: nothing outside this module injects either
* has no other reason to import this. * service. The reports read both tables in raw SQL — `STANDARDS_JOIN` and
* `plannedRowsSql` in `reports/operations-classification.ts` — so the exports
* below are for future callers, not current ones.
*/ */
@Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])], imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
controllers: [OperationsStandardsController, OperationsTargetsController], controllers: [OperationsStandardsController, OperationsTargetsController],

View File

@@ -0,0 +1,144 @@
import {
TARGET_PERIOD_LABELS,
TARGET_PERIOD_TYPES,
TargetPeriodType,
} from './entities/operations-target.entity';
import { normalisePeriodStart } from './operations-targets.service';
/**
* `normalisePeriodStart` decides which slot a target occupies — the unique
* index is keyed on its output — and it is one half of a pair. The other half
* is `PERIOD_UNITS[...].truncOn` in `reports/revenue-classification.ts`, which
* buckets the actuals. A target that snaps to a boundary the report does not
* bucket on is a plan measured against a period that does not exist, and
* nothing downstream would say so.
*
* Everything here is UTC on purpose: the column is a bare `date`, and the same
* arithmetic in local time shifts a 1st-of-month target into the previous month
* for anyone east of Greenwich.
*/
describe('normalisePeriodStart', () => {
it('leaves a daily target on its own day', () => {
expect(normalisePeriodStart('day', '2026-08-21')).toBe('2026-08-21');
});
it('snaps a week to its Monday', () => {
// 2026-08-21 is a Friday.
expect(normalisePeriodStart('week', '2026-08-21')).toBe('2026-08-17');
// A Sunday belongs to the week that started six days earlier, not the next.
expect(normalisePeriodStart('week', '2026-08-23')).toBe('2026-08-17');
expect(normalisePeriodStart('week', '2026-08-17')).toBe('2026-08-17');
});
it('snaps a month to the 1st', () => {
expect(normalisePeriodStart('month', '2026-08-21')).toBe('2026-08-01');
expect(normalisePeriodStart('month', '2026-08-01')).toBe('2026-08-01');
});
it('snaps a quarter to Jan/Apr/Jul/Oct', () => {
expect(normalisePeriodStart('quarter', '2026-02-14')).toBe('2026-01-01');
expect(normalisePeriodStart('quarter', '2026-05-01')).toBe('2026-04-01');
expect(normalisePeriodStart('quarter', '2026-08-21')).toBe('2026-07-01');
expect(normalisePeriodStart('quarter', '2026-12-31')).toBe('2026-10-01');
});
it('snaps a half-year to Jan/Jul', () => {
expect(normalisePeriodStart('half_year', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('half_year', '2026-06-30')).toBe('2026-01-01');
expect(normalisePeriodStart('half_year', '2026-07-01')).toBe('2026-07-01');
expect(normalisePeriodStart('half_year', '2026-12-31')).toBe('2026-07-01');
});
it('snaps a nine-month to Jan/Oct, leaving a short final block', () => {
expect(normalisePeriodStart('nine_month', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('nine_month', '2026-09-30')).toBe('2026-01-01');
// OctDec is three months, not nine. The block is short by design: nine
// does not divide twelve, and drifting out of the calendar year is worse.
expect(normalisePeriodStart('nine_month', '2026-10-01')).toBe('2026-10-01');
expect(normalisePeriodStart('nine_month', '2026-12-31')).toBe('2026-10-01');
});
it('snaps a 90-day block to day 1/91/181/271 of its year', () => {
expect(normalisePeriodStart('ninety_day', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('ninety_day', '2026-03-31')).toBe('2026-01-01'); // day 90
expect(normalisePeriodStart('ninety_day', '2026-04-01')).toBe('2026-04-01'); // day 91
expect(normalisePeriodStart('ninety_day', '2026-06-29')).toBe('2026-04-01'); // day 180
expect(normalisePeriodStart('ninety_day', '2026-06-30')).toBe('2026-06-30'); // day 181
expect(normalisePeriodStart('ninety_day', '2026-07-01')).toBe('2026-06-30');
expect(normalisePeriodStart('ninety_day', '2026-09-27')).toBe('2026-06-30'); // day 270
expect(normalisePeriodStart('ninety_day', '2026-09-28')).toBe('2026-09-28'); // day 271
});
it('widens the fourth 90-day block instead of opening a stub fifth', () => {
// Day 361 onwards would be its own block under an uncapped floor division —
// a five-day bucket at the end of every year. The cap keeps it in block 4,
// which must therefore match what late September resolves to.
const blockFour = normalisePeriodStart('ninety_day', '2026-09-28');
expect(normalisePeriodStart('ninety_day', '2026-12-27')).toBe(blockFour);
expect(normalisePeriodStart('ninety_day', '2026-12-31')).toBe(blockFour);
});
it('handles a leap year, where day 366 still lands in the fourth block', () => {
// 2028 is a leap year: Dec 31 is day 366.
expect(normalisePeriodStart('ninety_day', '2028-12-31')).toBe(
normalisePeriodStart('ninety_day', '2028-09-27'),
);
});
it('snaps a year to Jan 1', () => {
expect(normalisePeriodStart('year', '2026-08-21')).toBe('2026-01-01');
expect(normalisePeriodStart('year', '2026-01-01')).toBe('2026-01-01');
expect(normalisePeriodStart('year', '2026-12-31')).toBe('2026-01-01');
});
it('ignores any time component rather than letting it shift the day', () => {
expect(normalisePeriodStart('day', '2026-08-21T23:59:59.999Z')).toBe('2026-08-21');
expect(normalisePeriodStart('month', '2026-08-01T22:00:00+03:00')).toBe('2026-08-01');
});
it('is idempotent for every period type', () => {
// A normalised start must survive a second pass untouched, because `update`
// re-normalises whatever is already stored.
for (const periodType of TARGET_PERIOD_TYPES) {
for (const date of ['2026-01-01', '2026-05-17', '2026-08-21', '2026-12-31']) {
const once = normalisePeriodStart(periodType, date);
expect(normalisePeriodStart(periodType, once)).toBe(once);
}
}
});
it('never moves a date forward, only back to its block start', () => {
for (const periodType of TARGET_PERIOD_TYPES) {
for (const date of ['2026-02-28', '2026-06-15', '2026-10-02', '2026-12-31']) {
expect(normalisePeriodStart(periodType, date) <= date).toBe(true);
}
}
});
});
describe('target period vocabulary', () => {
it('labels every period type, so the admin grid shows no raw key', () => {
for (const periodType of TARGET_PERIOD_TYPES) {
expect(TARGET_PERIOD_LABELS[periodType]).toBeTruthy();
}
expect(Object.keys(TARGET_PERIOD_LABELS).sort()).toEqual([...TARGET_PERIOD_TYPES].sort());
});
it('keeps every period type inside the column width', () => {
// `period_type` is varchar(10); `nine_month` and `ninety_day` are exactly 10.
for (const periodType of TARGET_PERIOD_TYPES) {
expect(periodType.length).toBeLessThanOrEqual(10);
}
});
it('has a normalisation branch for every declared period type', () => {
// A type added to the union without a `case` would silently fall through
// and store an un-snapped date. Every type must move Dec 31 to a block
// start except `day`, which legitimately keeps it.
const unhandled = TARGET_PERIOD_TYPES.filter(
(t: TargetPeriodType) =>
t !== 'day' && normalisePeriodStart(t, '2026-12-31') === '2026-12-31',
);
expect(unhandled).toEqual([]);
});
});

View File

@@ -1,5 +1,10 @@
import { PaginatedResponse } from '@edr/types'; import { PaginatedResponse } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, IsNull, Repository } from 'typeorm'; import { Brackets, IsNull, Repository } from 'typeorm';
@@ -12,6 +17,8 @@ import {
TARGET_DIMENSION_LABELS, TARGET_DIMENSION_LABELS,
TARGET_METRIC_LABELS, TARGET_METRIC_LABELS,
TARGET_PERIOD_LABELS, TARGET_PERIOD_LABELS,
TargetDimension,
TargetMetric,
TargetPeriodType, TargetPeriodType,
} from './entities/operations-target.entity'; } from './entities/operations-target.entity';
import { import {
@@ -19,10 +26,19 @@ import {
CONTAINER_CLASSES, CONTAINER_CLASSES,
} from '../reports/operations-classification'; } from '../reports/operations-classification';
const MS_PER_DAY = 86_400_000;
/** /**
* 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 the
* Postgres `date_trunc` — which is what the reports group by. Week starts * bucket expression the reports group by (`PERIOD_UNITS` in
* Monday, the same as `date_trunc('week', …)` and ISO week numbering. * `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 * 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 * arithmetic in local time would shift a 1st-of-month target into the previous
@@ -31,6 +47,8 @@ import {
export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string { export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string {
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`); const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
switch (periodType) { switch (periodType) {
case 'day':
break;
case 'week': { case 'week': {
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in. // getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
const offset = (d.getUTCDay() + 6) % 7; const offset = (d.getUTCDay() + 6) % 7;
@@ -43,6 +61,22 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string
case 'quarter': case 'quarter':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1); d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
break; break;
case 'half_year':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 6) * 6, 1);
break;
case 'nine_month':
// Two blocks a year, not 1.33: JanSep, then a short OctDec.
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': case 'year':
d.setUTCMonth(0, 1); d.setUTCMonth(0, 1);
break; break;
@@ -76,6 +110,27 @@ const LABELS_BY_DIMENSION: Record<string, Map<string, string>> = {
const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category; 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() @Injectable()
export class OperationsTargetsService { export class OperationsTargetsService {
constructor( constructor(
@@ -152,35 +207,113 @@ export class OperationsTargetsService {
} }
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> { async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart); const slot = await this.resolveSlot(dto);
const cargoCategory = dto.cargoCategory ?? null; await this.assertSlotFree(slot);
await this.assertSlotFree({ ...dto, periodStart, cargoCategory }); return this.repository.save(this.repository.create({ ...dto, ...slot }));
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> {
const current = await this.findById(id); const current = await this.findById(id);
const periodType = dto.periodType ?? current.periodType; const slot = await this.resolveSlot({
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart); periodType: dto.periodType ?? current.periodType,
const next = { periodStart: dto.periodStart ?? current.periodStart,
periodType,
periodStart,
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,
// 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: cargoCategory:
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null, dto.cargoCategory !== undefined ? dto.cargoCategory : current.cargoCategory,
}; });
await this.assertSlotFree(next, id); await this.assertSlotFree(slot, id);
await this.repository.update(id, { await this.repository.update(id, {
...next, ...slot,
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}), ...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
...(dto.note !== undefined ? { note: dto.note } : {}), ...(dto.note !== undefined ? { note: dto.note } : {}),
}); });
return this.findById(id); 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> { async remove(id: string): Promise<void> {
await this.findById(id); await this.findById(id);
await this.repository.softDelete(id); await this.repository.softDelete(id);

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true }, { key: 'category', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' }, { key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'teu', label: 'TEU', type: 'number' }, { key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Wagons', type: 'number' }, { key: 'wagons', label: 'Wagons', type: 'number' },
@@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addGroupBy(originationExpr(params, 'code')) .addGroupBy(originationExpr(params, 'code'))
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade, keyed the way a station plan is: per station
// AND per cargo type. Unfiltered by date, so a mid-year view still knows
// what the station has already hauled against its target.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, params), 'bucket')
.addSelect(stationCode, 'act_key')
.addSelect(CARGO_CATEGORY_EXPR, 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, params))
.addGroupBy(stationCode)
.addGroupBy(CARGO_CATEGORY_EXPR);
// A station plan is keyed on station AND cargo type, so the join needs // A station plan is keyed on station AND cargo type, so the join needs
// both. Full outer, so a station-and-cargo line that was planned and never // both. Full outer, so a station-and-cargo line that was planned and never
// ran still reports its miss — the OCC report is full of those. // ran still reports its miss — the OCC report is full of those.
@@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu, COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'station',
params,
attained.getQuery(),
)}) p
ON p.period = o.period ON p.period = o.period
AND p.plan_key = o.station_code AND p.plan_key = o.station_code
AND p.plan_category = o.category_key`; AND p.plan_category = o.category_key`;
@@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = {
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect('r.station', 'station') .addSelect('r.station', 'station')
.addSelect('r.origination', 'origination') .addSelect('r.origination', 'origination')
@@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.teu::int', 'teu') .addSelect('r.teu::int', 'teu')
.addSelect('r.wagons::int', 'wagons') .addSelect('r.wagons::int', 'wagons')

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
{ key: 'category', label: 'Cargo category', type: 'string', sortable: true }, { key: 'category', label: 'Cargo category', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' }, { key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true }, { key: 'teu', label: 'TEU', type: 'number', sortable: true },
@@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// What the cascade measures attainment from: the same tonnage, over the
// target's whole period rather than the user's date window. Bucketed on the
// block start, not the label, so it joins the plan on a real timestamp.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(`${ACTUAL_TONS_EXPR}`, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// Full outer join so a planned cargo category that moved nothing still // Full outer join so a planned cargo category that moved nothing still
// reports its miss instead of disappearing from the table. // reports its miss instead of disappearing from the table.
const combined = ` const combined = `
@@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu, COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
'VOLUME_TONS',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`; ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.charged_tons::float8', 'chargedTons') .addSelect('r.charged_tons::float8', 'chargedTons')
.addSelect('r.teu::int', 'teu') .addSelect('r.teu::int', 'teu')

View File

@@ -0,0 +1,71 @@
import { WAGON_CANCELLATION_STATUSES } from '../../bookings/entities/booking-wagon-cancellation.entity';
import { ShippingLineCreditStatus } from '../../shipping-lines/entities/shipping-line-credit.entity';
import {
CREDIT_LIABILITY_STATUS,
INVOICE_SIDE_EXPR,
LEDGER_SIDES,
UNINVOICED_CREDIT_STATUS,
receivablesPayablesReport,
} from './receivables-payables.report';
/**
* The report's whole point is the sign of the money: a cancellation FEE is
* owed TO EDR, and the cancelled freight is owed BACK to the customer as
* bookable credit. These tests pin the two down at the string level — the SQL
* itself is validated against the database, not here.
*/
describe('receivables-payables report', () => {
it('treats exactly one wagon-cancellation status as a liability', () => {
expect(WAGON_CANCELLATION_STATUSES).toContain(CREDIT_LIABILITY_STATUS);
// Every other status owes nothing: nothing cut yet (FEE_PENDING), redeemed
// (REBOOKED), or voided (WITHDRAWN / EXPIRED). If a new status appears,
// this fails until someone decides which side of the ledger it lands on.
expect(WAGON_CANCELLATION_STATUSES.filter((s) => s !== CREDIT_LIABILITY_STATUS).sort()).toEqual(
['EXPIRED', 'FEE_PENDING', 'REBOOKED', 'WITHDRAWN'],
);
});
it('counts only the shipping-line credit status that has no invoice behind it', () => {
expect(UNINVOICED_CREDIT_STATUS).toBe(ShippingLineCreditStatus.Unbilled);
// BILLED is debt too, but it is counted through its invoice on the invoice
// branch — taking it here as well would double it.
expect(UNINVOICED_CREDIT_STATUS).not.toBe(ShippingLineCreditStatus.Billed);
});
it('never classifies the cancellation fee as a payable', () => {
// The fee invoice rides the booking's invoice list; while it is open it is
// an ordinary receivable balance, and it must not reach a PAYABLE arm.
expect(INVOICE_SIDE_EXPR).not.toContain('WAGON_CANCEL_FEE');
expect(INVOICE_SIDE_EXPR).not.toContain('CANCELLATION_FEE');
});
it('does not double-count a booking already carried by the cancellation ledger', () => {
expect(INVOICE_SIDE_EXPR).toContain('NOT EXISTS');
expect(INVOICE_SIDE_EXPR).toContain('booking_wagon_cancellations');
});
it('emits exactly the side keys the filter offers', () => {
const declared = LEDGER_SIDES.map((s) => s.value).sort();
expect(declared).toEqual([
'PAYABLE_PREPAID',
'PAYABLE_WAGON_CREDIT',
'RECEIVABLE_OPEN',
'RECEIVABLE_SL_INVOICED',
'RECEIVABLE_SL_UNBILLED',
]);
// The summary KPIs split on these prefixes; a key matching neither would
// silently vanish from both totals.
for (const key of declared) {
expect(key.startsWith('RECEIVABLE') || key.startsWith('PAYABLE')).toBe(true);
}
});
it('sorts on the union wrapper, never on a branch-local alias', () => {
// The runner appends ORDER BY outside the union subquery, where `i.*`,
// `b.*` and `bwc.*` do not exist.
for (const col of receivablesPayablesReport.columns) {
if (!col.sortExpr) continue;
expect(col.sortExpr).toMatch(/^r\./);
}
});
});

View File

@@ -1,5 +1,12 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { BookingWagonCancellation } from '../../bookings/entities/booking-wagon-cancellation.entity';
import { Company } from '../../companies/entities/company.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { ShippingLineCredit } from '../../shipping-lines/entities/shipping-line-credit.entity';
import { directionScopeSql } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types'; import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
import { import {
PAYER_EXPR, PAYER_EXPR,
@@ -10,47 +17,287 @@ import {
} from '../revenue-classification'; } from '../revenue-classification';
export const LEDGER_SIDES: ReportFilterOption[] = [ export const LEDGER_SIDES: ReportFilterOption[] = [
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, {
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, value: 'RECEIVABLE_SL_UNBILLED',
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, label: 'Receivable — shipping-line service, not yet invoiced',
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, },
{ value: 'SETTLED', label: 'Settled' }, {
value: 'RECEIVABLE_SL_INVOICED',
label: 'Receivable — shipping-line invoice open',
},
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open invoice balance' },
{
value: 'PAYABLE_WAGON_CREDIT',
label: 'Payable — unapplied wagon-cancellation credit',
},
{ value: 'PAYABLE_PREPAID', label: 'Payable — paid but not delivered' },
]; ];
/** /**
* Which side of the ledger an invoice sits on. * Which side of the ledger a row sits on, and why the report is a union of
* three fact tables rather than a CASE over `invoices`.
* *
* Receivable = EDR delivered and is owed money — the shipping-line credit * RECEIVABLE — money EDR is owed. The shipping-line arrangement is service
* arrangement, plus any invoice still carrying a balance. * first, pay later, and it produces debt in two shapes: a `shipping_line_credits`
* Payable = the customer paid for something EDR did not deliver, so the money * row with NO invoice while it is UNBILLED (a shipping-line booking raises no
* is a refund liability rather than revenue: cancellation fees, and prepaid * invoice at all), and an open batch invoice once finance bills it. Counting
* invoices whose booking died. * only the second understates the debt by everything not yet batched. Ordinary
* open invoice balances are the third shape — including the wagon-cancellation
* FEE, which is money the customer owes EDR, never a liability.
*
* PAYABLE — the customer paid and did not get the service. Wagon cancellation
* never refunds cash: the cancelled freight becomes a rebooking credit that is
* redeemed by creating another booking (see BookingWagonCancellationService).
* So the liability is exactly the cancellations sitting in CREDIT_AVAILABLE —
* fee settled, wagons freed, credit not yet applied — valued at `credit_amount`,
* and it disappears the moment the row turns REBOOKED. The source invoice is
* useless for this: a whole-booking cut leaves it PAID at its full amount
* forever, which is neither the right number nor the right lifetime.
*
* Fully settled invoices are not rows here. A zero-exposure invoice is neither
* a receivable nor a payable; Invoicing Pipeline is the report that lists them.
*/ */
const SIDE_EXPR = `CASE
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
THEN 'RECEIVABLE_CREDIT'
WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION'
WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
THEN 'PAYABLE_UNDELIVERED'
WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN'
ELSE 'SETTLED'
END`;
const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label])); const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label]));
const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR}
${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')} /** Labels a side key that is already a column — the union is classified inside, labelled outside. */
const SIDE_LABEL_OF = (keyExpr: string): string =>
`CASE ${keyExpr}\n ${[...LABELS]
.map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`)
.join('\n ')}\nEND`;
/**
* Statuses that cannot become cash. EXPIRED closed its own pay window and
* REFUNDED already gave the money back, so neither is owed in either
* direction. Filtered here rather than in the shared DEAD_INVOICE_STATUSES —
* that constant feeds every revenue report and those invoices did earn revenue.
*/
const UNCOLLECTABLE_INVOICE_STATUSES = "('EXPIRED', 'REFUNDED')";
/**
* A booking whose money is accounted for by the cancellation ledger instead.
* Without this, a whole-booking wagon cancellation would be counted twice: once
* as its own CREDIT_AVAILABLE credit, and again as the source booking's paid
* invoice sitting against a CANCELLED booking — and the second copy would never
* clear, because rebooking updates the ledger row, not the old invoice.
*/
const HAS_CANCELLATION_LEDGER = `EXISTS (
SELECT 1 FROM freight.booking_wagon_cancellations bwc0
WHERE bwc0.booking_id = b.id
AND bwc0.deleted_at IS NULL
AND bwc0.status <> 'WITHDRAWN'
)`;
/** Customer paid, booking died, and no cancellation credit represents it. */
const PREPAID_DEAD = `i.paid_amount > 0
AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED')
AND NOT ${HAS_CANCELLATION_LEDGER}`;
export const INVOICE_SIDE_EXPR = `CASE
WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT'
THEN 'RECEIVABLE_SL_INVOICED'
WHEN ${PREPAID_DEAD} THEN 'PAYABLE_PREPAID'
ELSE 'RECEIVABLE_OPEN'
END`; END`;
/** Money at stake on this row: what is owed, or what may have to be given back. */ /**
const EXPOSURE = `CASE * The union's column contract, in positional order.
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount *
ELSE i.balance_amount * UNION matches by POSITION, and TypeORM does not preserve `addSelect` order —
END`; * it hoists a branch's repeated expressions to the front, which silently
* rearranged one branch into `gross, exposure, side_key, …` and failed with
* "UNION types text and numeric cannot be matched". Every branch is therefore
* re-projected through this list by name before it is unioned.
*/
const UNION_COLUMNS = [
'side_key',
'txn_date',
'doc_ref',
'booking_ref',
'booking_status',
'payer',
'gross',
'settled',
'exposure',
] as const;
/**
* The one wagon-cancellation status that is a live liability: the fee is
* settled and the booking cut, but the credit has not been turned into a
* booking yet. FEE_PENDING has cut nothing, REBOOKED has been redeemed, and
* WITHDRAWN/EXPIRED owe nothing.
*/
export const CREDIT_LIABILITY_STATUS = 'CREDIT_AVAILABLE';
/**
* Shipping-line credit status that is debt with no invoice behind it. BILLED
* credits are counted through their invoice on branch A, which is what keeps
* the two shipping-line sides disjoint.
*/
export const UNINVOICED_CREDIT_STATUS = 'UNBILLED';
/** Applies the filters branches B and C share with {@link invoiceLedgerQb}. */
function applySharedFilters(
qb: SelectQueryBuilder<ObjectLiteral>,
ctx: ReportContext,
dateExpr: string,
): SelectQueryBuilder<ObjectLiteral> {
const { params, directions } = ctx;
if (params.dateFrom) qb.andWhere(`${dateExpr} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${dateExpr} < :dateTo`, { dateTo: params.dateTo });
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
if (params.destination) {
qb.andWhere('dy.code = :destination', { destination: params.destination });
}
if (params.customer) {
qb.andWhere(
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
{ customer: `%${params.customer as string}%` },
);
}
// An umbrella general contract is paid once and drawn down by many orders —
// same exclusion invoiceLedgerQb applies on branch A.
qb.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')");
// Both branches reach their booking directly, so the direction scope is the
// plain column form, not the source_id-pointer form invoices need. A row
// whose booking is gone carries no direction to scope by and stays visible —
// the same rule applyBookingRefDirectionScope applies on branch A.
const scope = directionScopeSql('b.trade_direction', directions);
qb.andWhere(`(b.id IS NULL OR ${scope.sql})`, scope.params);
return qb;
}
/** Branch A — invoices carrying a balance, plus prepayments against dead bookings. */
function invoiceBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
return invoiceLedgerQb(ctx)
.andWhere(`i.status NOT IN ${UNCOLLECTABLE_INVOICE_STATUSES}`)
.andWhere(`(i.balance_amount > 0 OR (${PREPAID_DEAD}))`)
.select(INVOICE_SIDE_EXPR, 'side_key')
.addSelect(REVENUE_DATE, 'txn_date')
.addSelect('i.invoice_number', 'doc_ref')
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect(PAYER_EXPR, 'payer')
.addSelect('i.total_amount', 'gross')
.addSelect('i.paid_amount', 'settled')
.addSelect(
`CASE WHEN ${PREPAID_DEAD} THEN i.paid_amount ELSE i.balance_amount END`,
'exposure',
);
}
/**
* Branch B — shipping-line services used but never invoiced.
*
* The credit row IS the debt while it is UNBILLED; BILLED rows are the ones
* behind an invoice and are already counted by branch A, so taking only
* UNBILLED here is what keeps the two shipping-line sides disjoint.
*/
function unbilledCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds
.createQueryBuilder()
.from(ShippingLineCredit, 'slc_c')
.leftJoin(Booking, 'b', 'b.id = slc_c.booking_id AND b.deleted_at IS NULL')
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
.leftJoin(Company, 'co', 'co.id = b.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = slc_c.shipping_line_company_id')
.where('slc_c.deleted_at IS NULL')
.andWhere('slc_c.status = :uninvoicedCreditStatus', {
uninvoicedCreditStatus: UNINVOICED_CREDIT_STATUS,
})
.andWhere('slc_c.currency = :currency', {
currency: currencyOf(ctx.params),
});
// Priced when the service was used; that is the date the debt was incurred.
applySharedFilters(qb, ctx, 'slc_c.created_at');
return qb
.select("'RECEIVABLE_SL_UNBILLED'", 'side_key')
.addSelect('slc_c.created_at', 'txn_date')
.addSelect("'—'", 'doc_ref')
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect("COALESCE(slc.name, 'Unknown')", 'payer')
.addSelect('slc_c.amount', 'gross')
.addSelect('0::numeric', 'settled')
.addSelect('slc_c.amount', 'exposure');
}
/**
* Branch C — cancelled wagons whose credit has not been rebooked.
*
* `credit_amount` is priced in the BOOKING's payment currency, not
* `fee_currency` — that one prices the cancellation fee, which is a separate
* (and opposite-signed) piece of money.
*/
function wagonCreditBranch(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = ctx.ds
.createQueryBuilder()
.from(BookingWagonCancellation, 'bwc')
.innerJoin(Booking, 'b', 'b.id = bwc.booking_id AND b.deleted_at IS NULL')
.leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id')
.leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id')
.leftJoin(Company, 'co', 'co.id = b.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = b.shipping_line_company_id')
.where('bwc.deleted_at IS NULL')
.andWhere('bwc.status = :creditLiabilityStatus', {
creditLiabilityStatus: CREDIT_LIABILITY_STATUS,
})
.andWhere("COALESCE(b.payment_currency, 'ETB') = :currency", {
currency: currencyOf(ctx.params),
});
// The credit exists from the moment the fee settled and the booking was cut.
applySharedFilters(qb, ctx, 'COALESCE(bwc.fee_paid_at, bwc.created_at)');
return (
qb
.select("'PAYABLE_WAGON_CREDIT'", 'side_key')
.addSelect('COALESCE(bwc.fee_paid_at, bwc.created_at)', 'txn_date')
// numeric(6,2) renders as "2.00"; a wagon count reads as "2" (and "2.5"
// survives, because a half wagon is a real bulk quantity here).
.addSelect(
`rtrim(rtrim(bwc.wagons_cancelled::text, '0'), '.') || ' wagon(s) cancelled'`,
'doc_ref',
)
.addSelect("COALESCE(b.reference, '—')", 'booking_ref')
.addSelect("COALESCE(b.status, '—')", 'booking_status')
.addSelect(PAYER_EXPR, 'payer')
// The freight was paid in full on the original booking, so the whole
// credit is money already in hand and owed back as bookable value.
.addSelect('bwc.credit_amount', 'gross')
.addSelect('bwc.credit_amount', 'settled')
.addSelect('bwc.credit_amount', 'exposure')
);
}
/**
* The three branches as one relation, wrapped so the runner can sort, page and
* COUNT(*) it like any other report query.
*
* Parameters are merged from every branch: `getQuery()` leaves `:name`
* placeholders in place, and only the outer builder's parameter bag is read
* when the SQL is finally bound.
*/
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const qb = invoiceLedgerQb(ctx); const branches = [invoiceBranch(ctx), unbilledCreditBranch(ctx), wagonCreditBranch(ctx)];
const combined = branches
.map((b, idx) => `SELECT ${UNION_COLUMNS.join(', ')} FROM (${b.getQuery()}) branch_${idx}`)
.join('\n UNION ALL\n ');
const qb = ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters(Object.assign({}, ...branches.map((b) => b.getParameters())));
const sides = ctx.params.sides as string[] | null; const sides = ctx.params.sides as string[] | null;
if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides }); if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides });
return qb; return qb;
} }
@@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = {
key: 'receivables-payables', key: 'receivables-payables',
title: 'Receivables and Payables', title: 'Receivables and Payables',
description: description:
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + 'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' +
'including shipping-line credit services — and payable, where the customer paid but ' + 'shipping-line credit services whether invoiced yet or not, plus any invoice still ' +
'the service was not delivered, such as cancellation fees and prepayments against ' + 'carrying a balance — and payable, where the customer paid and the service was not ' +
'dead bookings. Payable amounts are a refund liability, not revenue.', 'delivered. The payable is dominated by wagon cancellations whose credit has not been ' +
'rebooked; that credit is redeemed by creating another booking, never refunded in cash.',
group: 'Finance', group: 'Finance',
filters: [ filters: [
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'),
{ key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES }, {
key: 'sides',
label: 'Ledger side',
type: 'multiselect',
options: LEDGER_SIDES,
},
], ],
columns: [ columns: [
{ key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR }, {
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, key: 'side',
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, label: 'Ledger side',
type: 'string',
sortable: true,
sortExpr: 'r.side_key',
},
{
key: 'issuedAt',
label: 'Date',
type: 'date',
sortable: true,
sortExpr: 'r.txn_date',
},
{
key: 'invoiceNumber',
label: 'Invoice / ref',
type: 'string',
sortable: true,
sortExpr: 'r.doc_ref',
},
{ key: 'bookingRef', label: 'Booking', type: 'string' }, { key: 'bookingRef', label: 'Booking', type: 'string' },
{ key: 'bookingStatus', label: 'Booking status', type: 'string' }, { key: 'bookingStatus', label: 'Booking status', type: 'string' },
{ key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, {
{ key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, key: 'customer',
{ key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, label: 'Payer',
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, type: 'string',
sortable: true,
sortExpr: 'r.payer',
},
{
key: 'invoiced',
label: 'Amount',
type: 'money',
sortable: true,
sortExpr: 'r.gross',
},
{
key: 'paid',
label: 'Paid',
type: 'money',
sortable: true,
sortExpr: 'r.settled',
},
{
key: 'exposure',
label: 'Owed / refundable',
type: 'money',
sortable: true,
sortExpr: 'r.exposure',
},
], ],
defaultSort: { key: 'exposure', dir: 'DESC' }, defaultSort: { key: 'exposure', dir: 'DESC' },
chart: { type: 'bar', x: 'side', y: ['exposure'] }, chart: { type: 'bar', x: 'side', y: ['exposure'] },
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select(SIDE_LABEL_EXPR, 'side') .select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') .addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber') .addSelect('r.doc_ref', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef') .addSelect('r.booking_ref', 'bookingRef')
.addSelect("COALESCE(b.status, '—')", 'bookingStatus') .addSelect('r.booking_status', 'bookingStatus')
.addSelect(PAYER_EXPR, 'customer') .addSelect('r.payer', 'customer')
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') .addSelect('ROUND(r.gross, 2)::float8', 'invoiced')
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') .addSelect('ROUND(r.settled, 2)::float8', 'paid')
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); .addSelect('ROUND(r.exposure, 2)::float8', 'exposure');
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
.select( .select(
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`, "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'RECEIVABLE%'), 0))::float8",
'receivable', 'receivable',
) )
.addSelect( .addSelect(
`ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`, "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'PAYABLE%'), 0))::float8",
'payable', 'payable',
) )
.addSelect('COUNT(*)::int', 'invoices') .addSelect('COUNT(*)::int', 'items')
.getRawOne<{ receivable: number; payable: number; invoices: number }>(); .getRawOne<{ receivable: number; payable: number; items: number }>();
const receivable = Number(row?.receivable ?? 0);
const payable = Number(row?.payable ?? 0);
const currency = currencyOf(ctx.params); const currency = currencyOf(ctx.params);
return [ return [
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, { label: 'Receivable', value: receivable, unit: currency },
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, { label: 'Payable', value: payable, unit: currency },
{ label: 'Invoices', value: Number(row?.invoices ?? 0) }, {
label: 'Net position',
value: Math.round(receivable - payable),
unit: currency,
},
{ label: 'Open items', value: Number(row?.items ?? 0) },
]; ];
}, },
}; };

View File

@@ -10,6 +10,7 @@ import {
OPERATIONS_FILTERS, OPERATIONS_FILTERS,
TEU_EXPR, TEU_EXPR,
allocationLedgerQb, allocationLedgerQb,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -60,6 +61,7 @@ export const teuPerformanceReport: ReportDefinition = {
{ key: "containers40", label: "40ft", type: "number", sortable: true }, { key: "containers40", label: "40ft", type: "number", sortable: true },
{ key: "operated", label: "Operated (TEU)", type: "number", sortable: true }, { key: "operated", label: "Operated (TEU)", type: "number", sortable: true },
{ key: "plan", label: "Plan", type: "number" }, { key: "plan", label: "Plan", type: "number" },
{ key: "planRequired", label: "Required", type: "number" },
{ key: "implementRate", label: "Implement rate", type: "percent" }, { key: "implementRate", label: "Implement rate", type: "percent" },
], ],
defaultSort: { key: "operated", dir: "DESC" }, defaultSort: { key: "operated", dir: "DESC" },
@@ -75,6 +77,16 @@ export const teuPerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CONTAINER_CLASS_EXPR); .addGroupBy(CONTAINER_CLASS_EXPR);
// Attainment for the cascade: TEU across the target's whole period, so a
// mid-year view does not read as "nothing shipped yet".
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), "bucket")
.addSelect(CONTAINER_CLASS_EXPR, "act_key")
.addSelect("NULL::varchar", "act_category")
.addSelect(TEU_EXPR, "actual")
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CONTAINER_CLASS_EXPR);
// Full outer join so a planned container class that never moved still // Full outer join so a planned container class that never moved still
// reports, at zero rather than vanishing. // reports, at zero rather than vanishing.
const combined = ` const combined = `
@@ -83,15 +95,25 @@ export const teuPerformanceReport: ReportDefinition = {
COALESCE(o.containers20, 0) AS containers20, COALESCE(o.containers20, 0) AS containers20,
COALESCE(o.containers40, 0) AS containers40, COALESCE(o.containers40, 0) AS containers40,
COALESCE(o.operated, 0) AS operated, COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql("TEU", "container_class", ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
"TEU",
"container_class",
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.class_key`; ON p.period = o.period AND p.plan_key = o.class_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, "r") .from(`(${combined})`, "r")
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select("r.period", "period") .select("r.period", "period")
.addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass") .addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass")
.addSelect("r.class_key", "containerClassKey") .addSelect("r.class_key", "containerClassKey")
@@ -99,6 +121,7 @@ export const teuPerformanceReport: ReportDefinition = {
.addSelect("r.containers40::int", "containers40") .addSelect("r.containers40::int", "containers40")
.addSelect("r.operated::int", "operated") .addSelect("r.operated::int", "operated")
.addSelect("r.plan::float8", "plan") .addSelect("r.plan::float8", "plan")
.addSelect("r.plan_required::float8", "planRequired")
.addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate"); .addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate");
}, },
async summary(ctx) { async summary(ctx) {

View File

@@ -13,6 +13,7 @@ import {
TRAINSETS_EXPR, TRAINSETS_EXPR,
allocationLedgerQb, allocationLedgerQb,
applyCategoryFilter, applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE, PLAN_GRANULARITY_NOTE,
implementRateExpr, implementRateExpr,
plannedRowsParams, plannedRowsParams,
@@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, { key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' }, { key: 'plan', label: 'Plan', type: 'number' },
{ key: 'planRequired', label: 'Required', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' },
], ],
defaultSort: { key: 'operated', dir: 'DESC' }, defaultSort: { key: 'operated', dir: 'DESC' },
@@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = {
.groupBy(bucket) .groupBy(bucket)
.addGroupBy(CARGO_CATEGORY_EXPR); .addGroupBy(CARGO_CATEGORY_EXPR);
// Attainment for the cascade: the same trainset measure across the target's
// whole period, not just the window the viewer is looking at.
const attained = baseQuery(attainmentCtx(ctx))
.select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket')
.addSelect(CARGO_CATEGORY_EXPR, 'act_key')
.addSelect('NULL::varchar', 'act_category')
.addSelect(TRAINSETS_EXPR, 'actual')
.groupBy(periodTruncExprOn(OPS_DATE, ctx.params))
.addGroupBy(CARGO_CATEGORY_EXPR);
// FULL OUTER JOIN so a category that was planned but never ran still shows, // FULL OUTER JOIN so a category that was planned but never ran still shows,
// at zero — TypeORM's builder has no full-outer join, hence the raw text. // at zero — TypeORM's builder has no full-outer join, hence the raw text.
const combined = ` const combined = `
@@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = {
COALESCE(o.trains, 0) AS trains, COALESCE(o.trains, 0) AS trains,
COALESCE(o.wagons, 0) AS wagons, COALESCE(o.wagons, 0) AS wagons,
COALESCE(o.operated, 0) AS operated, COALESCE(o.operated, 0) AS operated,
p.plan_value AS plan p.plan_value AS plan,
p.plan_required AS plan_required
FROM (${operated.getQuery()}) o FROM (${operated.getQuery()}) o
FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) p FULL OUTER JOIN (${plannedRowsSql(
'TRAINSET',
'cargo_category',
ctx.params,
attained.getQuery(),
)}) p
ON p.period = o.period AND p.plan_key = o.category_key`; ON p.period = o.period AND p.plan_key = o.category_key`;
return ctx.ds return ctx.ds
.createQueryBuilder() .createQueryBuilder()
.from(`(${combined})`, 'r') .from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) .setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period') .select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey') .addSelect('r.category_key', 'categoryKey')
@@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
.addSelect('r.wagons::int', 'wagons') .addSelect('r.wagons::int', 'wagons')
.addSelect('r.operated::float8', 'operated') .addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan') .addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
}, },
async summary(ctx) { async summary(ctx) {

View File

@@ -503,21 +503,68 @@ export function applyCategoryFilter(
} }
/** /**
* The planned rows for a metric, as a derived table. * Appended to every plan-versus-actual report's description, because neither
* the re-bucketing nor the catch-up rule is guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both. ' +
'Plan is the committed figure and never moves. Required is the same target treated as a ' +
'quota: whatever is still outstanding, spread across the time still left, so a period ' +
'that fell behind raises what the periods after it must carry. A target already met in ' +
'full requires nothing further.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
/**
* How long one target's period runs. A target's span is exact — 90 days is 90
* days — and need not line up with the ragged year-end display blocks the
* `nine_month` and `ninety_day` granularities produce. The spread below is
* proportional, so partial overlap resolves correctly either way.
*/
const TARGET_SPAN = `CASE ot.period_type
WHEN 'day' THEN INTERVAL '1 day'
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'half_year' THEN INTERVAL '6 months'
WHEN 'nine_month' THEN INTERVAL '9 months'
WHEN 'ninety_day' THEN INTERVAL '90 days'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END`;
/**
* The planned rows for a metric, as a derived table: one row per bucket per
* planned key, carrying both a committed and a required figure.
* *
* A target is a rate over its own period, not a lump at its start: the plan is * **Plan** — a target is a rate over its own period, not a lump at its start.
* spread evenly across the days it covers, then re-gathered into the report's * The committed value is spread evenly across the days it covers and
* buckets. One rule covers every direction — three monthly targets add up to a * re-gathered into the report's buckets, so three monthly targets add up to a
* quarter exactly, a daily view gets a thirty-first of the month, and a week * quarter exactly, a daily view gets a thirty-first of the month, and a week
* straddling a month boundary draws proportionally on both months. * straddling a month boundary draws proportionally on both. The even spread is
* an assumption, and the only one available: a monthly figure carries no
* information about which days inside it were busier. This number never moves —
* Implement Rate is measured against it, so a month that missed keeps reading
* as a month that missed.
* *
* The even spread is an assumption, and the only one available: a monthly * **Required** — the same target read as a quota. At each bucket, whatever is
* figure carries no information about which days inside it were busier. * still outstanding (committed minus everything delivered in earlier buckets)
* is spread across the time still left in the period. A year 20% met at the
* halfway mark asks the remaining months for the other 80%. Over-delivery
* clamps to zero rather than going negative: a met quota requires nothing more.
* *
* The share is clipped to the user's date filter as well as to the bucket, so * `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must
* the plan always covers exactly the span the operated figure beside it covers. * be built **without the user's date bounds** — see {@link attainmentCtx}.
* Without that, filtering to July and viewing by year would put a whole year's * Attainment is a fact about the target's whole period; measuring it through
* plan next to one month's work. * the report's date filter would read a mid-year view as "nothing delivered
* yet" and demand the entire year's work from one month.
* *
* The reports FULL OUTER JOIN this to their operated aggregate so a category * The reports FULL OUTER JOIN this to their operated aggregate so a category
* that was planned but never ran still appears, at zero. The OCC monthly report * that was planned but never ran still appears, at zero. The OCC monthly report
@@ -528,62 +575,96 @@ export function applyCategoryFilter(
* Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind * Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind
* with {@link plannedRowsParams} — they come from the user's date filter. * with {@link plannedRowsParams} — they come from the user's date filter.
*/ */
/**
* Appended to every plan-versus-actual report's description, because the
* re-bucketing rule is not guessable from the table.
*/
export const PLAN_GRANULARITY_NOTE =
' A plan is spread evenly across its own period and re-gathered into whichever bucket ' +
'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' +
'or weekly view gets its share of it. A week that straddles two months draws on both.';
/**
* The user's date filter as open-ended bounds, so the clipping arithmetic below
* never has to branch on null.
*/
const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)";
const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)";
export const plannedRowsSql = ( export const plannedRowsSql = (
metric: string, metric: string,
dimension: string, dimension: string,
params: Record<string, unknown>, params: Record<string, unknown>,
actualsSql: string,
): string => { ): string => {
const unit = resolvePeriod(params); const unit = resolvePeriod(params);
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucketOf = unit.truncOn('d.day');
return ` return `
SELECT to_char(g.bucket, '${unit.fmt}') AS period, WITH tgt AS (
ot.dimension_key AS plan_key, SELECT ot.id,
ot.cargo_category AS plan_category, ot.dimension_key,
SUM(ot.planned_value * ( ot.cargo_category,
GREATEST(0, EXTRACT(EPOCH FROM ( ot.planned_value,
LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO}) ot.period_start::timestamptz AS starts,
- GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM})))) ot.period_start::timestamptz + ${TARGET_SPAN} AS ends
/ NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0) FROM freight.operations_targets ot
)) AS plan_value WHERE ot.deleted_at IS NULL
FROM freight.operations_targets ot AND ot.metric = '${metric}'
CROSS JOIN LATERAL ( AND ot.dimension = '${dimension}'
SELECT ot.period_start + CASE ot.period_type AND ot.planned_value > 0
WHEN 'week' THEN INTERVAL '7 days' ),
WHEN 'month' THEN INTERVAL '1 month' -- One row per target per bucket. Generated a day at a time rather than a
WHEN 'quarter' THEN INTERVAL '3 months' -- bucket at a time: the ragged units restart their blocks each January, so
WHEN 'year' THEN INTERVAL '1 year' -- stepping by the unit's own width walks off the anchor in the second year.
ELSE INTERVAL '1 day' -- Day grain also makes a bucket that only partly overlaps the target fall out
END AS ends -- for free, at the same sub-day precision the clipping used before.
) t spread AS (
CROSS JOIN LATERAL generate_series( SELECT t.id,
date_trunc('${unit.trunc}', ot.period_start::timestamptz), t.dimension_key,
date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'), t.cargo_category,
INTERVAL '${unit.step}' t.planned_value,
) AS g(bucket) EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total,
WHERE ot.deleted_at IS NULL ${bucketOf} AS bucket,
AND ot.metric = '${metric}' SUM(GREATEST(0, EXTRACT(EPOCH FROM (
AND ot.dimension = '${dimension}' LEAST(d.day + INTERVAL '1 day', t.ends)
AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM} - GREATEST(d.day, t.starts))))) AS secs_full,
AND g.bucket < ${PLAN_TO} SUM(GREATEST(0, EXTRACT(EPOCH FROM (
GROUP BY 1, 2, 3 LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO})
HAVING SUM(ot.planned_value) > 0`; - GREATEST(d.day, t.starts, ${PLAN_FROM}))))) AS secs_in
FROM tgt t
CROSS JOIN LATERAL generate_series(
date_trunc('day', t.starts),
t.ends - INTERVAL '1 microsecond',
INTERVAL '1 day'
) AS d(day)
GROUP BY t.id, t.dimension_key, t.cargo_category, t.planned_value,
t.starts, t.ends, ${bucketOf}
),
-- secs_before and actual_before are strictly-preceding running sums, so a
-- bucket's requirement is decided by what happened before it, never by its
-- own result. The frame is spelled out rather than defaulted: the default
-- RANGE frame would fold peer rows into the current one.
cascaded AS (
SELECT s.*,
COALESCE(SUM(s.secs_full) OVER prior, 0) AS secs_before,
COALESCE(SUM(a.actual) OVER prior, 0) AS actual_before
FROM spread s
LEFT JOIN (${actualsSql}) a
ON a.bucket = s.bucket
AND a.act_key = s.dimension_key
AND a.act_category IS NOT DISTINCT FROM s.cargo_category
WINDOW prior AS (
PARTITION BY s.id ORDER BY s.bucket
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
)
)
SELECT ${unit.labelOn('c.bucket')} AS period,
c.dimension_key AS plan_key,
c.cargo_category AS plan_category,
SUM(c.planned_value * c.secs_in / NULLIF(c.secs_total, 0)) AS plan_value,
SUM(GREATEST(0, c.planned_value - c.actual_before)
* c.secs_in / NULLIF(c.secs_total - c.secs_before, 0)) AS plan_required
FROM cascaded c
WHERE c.secs_in > 0
GROUP BY 1, 2, 3`;
}; };
/**
* The report's own ledger with the user's date bounds removed, for the
* attainment series {@link plannedRowsSql} cascades from. Every other filter
* stays applied, so the catch-up figure is measured on the same population as
* the `operated` column it sits beside.
*/
export const attainmentCtx = (ctx: ReportContext): ReportContext => ({
...ctx,
params: { ...ctx.params, dateFrom: null, dateTo: null },
});
/** The bindings {@link plannedRowsSql} expects. */ /** The bindings {@link plannedRowsSql} expects. */
export const plannedRowsParams = ( export const plannedRowsParams = (
params: Record<string, unknown>, params: Record<string, unknown>,

View File

@@ -86,17 +86,54 @@ describe('revenue classification', () => {
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
// Anything unrecognised — including an injection attempt — becomes 'month'. // Anything unrecognised — including an injection attempt — becomes 'month'.
expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( const injection = "day'); DROP TABLE freight.invoices; --";
"date_trunc('month'", expect(periodExpr({ period: injection })).toContain("date_trunc('month'");
); expect(periodExpr({ period: injection })).not.toContain('DROP TABLE');
expect(periodExpr({})).toContain("date_trunc('month'"); expect(periodExpr({})).toContain("date_trunc('month'");
}); });
it('offers exactly the period units the expression understands', () => { it('offers exactly the period units the expression understands', () => {
const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value);
expect(offered.length).toBe(5); expect(offered).toEqual([
for (const unit of offered) { 'day',
expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); 'week',
'month',
'quarter',
'half_year',
'nine_month',
'ninety_day',
'year',
]);
// Every offered unit resolves to its own expression rather than silently
// falling through to the month default — which is what a missing entry or a
// typo'd key would look like.
const expressions = offered.map((unit) => periodExpr({ period: unit }));
expect(new Set(expressions).size).toBe(offered.length);
});
/**
* Half-year, nine-month and ninety-day have no `date_trunc` unit, so they are
* offset arithmetic anchored to January 1st. These pin the anchor: they are
* the SQL half of a pair whose other half is `normalisePeriodStart` in
* `operations-targets.service.ts`, and a target that snaps to a boundary the
* report does not bucket on plans against a period that does not exist.
*/
it('anchors the irregular units to the start of the calendar year', () => {
for (const unit of ['half_year', 'nine_month', 'ninety_day']) {
const expr = periodExpr({ period: unit });
expect(expr).toContain("date_trunc('year'");
expect(expr).not.toContain(`date_trunc('${unit}'`);
} }
// Six- and nine-month blocks count whole months from January.
expect(periodExpr({ period: 'half_year' })).toContain("INTERVAL '6 months'");
expect(periodExpr({ period: 'nine_month' })).toContain("INTERVAL '9 months'");
// 90-day blocks count days, and cap at the fourth so the last days of
// December widen block four instead of forming a 5-day stub of their own.
const ninety = periodExpr({ period: 'ninety_day' });
expect(ninety).toContain("INTERVAL '90 days'");
expect(ninety).toContain('LEAST(');
expect(ninety).toContain('/ 90, 3)');
}); });
}); });

View File

@@ -230,22 +230,103 @@ END`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string * A granularity, as SQL builders rather than fragments to interpolate.
* or null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of five compile-time constants does.
* *
* Every format is zero-padded, so lexicographic order equals chronological * Five of the eight are plain `date_trunc` units. The other three — half-year,
* order. The growth window depends on that. * nine-month, ninety-day — have no `date_trunc` equivalent in Postgres, so they
* are offset arithmetic from the start of the calendar year. Builders let both
* kinds live behind one interface.
*/ */
const PERIOD_UNITS = { interface PeriodUnit {
day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, label: string;
week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, /** Interval one whole block wide. Only exact for the six regular units. */
month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, step: string;
/** Timestamp expression → the start of the block that timestamp falls in. */
truncOn: (dateExpr: string) => string;
/** Block-start expression → its display label. */
labelOn: (truncExpr: string) => string;
/**
* Block-start expression → the start of the NEXT block. Not always
* `+ step`: a ragged unit's final block of the year is shorter than its own
* step, so stepping past it overshoots into the wrong block.
*/
nextStartOn: (truncExpr: string) => string;
}
const regular = (trunc: string, fmt: string, label: string, step: string): PeriodUnit => ({
label,
step,
truncOn: (dateExpr) => `date_trunc('${trunc}', ${dateExpr})`,
labelOn: (truncExpr) => `to_char(${truncExpr}, '${fmt}')`,
nextStartOn: (truncExpr) => `(${truncExpr} + INTERVAL '${step}')`,
});
/**
* Blocks of `months` months counted from January, so they reset every calendar
* year. Six divides twelve and nine does not: a nine-month year is JanSep plus
* a short OctDec. That ragged tail is inherent to the unit — the alternative
* is blocks that drift out of the calendar, which is not what "calendar
* anchored" means.
*/
const monthBlocks = (months: number, marker: string, label: string): PeriodUnit => ({
label,
step: `${months} months`,
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (((EXTRACT(MONTH FROM ${dateExpr})::int - 1) / ${months}) * INTERVAL '${months} months'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-${marker}' ||` +
` ((EXTRACT(MONTH FROM ${truncExpr})::int - 1) / ${months} + 1)::text)`,
nextStartOn: (truncExpr) =>
`LEAST(${truncExpr} + INTERVAL '${months} months',` +
` date_trunc('year', ${truncExpr}) + INTERVAL '1 year')`,
});
/**
* Frozen whitelist. The runner coerces a `select` filter to a trimmed string or
* null; that string is used only as an object key here, so the user's value
* never reaches SQL — one of eight compile-time constants does.
*
* Every label is zero-padded or single-digit-bounded, so lexicographic order
* equals chronological order. The growth windows depend on that.
*/
const PERIOD_UNITS: Record<string, PeriodUnit> = {
day: regular('day', 'YYYY-MM-DD', 'Daily', '1 day'),
week: regular('week', 'IYYY-"W"IW', 'Weekly', '1 week'),
month: regular('month', 'YYYY-MM', 'Monthly', '1 month'),
// `quarter` is a valid date_trunc unit but NOT a valid interval unit — // `quarter` is a valid date_trunc unit but NOT a valid interval unit —
// INTERVAL '1 quarter' is a syntax error, so the step is spelled in months. // INTERVAL '1 quarter' is a syntax error, so the step is spelled in months.
quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' }, quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'),
year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, half_year: monthBlocks(6, 'H', 'Half-yearly'),
} as const; nine_month: monthBlocks(9, 'N', 'Nine-monthly'),
/**
* Four 90-day blocks from January 1st: days 1, 91, 181, 271.
*
* The block index is capped at 3 on purpose. Uncapped, `(doy - 1) / 90` puts
* December 27th onwards in a fifth block — a 5-day stub bucket at the end of
* every year, which is noise rather than a period. Capping instead lets the
* fourth block absorb the remainder and run 95 or 96 days.
*
* The label carries the zero-padded start day-of-year, which keeps it sorting
* chronologically and — unlike an ordinal — says out loud that the blocks are
* day-counted rather than month-aligned.
*/
ninety_day: {
label: '90-day',
step: '90 days',
truncOn: (dateExpr) =>
`(date_trunc('year', ${dateExpr})` +
` + (LEAST((EXTRACT(DOY FROM ${dateExpr})::int - 1) / 90, 3) * INTERVAL '90 days'))`,
labelOn: (truncExpr) =>
`(to_char(${truncExpr}, 'YYYY') || '-D' || lpad(EXTRACT(DOY FROM ${truncExpr})::int::text, 3, '0'))`,
// The fourth block ends with the year, not 90 days after it started.
nextStartOn: (truncExpr) =>
`(CASE WHEN EXTRACT(DOY FROM ${truncExpr})::int >= 271` +
` THEN date_trunc('year', ${truncExpr}) + INTERVAL '1 year'` +
` ELSE ${truncExpr} + INTERVAL '90 days' END)`,
},
year: regular('year', 'YYYY', 'Yearly', '1 year'),
};
export const PERIOD_FILTER: ReportFilterDef = { export const PERIOD_FILTER: ReportFilterDef = {
key: 'period', key: 'period',
@@ -274,10 +355,8 @@ export function periodExpr(params: Record<string, unknown>): string {
return periodExprOn(REVENUE_DATE, params); return periodExprOn(REVENUE_DATE, params);
} }
export function resolvePeriod( export function resolvePeriod(params: Record<string, unknown>): PeriodUnit {
params: Record<string, unknown>, const key = String(params.period ?? '');
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; return PERIOD_UNITS[key] ?? PERIOD_UNITS.month;
} }
@@ -287,10 +366,10 @@ export function resolvePeriod(
* these units so a month means the same thing on both sides of the product. * these units so a month means the same thing on both sides of the product.
*/ */
export const periodExprOn = (dateExpr: string, params: Record<string, unknown>): string => export const periodExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params));
export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string => export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): string =>
`date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`; resolvePeriod(params).truncOn(dateExpr);
/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ /** The period's start timestamp — what to GROUP BY when a report needs it numerically. */
export const periodTruncExpr = (params: Record<string, unknown>): string => export const periodTruncExpr = (params: Record<string, unknown>): string =>
@@ -305,9 +384,16 @@ export const periodTruncExpr = (params: Record<string, unknown>): string =>
export const periodOrdinalExpr = (params: Record<string, unknown>): string => export const periodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`; `EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`;
/** Same scale, one period later — where a one-step-ahead projection lands. */ /**
* Same scale, one period later — where a one-step-ahead projection lands.
*
* Asks the unit rather than adding its step, because the two differ for the
* ragged units: a nine-month year's second block is three months long, and a
* 90-day year's fourth is 95, so `+ step` would land past the next block start
* and evaluate the regression at the wrong x.
*/
export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string => export const nextPeriodOrdinalExpr = (params: Record<string, unknown>): string =>
`EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`; `EXTRACT(EPOCH FROM ${resolvePeriod(params).nextStartOn(periodTruncExpr(params))})`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Volume — measured at line grain, never joined from the booking // Volume — measured at line grain, never joined from the booking

View File

@@ -229,7 +229,18 @@ export class FreightPositionsSeeder {
return; return;
} }
await positionPermissionRepository.insert(rowsToInsert); // orIgnore, not a bare insert: the read above and this write are not
// atomic across processes — two API replicas booting together (or a
// restart racing a running boot) both see the grant missing and both
// insert it, and the loser died on UQ_87ee8f7eef7366389a02ff69f04 with
// the whole seed transaction. ON CONFLICT DO NOTHING makes the grant
// idempotent no matter who else is inserting it.
await positionPermissionRepository
.createQueryBuilder()
.insert()
.values(rowsToInsert)
.orIgnore()
.execute();
this.logger.log( this.logger.log(
`Granted ${rowsToInsert.length} permissions to position '${seed.key}'`, `Granted ${rowsToInsert.length} permissions to position '${seed.key}'`,

View File

@@ -151,6 +151,12 @@ const TRADE_DIRECTIONS = [
* a report matches a target by this exact key, so a value here that the API * a report matches a target by this exact key, so a value here that the API
* does not emit is a plan the report will never find. The API spec * does not emit is a plan the report will never find. The API spec
* `operations-classification.spec.ts` guards the API side of the pair. * `operations-classification.spec.ts` guards the API side of the pair.
*
* Drift is no longer silent: `OperationsTargetsService.assertDimensionKey`
* rejects any key outside the API's own vocabulary, so a stale entry here
* surfaces as a 400 on save rather than a plan that quietly never joins.
* `UNCLASSIFIED` is left out deliberately — the API accepts it, but there is no
* sense in planning against cargo nobody has classified.
*/ */
export const OPERATIONS_CARGO_CATEGORIES = [ export const OPERATIONS_CARGO_CATEGORIES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" }, { label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
@@ -713,14 +719,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
], ],
}, },
{ {
// Mirrors TARGET_PERIOD_LABELS in the API's operations-target entity.
// Commit the number at whatever grain the business quotes it — the
// report re-gathers it into whichever grain the viewer asks for.
name: "periodType", name: "periodType",
label: "Period", label: "Period",
type: "select", type: "select",
required: true, required: true,
options: [ options: [
{ label: "Daily", value: "day" },
{ label: "Weekly", value: "week" }, { label: "Weekly", value: "week" },
{ label: "Monthly", value: "month" }, { label: "Monthly", value: "month" },
{ label: "Quarterly", value: "quarter" }, { label: "Quarterly", value: "quarter" },
{ label: "Half-yearly", value: "half_year" },
{ label: "Nine-monthly", value: "nine_month" },
{ label: "90-day", value: "ninety_day" },
{ label: "Yearly", value: "year" }, { label: "Yearly", value: "year" },
], ],
}, },