mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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()
|
||||
|
||||
@@ -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 (Oct–Dec
|
||||
* and the 5–6 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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
// Oct–Dec 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: Jan–Sep, then a short Oct–Dec.
|
||||
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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 Jan–Sep plus
|
||||
* a short Oct–Dec. 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
|
||||
|
||||
@@ -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}'`,
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user