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({
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',
})
@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()
@MaxLength(60)
cargoCategory?: string;
cargoCategory?: string | null;
@ApiPropertyOptional()
@IsOptional()

View File

@@ -1,8 +1,26 @@
import { BaseEntity } from '@edr/api-common';
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];
/** What is being planned. */
@@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record<TargetDimension, string> = {
};
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
quarter: 'Quarterly',
half_year: 'Half-yearly',
nine_month: 'Nine-monthly',
ninety_day: '90-day',
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 { 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
* actuals against.
*
* Global because the reports module reads the standards row on every run and
* has no other reason to import this.
* Not global, and deliberately so: nothing outside this module injects either
* 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({
imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])],
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 { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, IsNull, Repository } from 'typeorm';
@@ -12,6 +17,8 @@ import {
TARGET_DIMENSION_LABELS,
TARGET_METRIC_LABELS,
TARGET_PERIOD_LABELS,
TargetDimension,
TargetMetric,
TargetPeriodType,
} from './entities/operations-target.entity';
import {
@@ -19,10 +26,19 @@ import {
CONTAINER_CLASSES,
} from '../reports/operations-classification';
const MS_PER_DAY = 86_400_000;
/**
* Normalises any date inside a bucket to the bucket's first day, matching
* Postgres `date_trunc` — which is what the reports group by. Week starts
* Monday, the same as `date_trunc('week', …)` and ISO week numbering.
* Normalises any date inside a bucket to the bucket's first day, matching the
* bucket expression the reports group by (`PERIOD_UNITS` in
* `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
* 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 {
const d = new Date(`${value.slice(0, 10)}T00:00:00Z`);
switch (periodType) {
case 'day':
break;
case 'week': {
// getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in.
const offset = (d.getUTCDay() + 6) % 7;
@@ -43,6 +61,22 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string
case 'quarter':
d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1);
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':
d.setUTCMonth(0, 1);
break;
@@ -76,6 +110,27 @@ const LABELS_BY_DIMENSION: Record<string, Map<string, string>> = {
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()
export class OperationsTargetsService {
constructor(
@@ -152,35 +207,113 @@ export class OperationsTargetsService {
}
async create(dto: CreateOperationsTargetDto): Promise<OperationsTarget> {
const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart);
const cargoCategory = dto.cargoCategory ?? null;
await this.assertSlotFree({ ...dto, periodStart, cargoCategory });
return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory }));
const slot = await this.resolveSlot(dto);
await this.assertSlotFree(slot);
return this.repository.save(this.repository.create({ ...dto, ...slot }));
}
async update(id: string, dto: UpdateOperationsTargetDto): Promise<OperationsTarget> {
const current = await this.findById(id);
const periodType = dto.periodType ?? current.periodType;
const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart);
const next = {
periodType,
periodStart,
const slot = await this.resolveSlot({
periodType: dto.periodType ?? current.periodType,
periodStart: dto.periodStart ?? current.periodStart,
metric: dto.metric ?? current.metric,
dimension: dto.dimension ?? current.dimension,
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:
dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null,
};
await this.assertSlotFree(next, id);
dto.cargoCategory !== undefined ? dto.cargoCategory : current.cargoCategory,
});
await this.assertSlotFree(slot, id);
await this.repository.update(id, {
...next,
...slot,
...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}),
...(dto.note !== undefined ? { note: dto.note } : {}),
});
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> {
await this.findById(id);
await this.repository.softDelete(id);

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
@@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'teu', label: 'TEU', type: 'number' },
{ key: 'wagons', label: 'Wagons', type: 'number' },
@@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addGroupBy(originationExpr(params, 'code'))
.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
// 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.
@@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons,
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
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
AND p.plan_key = o.station_code
AND p.plan_category = o.category_key`;
@@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = {
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) })
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(params),
})
.select('r.period', 'period')
.addSelect('r.station', 'station')
.addSelect('r.origination', 'origination')
@@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = {
.addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.teu::int', 'teu')
.addSelect('r.wagons::int', 'wagons')

View File

@@ -13,6 +13,7 @@ import {
TEU_EXPR,
allocationLedgerQb,
applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
@@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
{ key: 'category', label: 'Cargo category', type: 'string', sortable: true },
{ key: 'operated', label: 'Operated', type: 'tons', sortable: true },
{ key: 'plan', label: 'Plan', type: 'tons' },
{ key: 'planRequired', label: 'Required', type: 'tons' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
{ key: 'teu', label: 'TEU', type: 'number', sortable: true },
@@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
.groupBy(bucket)
.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
// reports its miss instead of disappearing from the table.
const combined = `
@@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = {
COALESCE(o.teu, 0) AS teu,
COALESCE(o.wagons, 0) AS wagons,
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
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`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate')
.addSelect('r.charged_tons::float8', 'chargedTons')
.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 { 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 {
PAYER_EXPR,
@@ -10,47 +17,287 @@ import {
} from '../revenue-classification';
export const LEDGER_SIDES: ReportFilterOption[] = [
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' },
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' },
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' },
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' },
{ value: 'SETTLED', label: 'Settled' },
{
value: 'RECEIVABLE_SL_UNBILLED',
label: 'Receivable — shipping-line service, not yet invoiced',
},
{
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
* arrangement, plus any invoice still carrying a balance.
* Payable = the customer paid for something EDR did not deliver, so the money
* is a refund liability rather than revenue: cancellation fees, and prepaid
* invoices whose booking died.
* RECEIVABLE — money EDR is owed. The shipping-line arrangement is service
* first, pay later, and it produces debt in two shapes: a `shipping_line_credits`
* row with NO invoice while it is UNBILLED (a shipping-line booking raises no
* invoice at all), and an open batch invoice once finance bills it. Counting
* 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 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`;
/** Money at stake on this row: what is owed, or what may have to be given back. */
const EXPOSURE = `CASE
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount
ELSE i.balance_amount
END`;
/**
* The union's column contract, in positional order.
*
* UNION matches by POSITION, and TypeORM does not preserve `addSelect` order —
* 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> {
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;
if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides });
if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides });
return qb;
}
@@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = {
key: 'receivables-payables',
title: 'Receivables and Payables',
description:
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' +
'including shipping-line credit services — and payable, where the customer paid but ' +
'the service was not delivered, such as cancellation fees and prepayments against ' +
'dead bookings. Payable amounts are a refund liability, not revenue.',
'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' +
'shipping-line credit services whether invoiced yet or not, plus any invoice still ' +
'carrying a balance — and payable, where the customer paid and the service was not ' +
'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',
filters: [
...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: [
{ key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR },
{ key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE },
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' },
{
key: 'side',
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: '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: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' },
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE },
{
key: 'customer',
label: 'Payer',
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' },
chart: { type: 'bar', x: 'side', y: ['exposure'] },
query(ctx) {
return baseQuery(ctx)
.select(SIDE_LABEL_EXPR, 'side')
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef')
.addSelect("COALESCE(b.status, '—')", 'bookingStatus')
.addSelect(PAYER_EXPR, 'customer')
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced')
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid')
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure');
.select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
.addSelect('r.doc_ref', 'invoiceNumber')
.addSelect('r.booking_ref', 'bookingRef')
.addSelect('r.booking_status', 'bookingStatus')
.addSelect('r.payer', 'customer')
.addSelect('ROUND(r.gross, 2)::float8', 'invoiced')
.addSelect('ROUND(r.settled, 2)::float8', 'paid')
.addSelect('ROUND(r.exposure, 2)::float8', 'exposure');
},
async summary(ctx) {
const row = await baseQuery(ctx)
.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',
)
.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',
)
.addSelect('COUNT(*)::int', 'invoices')
.getRawOne<{ receivable: number; payable: number; invoices: number }>();
.addSelect('COUNT(*)::int', 'items')
.getRawOne<{ receivable: number; payable: number; items: number }>();
const receivable = Number(row?.receivable ?? 0);
const payable = Number(row?.payable ?? 0);
const currency = currencyOf(ctx.params);
return [
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency },
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency },
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Receivable', value: receivable, unit: currency },
{ label: 'Payable', value: payable, unit: currency },
{
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,
TEU_EXPR,
allocationLedgerQb,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
@@ -60,6 +61,7 @@ export const teuPerformanceReport: ReportDefinition = {
{ key: "containers40", label: "40ft", type: "number", sortable: true },
{ key: "operated", label: "Operated (TEU)", type: "number", sortable: true },
{ key: "plan", label: "Plan", type: "number" },
{ key: "planRequired", label: "Required", type: "number" },
{ key: "implementRate", label: "Implement rate", type: "percent" },
],
defaultSort: { key: "operated", dir: "DESC" },
@@ -75,6 +77,16 @@ export const teuPerformanceReport: ReportDefinition = {
.groupBy(bucket)
.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
// reports, at zero rather than vanishing.
const combined = `
@@ -83,15 +95,25 @@ export const teuPerformanceReport: ReportDefinition = {
COALESCE(o.containers20, 0) AS containers20,
COALESCE(o.containers40, 0) AS containers40,
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
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`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, "r")
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select("r.period", "period")
.addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass")
.addSelect("r.class_key", "containerClassKey")
@@ -99,6 +121,7 @@ export const teuPerformanceReport: ReportDefinition = {
.addSelect("r.containers40::int", "containers40")
.addSelect("r.operated::int", "operated")
.addSelect("r.plan::float8", "plan")
.addSelect("r.plan_required::float8", "planRequired")
.addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate");
},
async summary(ctx) {

View File

@@ -13,6 +13,7 @@ import {
TRAINSETS_EXPR,
allocationLedgerQb,
applyCategoryFilter,
attainmentCtx,
PLAN_GRANULARITY_NOTE,
implementRateExpr,
plannedRowsParams,
@@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
{ key: 'wagons', label: 'Wagons', type: 'number', sortable: true },
{ key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true },
{ key: 'plan', label: 'Plan', type: 'number' },
{ key: 'planRequired', label: 'Required', type: 'number' },
{ key: 'implementRate', label: 'Implement rate', type: 'percent' },
],
defaultSort: { key: 'operated', dir: 'DESC' },
@@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = {
.groupBy(bucket)
.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,
// at zero — TypeORM's builder has no full-outer join, hence the raw text.
const combined = `
@@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = {
COALESCE(o.trains, 0) AS trains,
COALESCE(o.wagons, 0) AS wagons,
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
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`;
return ctx.ds
.createQueryBuilder()
.from(`(${combined})`, 'r')
.setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) })
.setParameters({
...operated.getParameters(),
...attained.getParameters(),
...plannedRowsParams(ctx.params),
})
.select('r.period', 'period')
.addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category')
.addSelect('r.category_key', 'categoryKey')
@@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = {
.addSelect('r.wagons::int', 'wagons')
.addSelect('r.operated::float8', 'operated')
.addSelect('r.plan::float8', 'plan')
.addSelect('r.plan_required::float8', 'planRequired')
.addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate');
},
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
* spread evenly across the days it covers, then re-gathered into the report's
* buckets. One rule covers every direction — three monthly targets add up to a
* **Plan** — a target is a rate over its own period, not a lump at its start.
* The committed value is spread evenly across the days it covers and
* 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
* 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
* figure carries no information about which days inside it were busier.
* **Required** — the same target read as a quota. At each bucket, whatever is
* 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
* the plan always covers exactly the span the operated figure beside it covers.
* Without that, filtering to July and viewing by year would put a whole year's
* plan next to one month's work.
* `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must
* be built **without the user's date bounds** — see {@link attainmentCtx}.
* Attainment is a fact about the target's whole period; measuring it through
* 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
* 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
* 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 = (
metric: string,
dimension: string,
params: Record<string, unknown>,
actualsSql: string,
): string => {
const unit = resolvePeriod(params);
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
const bucketOf = unit.truncOn('d.day');
return `
SELECT to_char(g.bucket, '${unit.fmt}') AS period,
ot.dimension_key AS plan_key,
ot.cargo_category AS plan_category,
SUM(ot.planned_value * (
GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO})
- GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM}))))
/ NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0)
)) AS plan_value
FROM freight.operations_targets ot
CROSS JOIN LATERAL (
SELECT ot.period_start + CASE ot.period_type
WHEN 'week' THEN INTERVAL '7 days'
WHEN 'month' THEN INTERVAL '1 month'
WHEN 'quarter' THEN INTERVAL '3 months'
WHEN 'year' THEN INTERVAL '1 year'
ELSE INTERVAL '1 day'
END AS ends
) t
CROSS JOIN LATERAL generate_series(
date_trunc('${unit.trunc}', ot.period_start::timestamptz),
date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'),
INTERVAL '${unit.step}'
) AS g(bucket)
WHERE ot.deleted_at IS NULL
AND ot.metric = '${metric}'
AND ot.dimension = '${dimension}'
AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM}
AND g.bucket < ${PLAN_TO}
GROUP BY 1, 2, 3
HAVING SUM(ot.planned_value) > 0`;
WITH tgt AS (
SELECT ot.id,
ot.dimension_key,
ot.cargo_category,
ot.planned_value,
ot.period_start::timestamptz AS starts,
ot.period_start::timestamptz + ${TARGET_SPAN} AS ends
FROM freight.operations_targets ot
WHERE ot.deleted_at IS NULL
AND ot.metric = '${metric}'
AND ot.dimension = '${dimension}'
AND ot.planned_value > 0
),
-- One row per target per bucket. Generated a day at a time rather than a
-- bucket at a time: the ragged units restart their blocks each January, so
-- stepping by the unit's own width walks off the anchor in the second year.
-- Day grain also makes a bucket that only partly overlaps the target fall out
-- for free, at the same sub-day precision the clipping used before.
spread AS (
SELECT t.id,
t.dimension_key,
t.cargo_category,
t.planned_value,
EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total,
${bucketOf} AS bucket,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends)
- GREATEST(d.day, t.starts))))) AS secs_full,
SUM(GREATEST(0, EXTRACT(EPOCH FROM (
LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO})
- 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. */
export const plannedRowsParams = (
params: Record<string, unknown>,

View File

@@ -86,17 +86,54 @@ describe('revenue classification', () => {
expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'");
expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'");
// Anything unrecognised — including an injection attempt — becomes 'month'.
expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain(
"date_trunc('month'",
);
const injection = "day'); DROP TABLE freight.invoices; --";
expect(periodExpr({ period: injection })).toContain("date_trunc('month'");
expect(periodExpr({ period: injection })).not.toContain('DROP TABLE');
expect(periodExpr({})).toContain("date_trunc('month'");
});
it('offers exactly the period units the expression understands', () => {
const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value);
expect(offered.length).toBe(5);
for (const unit of offered) {
expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`);
expect(offered).toEqual([
'day',
'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
* 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.
* A granularity, as SQL builders rather than fragments to interpolate.
*
* Every format is zero-padded, so lexicographic order equals chronological
* order. The growth window depends on that.
* Five of the eight are plain `date_trunc` units. The other three — half-year,
* 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 = {
day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' },
week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' },
month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' },
interface PeriodUnit {
label: string;
/** Interval one whole block wide. Only exact for the six regular units. */
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 —
// 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' },
year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' },
} as const;
quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'),
half_year: monthBlocks(6, 'H', 'Half-yearly'),
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 = {
key: 'period',
@@ -274,10 +355,8 @@ export function periodExpr(params: Record<string, unknown>): string {
return periodExprOn(REVENUE_DATE, params);
}
export function resolvePeriod(
params: Record<string, unknown>,
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
export function resolvePeriod(params: Record<string, unknown>): PeriodUnit {
const key = String(params.period ?? '');
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.
*/
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 =>
`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. */
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 =>
`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 =>
`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

View File

@@ -229,7 +229,18 @@ export class FreightPositionsSeeder {
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(
`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
* 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.
*
* 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 = [
{ 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",
label: "Period",
type: "select",
required: true,
options: [
{ label: "Daily", value: "day" },
{ label: "Weekly", value: "week" },
{ label: "Monthly", value: "month" },
{ 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" },
],
},