From c8b44d4d04541b20f23d6b9171a6c28c841f203a Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 17:37:21 +0300 Subject: [PATCH 1/6] fix(operations-targets): derive cargo category from its dimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A station target's plan is per station AND per cargo type; the other two dimensions already carry the category inside dimension_key. update() kept whatever was stored whenever the payload omitted the key, and the admin form builds its payload from visible fields only — so switching "Plan by" away from Station left the old category behind on a row that no longer has any use for one. That row is not merely untidy. It survives the COALESCE(cargo_category,'') unique index alongside the legitimate null-category row for the same key, plannedRowsSql groups by the column, and the two plan rows then join the same operated row through the FULL OUTER JOIN: the category lists twice, each line carrying the full operated tonnage, while the summary tiles are computed separately and stay correct — so the table disagrees with its own totals and nothing says why. create() and update() now resolve the slot through one place, which is the point: the two paths cannot drift again. cargoCategory is derived from the effective dimension rather than carried over, and a station target without one is rejected instead of stored as a plan the report can never match. dimensionKey is checked against the vocabulary the reports actually emit — CARGO_CATEGORIES / CONTAINER_CLASSES, or live yards.code for stations. An unknown key used to store fine, list fine and fall back to showing the raw key, while being a plan no report would ever find. Also drops @Global from the module. Nothing outside it injects either service; the reports read both tables in raw SQL, so the docstring's stated reason for being global was not true. --- .../dto/create-operations-target.dto.ts | 9 +- .../operations-reporting.module.ts | 9 +- .../operations-targets.service.ts | 134 ++++++++++++++++-- .../src/pages/ruleEngine/config/resources.ts | 6 + 4 files changed, 138 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts index 2d8f5beb0..07c380cda 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts index 180c12271..e2da0eb30 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts index c33054aa5..b3753e343 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -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 { @@ -76,6 +83,27 @@ const LABELS_BY_DIMENSION: Record> = { 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, Set> = { + 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 +180,113 @@ export class OperationsTargetsService { } async create(dto: CreateOperationsTargetDto): Promise { - 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 { 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 { + 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 { + 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 { await this.findById(id); await this.repository.softDelete(id); diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index cc899322a..e95724912 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -149,6 +149,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" }, From 260d5a15908290b2e3330c40f59f4f5fa27edc3b Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 17:40:35 +0300 Subject: [PATCH 2/6] feat(reports): cascade an unmet plan onto the periods that remain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A target is a quota, not a flat allowance. The Plan column spread it evenly and kept asking for the same twelfth of a yearly figure no matter how far behind the year had fallen, so the one number operations actually needs — what must move per month for the rest of the year — was nowhere on the page. Plan now keeps its meaning and a Required column sits beside it. Plan is the committed spread and never moves, which is the whole reason it stays: Implement Rate is measured against it, so a month that missed still reads as a month that missed. Required is the same target read as a quota — at each bucket, whatever is still outstanding spread across the time still left. A 1,200 t year 20% met by June asks 140 t of June and 960 t of December, which is 1,200 less the 240 delivered. Over-delivery clamps to zero rather than going negative. Attainment is deliberately measured with the user's date bounds stripped (attainmentCtx) and every other filter left in place. Reusing the report's own filtered aggregate would make a July-only view read year-to-date as nothing delivered and demand the entire year's tonnage from one month — the failure would look like a plausible number, not an error. Granularity gains half-year, nine-month and 90-day. Postgres has no date_trunc for any of them, so PERIOD_UNITS entries became builders rather than fragments to interpolate, and all eight blocks anchor to the calendar year. Nine does not divide twelve and 90 does not divide 365: a nine-month year is Jan-Sep plus a short Oct-Dec, and the fourth 90-day block absorbs the remainder at 95 days. That last one is a choice — uncapped floor division opens a five-day stub bucket every December, which is noise rather than a period. Two consequences of the shared unit table, both handled here: - plannedRowsSql now generates a day at a time and groups, instead of stepping by the bucket width. The ragged blocks restart each January, so stepping 90 days from January 1st walks off the anchor in the second year. Day grain also gets partial-bucket overlap for free, at the same sub-day precision the old clipping had. - nextPeriodOrdinalExpr asks the unit for its next block start rather than adding its own step. revenue-by-period evaluates a regression there, and a ragged unit's final block is shorter than its nominal width, so + step would land past the next block and forecast at the wrong x. Verified against Postgres 16 with the entities synchronised into it: all 272 report/granularity combinations in the registry EXPLAIN clean, and the 1,200 t drill-down sums back to 1,200 at every one of the eight grains. --- .../cargo-volume-by-station.report.ts | 31 ++- .../cargo-volume-performance.report.ts | 30 ++- .../definitions/teu-performance.report.ts | 29 ++- .../trainset-performance.report.ts | 29 ++- .../reports/operations-classification.ts | 197 ++++++++++++------ .../reports/revenue-classification.spec.ts | 49 ++++- .../modules/reports/revenue-classification.ts | 126 +++++++++-- 7 files changed, 395 insertions(+), 96 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts index dd111d22a..c63a28f05 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts index 68c255c11..2bb02b859 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts index 6bec4b29a..adc108a02 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts index 8e1c52434..385b69dc2 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index ed39536b7..1c8f8504c 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -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, + 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, diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts index 42475d591..abc63fc04 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts @@ -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)'); }); }); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 7beb79334..e3ba0a65d 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -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 = { + 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 { return periodExprOn(REVENUE_DATE, params); } -export function resolvePeriod( - params: Record, -): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { - const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; +export function resolvePeriod(params: Record): 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 => - `to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; + resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params)); export const periodTruncExprOn = (dateExpr: string, params: Record): 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 => @@ -305,9 +384,16 @@ export const periodTruncExpr = (params: Record): string => export const periodOrdinalExpr = (params: Record): 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 => - `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 From 23b3f265e64c1984575112ddd99820cd5760d580 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 17:40:51 +0300 Subject: [PATCH 3/6] feat(operations-targets): plan at any of the eight report grains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets could only be committed weekly, monthly, quarterly or yearly, so a figure the business quotes per half-year or per 90 days had to be split by hand into buckets it was never expressed in. The reports already re-gather a target into whatever grain the viewer asks for; this just lets the plan be entered at the grain it was agreed in. Adds day, half-year, nine-month and 90-day, matching the report units added alongside. normalisePeriodStart snaps each to its block start with the same calendar-year anchoring the SQL uses — Jan/Jul for half-years, Jan/Oct for nine-months, days 1/91/181/271 for 90-day blocks, including the same cap on the fourth block so late December does not snap into a stub of its own. That agreement is the load-bearing part. The unique index is keyed on period_start, and a target snapped to a boundary the report does not bucket on is a plan measured against a period that does not exist. The two halves live in different files and different languages, so the spec pins the boundaries rather than trusting them to stay in step. Adds operations-targets.service.spec.ts, which the module had none of: every period type, idempotency, the leap year, and the block-four cap. --- .../entities/operations-target.entity.ts | 26 +++- .../operations-targets.service.spec.ts | 144 ++++++++++++++++++ .../operations-targets.service.ts | 33 +++- .../src/pages/ruleEngine/config/resources.ts | 7 + 4 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts index aec11ae24..1bcbe908b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts @@ -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 = { }; export const TARGET_PERIOD_LABELS: Record = { + day: 'Daily', week: 'Weekly', month: 'Monthly', quarter: 'Quarterly', + half_year: 'Half-yearly', + nine_month: 'Nine-monthly', + ninety_day: '90-day', year: 'Yearly', }; diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts new file mode 100644 index 000000000..69ad38db6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts @@ -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([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts index b3753e343..fbcbc6d7b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -26,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 @@ -38,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; @@ -50,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; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index e95724912..10fef2982 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -704,14 +704,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" }, ], }, From 6d113ca00c1e761ca66cc905a0e34afbee528a84 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 17:47:41 +0300 Subject: [PATCH 4/6] fix(reports): classify receivables and payables by real money flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receivable/payable split contradicted how money actually moves, in three ways that each changed a headline number: - The wagon-cancellation FEE was booked as a payable. It is money the customer owes EDR (raised ISSUED and unpaid at request time), so it belongs on the receivable side while open. The sign was inverted. - A whole-booking wagon cancellation was booked at the source invoice's full paid_amount, and never cleared: the booking stays CANCELLED and the invoice stays PAID even after the credit is rebooked. The real liability is the ledger row's credit_amount, and only while it sits in CREDIT_AVAILABLE — cancellation refunds no cash, it hands back bookable credit redeemed by creating another booking. - Shipping-line debt in UNBILLED has no invoice row at all, so an invoice-only fact table could not see it. That is the un-batched half of the debt, in the report whose stated purpose is shipping-line credit. The report is now a UNION of the three tables that hold the answer: invoices with a balance (plus prepayments against dead bookings), UNBILLED shipping_line_credits, and CREDIT_AVAILABLE booking_wagon_cancellations. A booking already carried by the cancellation ledger is excluded from the invoice branch so its money is counted once. Fully settled invoices are dropped — zero exposure is neither a receivable nor a payable. Branches are re-projected through an explicit column list before being unioned: UNION matches by position and TypeORM does not preserve addSelect order, which silently reordered one branch into "gross, exposure, side_key, ..." and failed with "UNION types text and numeric cannot be matched". Verified against Postgres with a rollback-only fixture covering every side, plus EXPLAIN over each filter combination and every sortable column. --- .../receivables-payables.report.ts | 419 +++++++++++++++--- 1 file changed, 361 insertions(+), 58 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts index 018a68faf..0e07710bb 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -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, + ctx: ReportContext, + dateExpr: string, +): SelectQueryBuilder { + 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 { + 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 { + 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 { + 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 { - 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) }, ]; }, }; From 53e4b93ac9cf7cba2d6a4b9861f9af4138c5cba7 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 17:49:02 +0300 Subject: [PATCH 5/6] test(reports): pin the receivable/payable ledger sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards the two facts the report exists to get right: the cancellation fee is never a payable, and exactly one wagon-cancellation status (CREDIT_AVAILABLE) is a live liability. Adding a status to WAGON_CANCELLATION_STATUSES now fails here until someone decides which side of the ledger it lands on. Also pins the sort expressions to the union wrapper alias — a branch alias would resolve at build time and 42P01 at runtime, since the runner appends ORDER BY outside the subquery. --- .../receivables-payables.report.spec.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts new file mode 100644 index 000000000..76edd2238 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts @@ -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\./); + } + }); +}); From 47a0ba5ee6f9c3ea6d4bcb768206596e4cb531d9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 21 Aug 2026 19:24:00 +0000 Subject: [PATCH 6/6] fix: pos seeder --- .../src/seed/freight-positions.seeder.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts index 81df21a17..9357398c7 100644 --- a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -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}'`,