diff --git a/apps/web/app/components/ComboTrendChart.tsx b/apps/web/app/components/ComboTrendChart.tsx
new file mode 100644
index 000000000..9760cb17b
--- /dev/null
+++ b/apps/web/app/components/ComboTrendChart.tsx
@@ -0,0 +1,158 @@
+import { useState } from 'react';
+import type { TrendGranularity, TrendPoint } from '@sigma/api-contract';
+import { count, money, monthYear } from '@sigma/shared';
+import { yearAxisTicks } from '../lib/trendAxis';
+
+// Bar + line combo for the contracts overview (/trends): bars carry the contract count, the ink line
+// the € volume. Server-rendered SVG like TrendChart; the only client behavior is the hover tooltip
+// (React state after hydration — SSR renders the chart without it, so no-JS still gets the picture).
+// The accessible data lives in the year cards next to the chart, matching the TrendChart pattern.
+
+const W = 1000;
+const H = 300;
+const TOP = 10;
+const BOT = 272;
+const PAD = 8;
+
+/** 'YYYY-MM' → 'март 2024', 'YYYY-Qn' → 'Q1 2024', 'YYYY' → '2024'. */
+export function periodLabel(period: string, granularity: TrendGranularity): string {
+ if (granularity === 'year') return period;
+ if (granularity === 'quarter') {
+ const [y, q] = period.split('-Q');
+ return `Q${q} ${y}`;
+ }
+ return monthYear(period);
+}
+
+export function ComboTrendChart({
+ points,
+ granularity,
+ cssHeight = 240,
+ interactive = true,
+ ariaLabel = 'Брой договори и € обем във времето',
+}: {
+ points: TrendPoint[];
+ granularity: TrendGranularity;
+ cssHeight?: number;
+ interactive?: boolean;
+ ariaLabel?: string;
+}) {
+ const [hover, setHover] = useState(null);
+ if (points.length < 2) return null;
+
+ const n = points.length;
+ const vMax = Math.max(1, ...points.map((p) => p.valueEur)) * 1.12;
+ const cMax = Math.max(1, ...points.map((p) => p.contracts));
+ const x = (i: number) => (n > 1 ? PAD + (i * (W - 2 * PAD)) / (n - 1) : W / 2);
+ const yV = (v: number) => BOT - (v / vMax) * (BOT - TOP);
+ const yC = (c: number) => BOT - (c / cMax) * (BOT - TOP) * 0.62;
+ const bw = Math.max(2, ((W - 2 * PAD) / n) * 0.66);
+ // Bars are centred on x(i), and x(0)/x(n-1) sit on the plot edges — so the first/last bar would
+ // overflow the viewBox by bw/2 (severe at n=2, bw≈324). Inset just the bar x-position at the ends;
+ // the line/cursor/dot keep using x(i) so the value series stays anchored to the true period edges.
+ const barX = (i: number) => Math.min(W - PAD - bw / 2, Math.max(PAD + bw / 2, x(i)));
+
+ // Final period is partial (still filling): dashed line tail + faded bar, like TrendChart.
+ const partialIdx = points.findIndex((p) => p.partial);
+ // partialIdx > 0 also treats "no partial point" (findIndex returns -1) as non-partial, and a
+ // partial flag on the very first point (index 0) as non-partial too — the latter never happens
+ // in practice (the first period is never still-filling), matching TrendChart's same assumption.
+ const hasPartial = partialIdx > 0;
+ const solidEnd = hasPartial ? partialIdx - 1 : n - 1;
+ const xy = (i: number) => `${x(i).toFixed(1)} ${yV(points[i]!.valueEur).toFixed(1)}`;
+ const line = points
+ .slice(0, solidEnd + 1)
+ .map((_p, i) => `${i ? 'L' : 'M'}${xy(i)}`)
+ .join(' ');
+ const dashed = hasPartial ? `M${xy(solidEnd)} L${xy(partialIdx)}` : '';
+
+ const ticks = yearAxisTicks(points, granularity);
+
+ const hp = hover != null ? points[hover] : null;
+
+ return (
+ interactive && setHover(null)}>
+
+ {[0, 1 / 3, 2 / 3, 1].map((f) => (
+
+ ))}
+ {points.map((p, i) => (
+ setHover(i) : undefined}
+ />
+ ))}
+
+ {hasPartial && (
+
+ )}
+ {hp && hover != null && (
+ <>
+
+
+ >
+ )}
+
+
+ {ticks.map((t) => (
+ {t.year}
+ ))}
+
+ {hp && hover != null && (
+
+
+ {periodLabel(hp.period, granularity)}
+ {hp.partial ? ' · частично' : ''}
+
+
+ € обем
+ {money(hp.valueEur)}
+
+
+ договори
+ {count(hp.contracts)}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/app/components/MetricInfo.tsx b/apps/web/app/components/MetricInfo.tsx
new file mode 100644
index 000000000..527d6b6c1
--- /dev/null
+++ b/apps/web/app/components/MetricInfo.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+// SSR has no DOM, so useLayoutEffect on the server both does nothing useful and logs React's
+// "useLayoutEffect does nothing on the server" warning. Swap to useEffect during SSR (typeof
+// window guards it) while keeping the client on useLayoutEffect for its pre-paint clamp.
+const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
+
+// A small ⓘ affordance next to a metric label. For pointer users it reveals an elegant popover on
+// hover or keyboard focus (pure CSS `:hover` / `:focus-within`). Because hover does not exist on
+// touch, a click also toggles the popover open via an `is-open` class — and an outside-click or Esc
+// closes it again. The button carries the full text as its aria-label, so screen-reader users get the
+// same information without the visual popover (which is aria-hidden). SSR-safe: the initial render is
+// closed and the toggle/effects only run on the client.
+export function MetricInfo({
+ title,
+ summary,
+ readout,
+ align = 'start',
+}: {
+ title: string;
+ summary: string;
+ // Plain string so the readout is always reflected verbatim into the aria-label (all callers pass a
+ // string — the screen-reader text must never silently drop a non-string interpretation).
+ readout?: string;
+ // Which edge the popover anchors to — use 'end' for right-most metrics so it doesn't clip.
+ align?: 'start' | 'end';
+}) {
+ const aria = readout ? `${title}. ${summary} ${readout}`.trim() : `${title}. ${summary}`;
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const popRef = useRef(null);
+ // Horizontal shift (px) that keeps the click-opened popover inside the viewport on small screens
+ // (mobile audit: at 320px the fixed-width popover clips off-screen for edge-column metrics).
+ const [shift, setShift] = useState(0);
+
+ useIsomorphicLayoutEffect(() => {
+ if (!open) {
+ setShift(0);
+ return;
+ }
+ const pop = popRef.current;
+ if (!pop) return;
+ const rect = pop.getBoundingClientRect();
+ const vw = document.documentElement.clientWidth;
+ let dx = 0;
+ if (rect.right > vw - 8) dx = vw - 8 - rect.right;
+ if (rect.left + dx < 8) dx = 8 - rect.left;
+ setShift(Math.round(dx));
+ }, [open]);
+
+ // Close on outside-click / Esc while open (touch path — pointer users rely on CSS hover/focus).
+ useEffect(() => {
+ if (!open) return;
+ const onPointer = (e: PointerEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setOpen(false);
+ };
+ document.addEventListener('pointerdown', onPointer);
+ document.addEventListener('keydown', onKey);
+ return () => {
+ document.removeEventListener('pointerdown', onPointer);
+ document.removeEventListener('keydown', onKey);
+ };
+ }, [open]);
+
+ return (
+
+ setOpen((v) => !v)}
+ >
+
+ ⓘ
+
+
+
+ {title}
+ {summary}
+ {readout ? {readout} : null}
+
+
+ );
+}
diff --git a/apps/web/app/components/TrendChart.tsx b/apps/web/app/components/TrendChart.tsx
index 9248669de..520df384f 100644
--- a/apps/web/app/components/TrendChart.tsx
+++ b/apps/web/app/components/TrendChart.tsx
@@ -1,4 +1,5 @@
-import type { TrendPoint } from '@sigma/api-contract';
+import type { TrendGranularity, TrendPoint } from '@sigma/api-contract';
+import { yearAxisTicks } from '../lib/trendAxis';
// Server-rendered area + line of spend over time (no chart JS, like SankeyDiagram). The accessible
// data is the per-year table beside it; this SVG is a visual summary (role="img" + aria-label) with
@@ -13,7 +14,7 @@ export function TrendChart({
granularity,
}: {
points: TrendPoint[];
- granularity: 'month' | 'year';
+ granularity: TrendGranularity;
}) {
if (points.length < 2) return null;
const max = Math.max(1, ...points.map((p) => p.valueEur));
@@ -32,10 +33,7 @@ export function TrendChart({
.join('');
const area = `${line}L${x(solidEnd).toFixed(1)},${H - PAD_B}L0,${H - PAD_B}Z`;
const dashed = hasPartial ? `M${xy(solidEnd)}L${xy(partialIdx)}` : '';
- // x-axis ticks at the first month of each year (month granularity) or at every point (year).
- const ticks = points
- .map((p, i) => ({ i, year: p.period.slice(0, 4) }))
- .filter((t, idx) => granularity === 'year' || points[idx]!.period.endsWith('-01'));
+ const ticks = yearAxisTicks(points, granularity);
// viewBox carries 14px of horizontal bleed on each side so the first and last year labels, which are
// centred on the edge ticks, are not clipped.
diff --git a/apps/web/app/lib/analytics-lenses.ts b/apps/web/app/lib/analytics-lenses.ts
index 8e14b0317..dcac9ca5e 100644
--- a/apps/web/app/lib/analytics-lenses.ts
+++ b/apps/web/app/lib/analytics-lenses.ts
@@ -11,14 +11,19 @@ export const ANALYTICS_LENSES = [
},
{
href: '/trends',
- title: 'Тренд',
- desc: 'Как се движат разходите във времето по месеци и години.',
+ title: 'Договори — обзор',
+ desc: 'Договорите във времето, по CPV код, или двете наведнъж — с типичните цени по група.',
},
{
href: '/competition',
title: 'Конкуренция',
desc: 'Къде има висок дял „една оферта“ и концентрация на доставчици.',
},
+ {
+ href: '/quality',
+ title: 'Индекс на качеството',
+ desc: 'Колко здрав е процесът по всеки договор: пет измерения, една оценка 0–100.',
+ },
] as const;
export const ANALYTICS_NAV_PATHS = [
diff --git a/apps/web/app/lib/etl.ts b/apps/web/app/lib/etl.ts
new file mode 100644
index 000000000..fc4b9da8f
--- /dev/null
+++ b/apps/web/app/lib/etl.ts
@@ -0,0 +1,5 @@
+/** True for the expected "table doesn't exist yet" error the daily ETL derive can leave behind
+ * (before the first derive, or mid-rebuild since ship-domain drops+recreates contract_features). */
+export function isMissingDerivedTableError(err: unknown): boolean {
+ return /no such table/i.test(err instanceof Error ? err.message : String(err));
+}
diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts
index 158f16093..afdc00066 100644
--- a/apps/web/app/lib/filters.test.ts
+++ b/apps/web/app/lib/filters.test.ts
@@ -9,6 +9,7 @@ import {
MAX_MULTI_VALUES,
pageNav,
PARAM_ORDER,
+ qualityRankingControls,
searchHref,
withParams,
} from './filters';
@@ -344,6 +345,53 @@ describe('pageNav', () => {
});
});
+describe('qualityRankingControls', () => {
+ it('parses the /quality „Разбивка" controls the loader consumes', () => {
+ expect(qualityRankingControls(sp('rdir=desc&rfrom=10&rto=60'))).toEqual({
+ rankDir: 'desc',
+ rankFrom: 10,
+ rankTo: 60,
+ });
+ expect(qualityRankingControls(sp(''))).toEqual({
+ rankDir: null,
+ rankFrom: null,
+ rankTo: null,
+ });
+ });
+
+ it('accepts only asc|desc for ?rdir — anything else falls back to the default order', () => {
+ expect(qualityRankingControls(sp('rdir=asc')).rankDir).toBe('asc');
+ expect(qualityRankingControls(sp('rdir=down')).rankDir).toBeNull();
+ expect(qualityRankingControls(sp('rdir=DESC')).rankDir).toBeNull();
+ expect(qualityRankingControls(sp("rdir=asc'--")).rankDir).toBeNull();
+ });
+
+ it('validates ?rfrom/?rto as ints in [0, 100] and drops malformed bounds (CWE-349)', () => {
+ expect(qualityRankingControls(sp('rfrom=0&rto=100'))).toMatchObject({
+ rankFrom: 0,
+ rankTo: 100,
+ });
+ expect(qualityRankingControls(sp('rfrom=35')).rankFrom).toBe(35); // one-sided range is fine
+ expect(qualityRankingControls(sp('rto=35')).rankTo).toBe(35);
+ expect(qualityRankingControls(sp('rfrom=101')).rankFrom).toBeNull();
+ expect(qualityRankingControls(sp('rfrom=-1')).rankFrom).toBeNull();
+ expect(qualityRankingControls(sp('rfrom=1.5')).rankFrom).toBeNull();
+ expect(qualityRankingControls(sp('rfrom=abc')).rankFrom).toBeNull();
+ expect(qualityRankingControls(sp('rfrom=5 OR 1=1')).rankFrom).toBeNull();
+ expect(qualityRankingControls(sp('rfrom=1000')).rankFrom).toBeNull();
+ });
+
+ it('swaps an inverted ?rfrom/?rto pair so the range is always from ≤ to', () => {
+ const f = qualityRankingControls(sp('rfrom=60&rto=10'));
+ expect(f.rankFrom).toBe(10);
+ expect(f.rankTo).toBe(60);
+ // from = to pins a single display value — kept, not dropped
+ const pin = qualityRankingControls(sp('rfrom=69&rto=69'));
+ expect(pin.rankFrom).toBe(69);
+ expect(pin.rankTo).toBe(69);
+ });
+});
+
describe('withParams', () => {
it('drops unknown params — including repeated ones — so none can ride a link into the edge cache (#197)', () => {
expect(withParams(sp('sort=value-desc&x=poison'), {})).toBe('?sort=value-desc');
diff --git a/apps/web/app/lib/filters.ts b/apps/web/app/lib/filters.ts
index 6e5d621bb..9bfcdf3b0 100644
--- a/apps/web/app/lib/filters.ts
+++ b/apps/web/app/lib/filters.ts
@@ -175,6 +175,29 @@ export function buildSectorGroup(
};
}
+/** /quality „Разбивка" ranking controls read from the URL (?rdir/?rfrom/?rto). */
+export interface QualityRankingControls {
+ rankDir: 'asc' | 'desc' | null; // null = the sort key's default order
+ rankFrom: number | null; // avg-index range bounds on the 0–100 display scale (from ≤ to)
+ rankTo: number | null;
+}
+
+/**
+ * Parse + validate the „Разбивка" ranking controls: ?rdir is an allow-listed asc|desc; ?rfrom/?rto
+ * are digits-only ints ≤ 100 (no signs, decimals or SQL-ish shapes reach a query or a cache key —
+ * CWE-349); an inverted pair is swapped so the range is always from ≤ to. The db layer re-validates.
+ */
+export function qualityRankingControls(sp: URLSearchParams): QualityRankingControls {
+ const rdir = sp.get('rdir');
+ const rangeInt = (raw: string | null): number | null =>
+ raw != null && /^\d{1,3}$/.test(raw) && Number(raw) <= 100 ? Number(raw) : null;
+ let rankFrom = rangeInt(sp.get('rfrom'));
+ let rankTo = rangeInt(sp.get('rto'));
+ if (rankFrom != null && rankTo != null && rankFrom > rankTo)
+ [rankFrom, rankTo] = [rankTo, rankFrom];
+ return { rankDir: rdir === 'asc' || rdir === 'desc' ? rdir : null, rankFrom, rankTo };
+}
+
// Canonical serialization order so the same logical state always yields the same URL string —
// good for history/bookmarks/caching. Filter facets first, then search/sort, then the paging cursor
// markers. Link param order (cosmetic). Every entry must be in CANONICAL_QUERY_PARAMS (asserted in
@@ -184,19 +207,30 @@ export const PARAM_ORDER = [
'type',
'kind',
'sector',
- 'g', // trends granularity (month/year)
+ 'cpv', // /trends: 5-digit CPV group filter
+ 'cpvSort', // /trends: CPV list ordering
+ 'angle', // /trends: time | cpv | cross lens
+ 'step', // /trends: series granularity (m|q|y)
'year',
'procedure',
'funding',
'eu',
'bids', // /contracts single-bid filter
+ 'band', // /quality: histogram score-band filter
+ 'grain', // /quality: rollup grain
'value',
'authority',
'bidder',
'center', // /network focus entity
+ 'contract', // /quality: scorecard subject
+ 'sel', // /quality: selected ranking row
'top',
'count',
'sort',
+ 'csort', // /quality: contract list ordering
+ 'rdir',
+ 'rfrom',
+ 'rto',
'cursor',
'page',
'p', // sitemap-contracts page
diff --git a/apps/web/app/lib/query-params.ts b/apps/web/app/lib/query-params.ts
index e7b603a30..daedb2c95 100644
--- a/apps/web/app/lib/query-params.ts
+++ b/apps/web/app/lib/query-params.ts
@@ -2,22 +2,33 @@
// one list means an unknown param (`?x=poison`) can neither poison the key nor ride a cached link
// (#56 / #197). The cache-key.test.ts drift guard keeps it a complete superset of what the app reads.
export const CANONICAL_QUERY_PARAMS = new Set([
+ 'angle', // /trends: time | cpv | cross lens
'authority',
+ 'band', // /quality: histogram score-band filter on the contracts list — changes rows (CWE-349)
'bidder',
'bids', // single-bid filter — changes the result set + totals
'center',
+ 'contract', // /quality: scorecard subject
'count',
+ 'cpv', // /trends: 5-digit CPV group filter
+ 'cpvSort', // /trends: CPV list ordering
+ 'csort', // /quality: contract list ordering
'cursor',
'eu',
'funding',
- 'g',
+ 'grain', // /quality: rollup grain (authority|supplier|sector|region|year|funding)
'kind',
'p',
'page', // keyed unconditionally — harmless over-key when there's no cursor
'procedure',
'q',
+ 'rdir', // /quality: „Разбивка" ranking direction (asc|desc) — flips the rendered row order (CWE-349)
+ 'rfrom', // /quality: „Разбивка" avg-index range lower bound — changes the rendered rows (CWE-349)
+ 'rto', // /quality: „Разбивка" avg-index range upper bound — changes the rendered rows (CWE-349)
'sector',
+ 'sel', // /quality: selected ranking row scoping the contract list
'sort',
+ 'step', // /trends: series granularity (m|q|y; replaced the old `g` param)
'top', // top-20 vs top-50 on /flows, /competition
'type',
'value',
diff --git a/apps/web/app/lib/trendAxis.ts b/apps/web/app/lib/trendAxis.ts
new file mode 100644
index 000000000..6ecad9208
--- /dev/null
+++ b/apps/web/app/lib/trendAxis.ts
@@ -0,0 +1,15 @@
+import type { TrendGranularity, TrendPoint } from '@sigma/api-contract';
+
+/**
+ * x-axis year ticks: the first period of each year (month/quarter grain), or every point at year
+ * grain. Shared by TrendChart and ComboTrendChart so the two SVGs agree on where year labels land.
+ */
+export function yearAxisTicks(
+ points: TrendPoint[],
+ granularity: TrendGranularity,
+): Array<{ i: number; year: string }> {
+ const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01';
+ return points
+ .map((p, i) => ({ i, year: p.period.slice(0, 4) }))
+ .filter(({ i }) => yearStart == null || points[i]!.period.endsWith(yearStart));
+}
diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts
index 70909b7d1..5501f85e5 100644
--- a/apps/web/app/routes.ts
+++ b/apps/web/app/routes.ts
@@ -10,6 +10,7 @@ export default [
route('trends', 'routes/trends.tsx'),
route('map', 'routes/map.tsx'),
route('competition', 'routes/competition.tsx'),
+ route('quality', 'routes/quality.tsx'),
route('analytics', 'routes/analytics.tsx'),
route('companies', 'routes/companies.tsx'),
route('companies.csv', 'routes/companies.csv.tsx'),
diff --git a/apps/web/app/routes/analytics.tsx b/apps/web/app/routes/analytics.tsx
index bb2d9c9ee..5187fb38a 100644
--- a/apps/web/app/routes/analytics.tsx
+++ b/apps/web/app/routes/analytics.tsx
@@ -2,6 +2,7 @@ import { Link } from 'react-router';
import {
getCompetitionSummary,
getFlows,
+ getQualitySummary,
getRegionalSpending,
getSpendingTrend,
getDb,
@@ -17,6 +18,7 @@ import { SingleOfferPortion } from '../components/SingleOfferPortion';
import { Section, ShareBar } from '../components/ui';
import { publicCache } from '../lib/cache';
import { ANALYTICS_LENSES } from '../lib/analytics-lenses';
+import { isMissingDerivedTableError } from '../lib/etl';
import { seoMeta } from '../lib/meta';
export function meta({ matches }: Route.MetaArgs) {
@@ -35,11 +37,17 @@ export function headers() {
export async function loader({ context }: Route.LoaderArgs) {
const db = getDb(context.cloudflare.env);
- const [flows, regional, trend, competition] = await Promise.all([
+ const [flows, regional, trend, competition, quality] = await Promise.all([
getFlows(db, { top: 3 }),
getRegionalSpending(db, { funding: 'all' }),
getSpendingTrend(db, { funding: 'all', granularity: 'year' }, { includeSectors: false }),
getCompetitionSummary(db),
+ getQualitySummary(db).catch((err) => {
+ // the quality tables land with the next full derive — anything else is unexpected
+ if (!isMissingDerivedTableError(err))
+ console.error('[analytics] getQualitySummary failed', err);
+ return null;
+ }),
]);
return {
@@ -59,6 +67,7 @@ export async function loader({ context }: Route.LoaderArgs) {
totals: competition.totals,
topConcentration: competition.topConcentration,
},
+ quality,
};
}
@@ -71,7 +80,7 @@ function LensLink({ to, children }: { to: string; children: ReactNode }) {
}
export default function Analytics({ loaderData }: Route.ComponentProps) {
- const { flows, regions, allRegions, regionTotal, trend, competition } = loaderData;
+ const { flows, regions, allRegions, regionTotal, trend, competition, quality } = loaderData;
return (
<>
@@ -194,6 +203,31 @@ export default function Analytics({ loaderData }: Route.ComponentProps) {
)}
)}
+ {lens.href === '/quality' && (
+
+
Среден индекс на корпуса
+ {quality &&
+ quality.scoredContracts > 0 &&
+ quality.totalContracts > 0 &&
+ quality.avgOverall != null ? (
+
+
+
Среден индекс
+ {Math.round(quality.avgOverall * 100)}/100
+
+
+
Оценени договори
+
+ {count(quality.scoredContracts)} (
+ {pct(quality.scoredContracts / quality.totalContracts)})
+
+
+
+ ) : (
+
Индексът се изчислява при следващото обновяване.
+ )}
+
+ )}
Виж {lens.title.toLowerCase()} →
))}
diff --git a/apps/web/app/routes/methodology.tsx b/apps/web/app/routes/methodology.tsx
index 7d3ad238b..699745f25 100644
--- a/apps/web/app/routes/methodology.tsx
+++ b/apps/web/app/routes/methodology.tsx
@@ -34,6 +34,7 @@ const TOC = [
['principles', 'Принципи'],
['glossary', 'Речник на понятията'],
['money', 'Валута, закръгляване, периоди'],
+ ['quality', 'Индексът за здраве на договора'],
['identity', 'Имена, ЕИК, УНП'],
['gaps', 'Известни празнини в полетата'],
['export', 'Сваляне и достъп до данните'],
@@ -430,8 +431,102 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {
+
-
+
Грешките поправяме ръчно при сигнал — двойни записи за институция/компания
(изпратете двата ЕИК/линка) или сума, която не отговаря на оригиналния документ
diff --git a/apps/web/app/routes/quality.tsx b/apps/web/app/routes/quality.tsx
new file mode 100644
index 000000000..2985e4cb9
--- /dev/null
+++ b/apps/web/app/routes/quality.tsx
@@ -0,0 +1,1220 @@
+import { Form, Link } from 'react-router';
+import type {
+ QualityContractRow,
+ QualityCoverageTier,
+ QualityGrain,
+ QualityPillars,
+ QualityRankDir,
+ QualityRankRow,
+ QualityRankSort,
+ QualityScorecard,
+} from '@sigma/api-contract';
+import { count, date, money, pct, plural } from '@sigma/shared';
+import { getDb, getQuality, QUALITY_WEIGHTS, qualityRankDefaultDir } from '@sigma/db';
+import type { Route } from './+types/quality';
+import { Breadcrumbs } from '../components/Breadcrumbs';
+import { PageHeader } from '../components/PageHeader';
+import { DataTable, type Column } from '../components/DataTable';
+import { MetricInfo } from '../components/MetricInfo';
+import { TotalsStrip, type Total } from '../components/TotalsStrip';
+import { Callout, Chip, Section } from '../components/ui';
+import { publicCache } from '../lib/cache';
+import { isMissingDerivedTableError } from '../lib/etl';
+import { qualityRankingControls } from '../lib/filters';
+import { seoMeta } from '../lib/meta';
+
+// „Индекс на качеството" — the Contract Quality / Health Index page. Reads the ETL-built
+// contract_features / *_quality_totals tables; every displayed score is [0,1] rendered as 0–100.
+// Neutrality stance (spec §1.3): a low score is a weak-process SIGNAL, never proof of wrongdoing;
+// contracts without a score are „недостатъчно данни", never zero.
+
+export function meta({ matches }: Route.MetaArgs) {
+ return seoMeta({
+ matches,
+ path: '/quality',
+ title: 'Индекс на качеството — СИГМА',
+ description:
+ 'Съставен индекс 0–100 за здравето на процеса по всеки договор: конкуренция, откритост, стойност, връзки и прозрачност. Сигнал за преглед, не присъда.',
+ });
+}
+
+export function headers() {
+ return { 'Cache-Control': publicCache(1800) };
+}
+
+const GRAIN_OPTIONS: { key: QualityGrain; label: string }[] = [
+ { key: 'authority', label: 'Институция' },
+ { key: 'supplier', label: 'Доставчик' },
+ { key: 'sector', label: 'CPV сектор' },
+ { key: 'region', label: 'Регион' },
+ { key: 'year', label: 'Година' },
+ { key: 'funding', label: 'Финансиране' },
+];
+
+const GRAIN_TITLES: Record = {
+ authority: 'Институции',
+ supplier: 'Доставчици',
+ sector: 'CPV сектори',
+ region: 'Региони',
+ year: 'Години',
+ funding: 'Източник на финансиране',
+};
+
+const PILLAR_META: {
+ key: keyof QualityPillars;
+ letter: string;
+ name: string;
+ desc: string;
+ leaves: string[];
+}[] = [
+ {
+ key: 'a',
+ letter: 'A',
+ name: 'Контестабилност',
+ desc: 'брой оферти, участие на МСП',
+ leaves: ['брой оферти (спрямо група)', 'единствена оферта', 'дял на МСП', 'електронен търг'],
+ },
+ {
+ key: 'b',
+ letter: 'B',
+ name: 'Откритост на процедурата',
+ desc: 'вид процедура, ускоряване',
+ leaves: ['вид процедура', 'пряко/договаряне', 'ускорена процедура', 'срок за оферти'],
+ },
+ {
+ key: 'c',
+ letter: 'C',
+ name: 'Интегритет на стойността',
+ desc: 'превишения, точност, анекси',
+ leaves: ['брой анекси', 'превишение спрямо подписаното', 'отклонение от прогнозата'],
+ },
+ {
+ key: 'd',
+ letter: 'D',
+ name: 'Здраве на връзките',
+ desc: 'концентрация, повторни печалби',
+ leaves: ['HHI на купувача', 'повторни печалби', 'възраст на връзката', 'дял в сектора'],
+ },
+ {
+ key: 'e',
+ letter: 'E',
+ name: 'Прозрачност / данни',
+ desc: 'разкрития и чисти дати',
+ leaves: ['ред на дати', 'разкрито подизпълнение', 'срок / заключване', 'корекции по обявата'],
+ },
+];
+
+// Header-hint reading order per sort key × direction („Подреждане: …“).
+const DIR_HINTS: Record> = {
+ score: { asc: 'най-слабите отгоре', desc: 'най-добрите отгоре' },
+ contracts: { desc: 'най-много договори отгоре', asc: 'най-малко договори отгоре' },
+};
+
+const COVERAGE_LABELS: Record = {
+ high: 'Високо',
+ medium: 'Средно',
+ low: 'Ниско',
+ none: 'Няма оценка',
+};
+
+// §3.4 value_flag gate — static reference rows (the ETL applies these before any pillar is scored).
+const GATE_ROWS: { flag: string; tone: 'good' | 'mid' | 'weak'; rule: string }[] = [
+ { flag: 'ok', tone: 'good', rule: 'чист договор — оценяват се всички измерения.' },
+ {
+ flag: 'review',
+ tone: 'mid',
+ rule: 'сива зона на надценяване — стълб C × 0,90; увереност −1 ниво.',
+ },
+ {
+ flag: 'value_low',
+ tone: 'mid',
+ rule: 'нулева/нищожна стойност — точността на прогнозата (C3) става NULL.',
+ },
+ {
+ flag: 'annex_suspect',
+ tone: 'weak',
+ rule: 'анекс е раздул стойността — превишението (C2) става NULL; C от анексите.',
+ },
+ {
+ flag: 'value_suspect',
+ tone: 'weak',
+ rule: 'извън прага за достоверност — цял C = NULL и договорът е НЕОЦЕНЕН, извън средните.',
+ },
+];
+
+const COV_TIERS: { tier: QualityCoverageTier; range: string; label: string }[] = [
+ { tier: 'high', range: '≥ 0,80', label: 'Високо · публикува се' },
+ { tier: 'medium', range: '0,60 – 0,79', label: 'Средно · публикува се' },
+ { tier: 'low', range: '0,40 – 0,59', label: 'Ниско · с уговорка' },
+ { tier: 'none', range: '< 0,40', label: 'Без оценка · „недостатъчно данни"' },
+];
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const db = getDb(context.cloudflare.env);
+ const sp = new URL(request.url).searchParams;
+ // „Разбивка" ranking controls come from the shared parser (validated before they can shape a
+ // cache key or a query — CWE-349); the db layer re-validates at its own boundary.
+ const rank = qualityRankingControls(sp);
+ const grainParam = sp.get('grain');
+ const grain = GRAIN_OPTIONS.some((g) => g.key === grainParam)
+ ? (grainParam as QualityGrain)
+ : undefined;
+ let data = null;
+ try {
+ data = await getQuality(db, {
+ grain,
+ sort: sp.get('sort') === 'contracts' ? 'contracts' : 'score',
+ dir: rank.rankDir,
+ contractSort: sp.get('csort') === 'value' ? 'value' : 'score',
+ sel: sp.get('sel'),
+ contractId: sp.get('contract'),
+ band: sp.get('band'),
+ rankFrom: rank.rankFrom,
+ rankTo: rank.rankTo,
+ });
+ } catch (err) {
+ // The health tables are built by the daily ETL (ship-domain rebuilds contract_features
+ // DROP+CREATE); before the first derive — or mid-rebuild — they may not exist yet.
+ if (!isMissingDerivedTableError(err)) {
+ console.error('[quality] getQuality failed', err);
+ throw err;
+ }
+ console.warn('[quality] quality tables not yet derived, showing empty state', err);
+ }
+ return { data };
+}
+
+/** 0–100 display of a [0,1] score; „—" when unknown (never a fabricated 0). */
+function score100(s: number | null | undefined): string {
+ return s == null ? '—' : String(Math.round(s * 100));
+}
+
+function band(s: number | null | undefined): 'good' | 'mid' | 'weak' | 'unknown' {
+ if (s == null) return 'unknown';
+ if (s >= 0.7) return 'good';
+ if (s >= 0.5) return 'mid';
+ return 'weak';
+}
+
+// Display label of a validated ?band value (bin index '0'–'19' or a named zone) on the 0–100 scale.
+const ZONE_BAND_LABELS: Record = {
+ weak: 'слабо (0–49)',
+ mid: 'средно (50–69)',
+ good: 'добро (70–100)',
+};
+function bandLabel(b: string): string {
+ if (/^\d+$/.test(b)) {
+ const i = Number(b);
+ return `${i * 5}–${(i + 1) * 5}`;
+ }
+ return ZONE_BAND_LABELS[b] ?? b;
+}
+
+function IndexBar({ score }: { score: number | null }) {
+ if (score == null) return — ;
+ const width = `${Math.min(100, Math.max(0, score * 100)).toFixed(1)}%`;
+ return (
+
+ {score100(score)}
+
+
+
+
+ );
+}
+
+// A–E mini bars. A NULL pillar renders as an empty track with an accessible „няма данни" title —
+// unknown stays visually distinct from a true low score.
+function PillarPills({ pillars }: { pillars: QualityPillars }) {
+ return (
+
+ {PILLAR_META.map((p) => {
+ const v = pillars[p.key];
+ const h = v == null ? 2 : Math.max(2, v * 26);
+ return (
+
+
+ {p.letter}
+
+ );
+ })}
+
+ );
+}
+
+function pillarSummary(pillars: QualityPillars): string {
+ return PILLAR_META.map((p) => `${p.letter} ${score100(pillars[p.key])}`).join(', ');
+}
+
+function CovChip({ tier }: { tier: QualityCoverageTier }) {
+ return {COVERAGE_LABELS[tier]} ;
+}
+
+export default function Quality({ loaderData }: Route.ComponentProps) {
+ const { data } = loaderData;
+ if (!data) {
+ return (
+
+
+
+
+ );
+ }
+ const { overview, ranking, contracts, scorecard, scope } = data;
+
+ // Preserve the page state in every internal link (grain/sort/selection/scorecard subject).
+ const defaultDir = qualityRankDefaultDir(scope.sort);
+ // ?rdir is written only when it differs from the sort key's default, so canonical URLs stay clean.
+ const rdirParam = scope.sortDir === defaultDir ? null : scope.sortDir;
+ const rangeActive = scope.rankFrom != null || scope.rankTo != null;
+ const qs = (patch: Record) => {
+ const params = new URLSearchParams();
+ const state: Record = {
+ grain: scope.grain === 'authority' ? null : scope.grain,
+ sort: scope.sort === 'score' ? null : scope.sort,
+ rdir: rdirParam,
+ rfrom: scope.rankFrom,
+ rto: scope.rankTo,
+ csort: scope.contractSort === 'score' ? null : scope.contractSort,
+ sel: scope.sel,
+ band: scope.band,
+ contract: scope.contractId,
+ ...patch,
+ };
+ for (const [k, v] of Object.entries(state)) if (v != null && v !== '') params.set(k, String(v));
+ const s = params.toString();
+ return s ? `/quality?${s}` : '/quality';
+ };
+
+ const selRow = scope.sel ? (ranking.find((r) => r.key === scope.sel) ?? null) : null;
+
+ const totals: Total[] = [
+ { num: `${score100(overview.avgOverall)}/100`, label: 'среден индекс (оценени договори)' },
+ {
+ num:
+ overview.totalContracts > 0 ? pct(overview.scoredContracts / overview.totalContracts) : '—',
+ label: `оценени договори (${count(overview.scoredContracts)})`,
+ },
+ {
+ num: overview.meanCoverage == null ? '—' : pct(overview.meanCoverage),
+ label: 'средно покритие на данните',
+ },
+ ];
+
+ return (
+ <>
+
+
+
+ Индекс на качеството
+ >
+ }
+ lede="Колко здрав е един договор: съставен индекс 0–100 (по-високо = по-здраво) от пет измерения на процеса — конкуренция, откритост, стойност, връзки и прозрачност. Ориентир за преглед, не присъда."
+ />
+
+
+
+ Ниският резултат е сигнал за слабо качество на процеса — не доказателство за
+ нарушение. Индексът не открива тръжни картели, необичайно ниски оферти или конфликт на
+ интереси; тези данни липсват във фийда. Договор без достатъчно данни е{' '}
+ „недостатъчно данни“ , никога нула, и не влиза в нито една средна. Всеки резултат
+ е проследим до конкретните договори.
+
+
+
+
+
+
+ Пет измерения
+ >
+ }
+ hint="Средни стойности за целия корпус по всяко измерение. Индексът = 0,6 × претеглена средна + 0,4 × най-слабото измерение — слабо звено не се компенсира изцяло от силните."
+ >
+
+ {PILLAR_META.map((p) => {
+ const v = overview.pillars[p.key];
+ return (
+
+
+ {p.letter}
+ {Math.round(QUALITY_WEIGHTS[p.key] * 100)}%
+
+ {p.name}
+
+ {score100(v)} корпус ср.
+
+
+
+
+ {p.desc}
+
+ );
+ })}
+
+
+
+
+ Как се смята индексът
+ >
+ }
+ hint="Пет измерения · тегла 30/15/25/20/10 · скала 0–100."
+ >
+
+
+
Съставяне
+
+
+ Във всяко измерение — претеглена средна на наличните показатели.
+
+
+ Между измеренията — 0,6 × средна + 0,4 × най-слабото , за да не се
+ „изкупува“ слабо звено със силни.
+
+
+ Измерение без никакви данни отпада , а теглата се пренормират до сбор 1.
+
+
+ Сравнението е спрямо група сходни договори : CPV дивизия × стойностен клас ×
+ вид процедура × година.
+
+
+
+
+
Какво не твърди
+
+
+ Ниска оценка е сигнал за слаб процес , не доказана злоупотреба.
+
+
+ Не открива картели, необичайно ниски оферти, скрита собственост или конфликт на
+ интереси — тези данни липсват във фийда.
+
+
+ Всяка оценка е проследима до конкретните договори; няма скрито тегло.
+
+
+
+
+
+ Какво влиза във всяко измерение
+
+ {PILLAR_META.map((p) => (
+
+
+ {p.letter} {p.name}
+
+
+ {p.leaves.map((leaf) => (
+ {leaf}
+ ))}
+
+
+ ))}
+
+
+
+
+
Праг за стойността · value_flag
+
+ {GATE_ROWS.map((g) => (
+
+
{g.flag}
+ {g.rule}
+
+ ))}
+
+
+
+
Ниво на увереност · покритие
+
+ {COV_TIERS.map((t) => (
+
+
+ {t.range}
+ {t.label}
+
+ ))}
+
+
+ Покритието се докладва до всяка оценка, но никога не влиза в аритметиката ѝ.
+
+
+
+
+
+
+ Разпределение на оценките
+
+ >
+ }
+ hint="Само оценени договори; договорите без оценка не са нули и стоят извън хистограмата. Клик върху стълб или зона показва договорите в диапазона."
+ >
+
+
+
`${qs({ band: b })}#distribution`}
+ />
+ {scope.band && (
+
+
+ Филтър: индекс {bandLabel(scope.band)}
+
+ изчисти ✕
+
+ )}
+
+
+
Ниво на увереност
+
Колко пълни са данните зад всяка оценка.
+
+
+ „Няма оценка“ обхваща договорите с покритие под 0,40 и{' '}
+ {count(overview.suspectContracts)}{' '}
+ {plural(overview.suspectContracts, 'договор', 'договора')} value_suspect — те се
+ изключват от всички средни, не се записват като нула.
+
+
+
+
+
+
+ Разбивка: {GRAIN_TITLES[scope.grain]}
+ >
+ }
+ hint={
+ scope.grain === 'authority' || scope.grain === 'supplier'
+ ? `Само редове с поне ${scope.minScored} оценени договора, за да няма шум при малки бройки. Подреждане: ${DIR_HINTS[scope.sort][scope.sortDir]}.`
+ : `Подреждане: ${DIR_HINTS[scope.sort][scope.sortDir]}.`
+ }
+ >
+
+ {GRAIN_OPTIONS.map((g) => (
+
+ {g.label}
+
+ ))}
+
+ Подреди:{' '}
+
+ индекс
+ {' '}
+
+ договори
+
+ {' · '}
+
+ ↑
+ {' '}
+
+ ↓
+
+
+
+
+ {/* Avg-index range over the rollup rows (0–100 display scale). Plain GET form (no-JS
+ friendly); a new range recomputes the ranking from the top. */}
+
+
+
+ {`Разбивка: ${count(ranking.length)} ${plural(ranking.length, 'ред', 'реда')}${
+ rangeActive ? ` · филтър по индекс ${scope.rankFrom ?? 0}–${scope.rankTo ?? 100}` : ''
+ }.`}
+
+ {ranking.length ? (
+ r.key}
+ caption={`${GRAIN_TITLES[scope.grain]} по индекс на качеството`}
+ />
+ ) : (
+
+ {rangeActive ? (
+ <>
+ Няма редове със среден индекс {scope.rankFrom ?? 0}–{scope.rankTo ?? 100} в тази
+ разбивка.{' '}
+
+ Изчисти диапазона ✕
+
+ >
+ ) : (
+ 'Няма достатъчно данни за тази разбивка — индексът се преизчислява при всяко обновяване на данните.'
+ )}
+
+ )}
+
+
+
+ Договори · оценки
+ >
+ }
+ hint={
+ selRow ? (
+ <>
+ Показани са договорите на {selRow.name} ·{' '}
+ изчисти избора ✕
+ >
+ ) : (
+ 'Най-слабите оценки в корпуса. Избери ред от разбивката, за да видиш договорите зад него.'
+ )
+ }
+ >
+
+ Подреди:{' '}
+
+ индекс
+ {' '}
+
+ стойност
+
+ {scope.band && (
+ <>
+ {' · '}
+ индекс {bandLabel(scope.band)} {' '}
+ ✕
+ >
+ )}
+
+
+ {`Показани ${count(contracts.length)} ${plural(contracts.length, 'договор', 'договора')}${
+ scope.band ? ` · филтър по индекс ${bandLabel(scope.band)}` : ''
+ }.`}
+
+ {contracts.length ? (
+
+ {contracts.map((c) => (
+
+ ))}
+
+ ) : (
+
+ Няма оценени договори за избрания разрез.{' '}
+ {scope.band && Изчисти филтъра по индекс}
+
+ )}
+
+ Договорите с недостатъчни данни за стойността (value_suspect ·{' '}
+ {count(overview.suspectContracts)} в корпуса) не получават оценка и се изключват от
+ всички средни — не се записват като нула. Оценката е ориентир за преглед, не заключение.
+
+
+
+ {scorecard && (
+
+ Декомпозиция на индекса
+ >
+ }
+ hint="Карта на оценката за избрания договор — всяко измерение, теглото му и суровите показатели зад него."
+ >
+
+
+ )}
+
+
+ Показателите са неутрални и описателни, не са оценка на конкретна процедура. Виж{' '}
+ методологията за дефинициите.
+
+
+ >
+ );
+}
+
+function rankColumns(
+ grain: QualityGrain,
+ qs: (patch: Record) => string,
+): Column[] {
+ return [
+ { key: 'rank', header: '#', isRank: true, cell: (_r, i) => i + 1 },
+ {
+ key: 'name',
+ header: GRAIN_TITLES[grain],
+ isTitle: true,
+ cell: (r) => (r.href ? {r.name} : r.name),
+ },
+ {
+ key: 'sub',
+ header: 'Вид',
+ secondary: true,
+ cell: (r) => (r.sub ? {r.sub} : null),
+ },
+ {
+ key: 'index',
+ header: 'Индекс',
+ align: 'num',
+ cell: (r) => ,
+ },
+ {
+ key: 'pillars',
+ header: 'Измерения A–E',
+ align: 'center',
+ cell: (r) => ,
+ },
+ {
+ key: 'contracts',
+ header: 'Оценени',
+ align: 'num',
+ cell: (r) => (
+ <>
+ {count(r.scoredContracts)}
+ / {count(r.totalContracts)}
+ >
+ ),
+ },
+ {
+ key: 'coverage',
+ header: 'Увереност',
+ align: 'center',
+ secondary: true,
+ cell: (r) => ,
+ },
+ {
+ key: 'drill',
+ header: Договори ,
+ cell: (r) => (
+
+ договори ↓
+
+ ),
+ },
+ ];
+}
+
+// SVG histogram — 20 bins over the scored corpus, band-zone underlay, corpus-mean marker. CSS-only
+// colors via currentColor classes; no chart library (same spirit as TrendChart/StackedBar). Every
+// bin and zone label is a plain GET link (no-JS friendly) that filters the contracts list below to
+// that score band (?band=…); clicking the active bin/zone clears the filter again. Tooltips are
+// native SVG children — the page's existing title-attr pattern, no separate tooltip style.
+function Histogram({
+ histogram,
+ mean,
+ scored,
+ selBand,
+ hrefFor,
+}: {
+ histogram: { bin: number; count: number }[];
+ mean: number | null;
+ scored: number;
+ selBand: string | null;
+ hrefFor: (band: string | null) => string;
+}) {
+ const W = 600;
+ const H = 248;
+ const PLOT_BOT = 210;
+ const PLOT_TOP = 28;
+ const counts = new Array(20).fill(0);
+ for (const b of histogram) if (b.bin >= 0 && b.bin < 20) counts[b.bin] = b.count;
+ const max = Math.max(1, ...counts);
+ const bw = W / 20;
+ const share = (n: number) => (scored > 0 ? pct(n / scored) : '—');
+ const zones: { key: 'weak' | 'mid' | 'good'; from: number; to: number; label: string }[] = [
+ { key: 'weak', from: 0, to: 0.5, label: 'СЛАБО' },
+ { key: 'mid', from: 0.5, to: 0.7, label: 'СРЕДНО' },
+ { key: 'good', from: 0.7, to: 1, label: 'ДОБРО' },
+ ];
+ const zoneCount = (z: { from: number; to: number }) =>
+ counts.reduce((t, c, i) => (i / 20 >= z.from && i / 20 < z.to ? t + c : t), 0);
+ // Is bin i inside the current selection? (a selected zone highlights all of its bins)
+ const inSel = (i: number) =>
+ selBand != null &&
+ (selBand === String(i) ||
+ (selBand === 'weak' && i < 10) ||
+ (selBand === 'mid' && i >= 10 && i < 14) ||
+ (selBand === 'good' && i >= 14));
+ return (
+
+ {zones.map((z) => (
+
+ ))}
+ {counts.map((c, i) => {
+ const h = (c / max) * (PLOT_BOT - PLOT_TOP);
+ const mid = (i + 0.5) / 20;
+ return (
+
+ {`Индекс ${i * 5}–${(i + 1) * 5}: ${count(c)} ${plural(c, 'договор', 'договора')} · ${share(c)} от оценените — клик за филтър.`}
+ {/* full-height invisible hit area so even a near-empty bin stays clickable */}
+
+
+
+ );
+ })}
+ {/* Zone labels render after the bins (SVG paints later elements on top) so their small
+ click/title target sits above the bins' full-height hit rects instead of being
+ shadowed by them. */}
+ {zones.map((z) => (
+
+ {`Зона „${ZONE_BAND_LABELS[z.key]}“: ${count(zoneCount(z))} ${plural(zoneCount(z), 'договор', 'договора')} · ${share(zoneCount(z))} от оценените — клик за филтър.`}
+
+ {z.label}
+
+
+ ))}
+
+ {[0, 25, 50, 75, 100].map((t) => (
+
+ {t}
+
+ ))}
+ {mean != null && (
+
+ {`Среден индекс на оценените договори: ${score100(mean)} от 100.`}
+
+
+ среден {score100(mean)}
+
+
+ )}
+
+ );
+}
+
+// One-sentence hover explanations for the confidence tiers (§6.2 coverage bands).
+const COV_TITLES: Record = {
+ high: 'Покритие на данните ≥ 0,80 — оценката се публикува без уговорки.',
+ medium: 'Покритие на данните 0,60–0,79 — оценката се публикува.',
+ low: 'Покритие на данните 0,40–0,59 — оценката се публикува с уговорка.',
+ none: 'Покритие под 0,40 или недостоверна стойност — договорът остава без оценка, никога нула.',
+};
+
+function ConfidenceMix({
+ confidence,
+}: {
+ confidence: { high: number; medium: number; low: number; none: number };
+}) {
+ const total = confidence.high + confidence.medium + confidence.low + confidence.none;
+ if (total === 0) return Няма данни.
;
+ const parts: { tier: QualityCoverageTier; n: number }[] = [
+ { tier: 'high', n: confidence.high },
+ { tier: 'medium', n: confidence.medium },
+ { tier: 'low', n: confidence.low },
+ { tier: 'none', n: confidence.none },
+ ];
+ return (
+ <>
+
+ {parts
+ .filter((p) => p.n > 0)
+ .map((p) => (
+
+ ))}
+
+
+ {parts.map((p) => (
+
+
+ {COVERAGE_LABELS[p.tier]}
+ {pct(p.n / total)}
+
+ ))}
+
+ >
+ );
+}
+
+function ContractCard({
+ c,
+ selected,
+ href,
+}: {
+ c: QualityContractRow;
+ selected: boolean;
+ href: string;
+}) {
+ return (
+
+
+ {date(c.signedAt)}
+ {c.cpvDivision && CPV {c.cpvDivision} }
+
+ {c.overall == null ? '—' : score100(c.overall)}
+
+
+
+ {c.authorityName}
+
+
+ → {c.bidderDisplayName}
+
+
+
+
+ {money(c.amountEur)}
+ стойност
+
+
+
+
+ {c.valueFlag === 'value_suspect' ? (
+ value_suspect · без оценка, извън всички средни
+ ) : c.valueFlag === 'annex_suspect' ? (
+ annex_suspect · C само от броя анекси
+ ) : null}
+
+ декомпозиция ↓
+
+
+
+ );
+}
+
+function Scorecard({ card }: { card: QualityScorecard }) {
+ const leafRows = scorecardLeaves(card);
+ return (
+
+
+
+
+ {date(card.signedAt)}
+ {card.cpvDivision && CPV {card.cpvDivision} }
+
+
+ {card.authorityName}
+
+
+ → {card.bidderDisplayName}
+
+
+ {money(card.amountEur)} · виж договора
+
+
+
+ {card.known && card.worstPillar && (
+
+ Най-слабо звено
+ {PILLAR_META.find((p) => p.key === card.worstPillar)?.name}
+
+ )}
+
+ увереност · {COVERAGE_LABELS[card.coverageTier]}
+
+
+ {card.known ? score100(card.overall) : '—'}
+ {card.known ? '/100' : 'неоценен'}
+
+
+
+
+ {card.known && card.wmean != null && card.worst != null ? (
+ <>
+
+ претеглена средна {score100(card.wmean)} · най-слабо{' '}
+ {score100(card.worst)} · 0,6 × {score100(card.wmean)} + 0,4 ×{' '}
+ {score100(card.worst)} ={' '}
+ {score100(card.overall)}
+
+
+
+ {PILLAR_META.map((p) => {
+ const s = card.pillars[p.key];
+ const w = card.effectiveWeights[p.key];
+ const isWorst = card.worstPillar === p.key;
+ return (
+
+
+ {p.letter}
+
+ {w == null ? 'отпада' : `${Math.round(w * 100)}%`}
+
+
+
{p.name}
+
{score100(s)}
+
+
+
+
+ {leafRows[p.key].map((leaf) => (
+
+
{leaf.k}
+ {leaf.v}
+
+ ))}
+
+ {isWorst &&
Най-слабо звено
}
+
+ );
+ })}
+
+
+
+ Покритие
+ {[
+ { label: 'брой оферти', ok: card.coverageFlags.bids },
+ { label: 'дял МСП', ok: card.coverageFlags.sme },
+ { label: 'прогнозна стойност', ok: card.coverageFlags.estimate },
+ { label: 'текуща стойност', ok: card.coverageFlags.overrun },
+ ].map((f) => (
+
+ {f.ok ? '✓' : '✕'} {f.label}
+
+ ))}
+
+ {card.valueFlag === 'annex_suspect' && (
+
+ Праг: annex_suspect · анекс е раздул текущата стойност → превишението (C2) е
+ NULL; стълб C се оценява само от броя анекси.
+
+ )}
+ {card.valueFlag === 'review' && (
+
+ Праг: review · сива зона на надценяване — стълб C е умножен по 0,90, а
+ увереността е свалена с едно ниво.
+
+ )}
+ >
+ ) : (
+
+ {card.valueFlag === 'value_suspect' ? (
+ <>
+ value_suspect · ефективната стойност надхвърля прага за достоверност. Стълб C =
+ NULL, а цялата оценка се задържа като неоценена . Договорът се изключва от всяка
+ средна — никога не се записва като нула.
+ >
+ ) : (
+ <>
+ Недостатъчно данни · покритието на този договор е под прага 0,40 (§6.2), затова
+ оценката се задържа. Договорът се изключва от всяка средна — никога не се записва като
+ нула.
+ >
+ )}
+
+ )}
+
+ );
+}
+
+// Raw leaves → display rows per pillar. Missing values render as „—" (unknown, never zero).
+function scorecardLeaves(
+ card: QualityScorecard,
+): Record {
+ const l = card.leaves;
+ const num = (v: number | null, dp = 2) =>
+ v == null
+ ? '—'
+ : v
+ .toFixed(dp)
+ .replace(/\.?0+$/, '')
+ .replace('.', ',');
+ const yesNo = (v: boolean | null) => (v == null ? '—' : v ? 'да' : 'не');
+ return {
+ a: [
+ {
+ k: 'Брой оферти',
+ v:
+ l.bidsReceived == null ? '—' : `${l.bidsReceived}${l.singleOffer ? ' · единствена' : ''}`,
+ },
+ { k: 'Дял МСП', v: l.smeRate == null ? '—' : pct(l.smeRate) },
+ { k: 'Електронен търг', v: yesNo(l.isEauction) },
+ ],
+ b: [
+ { k: 'Вид процедура', v: l.procedureType ?? '—' },
+ { k: 'Ускорена процедура', v: yesNo(l.isAccelerated) },
+ {
+ k: 'Срок за оферти',
+ v: l.bidWindowDays == null ? '—' : `${Math.round(l.bidWindowDays)} дни`,
+ },
+ ],
+ c: [
+ { k: 'Брой анекси', v: l.annexCount == null ? '—' : String(l.annexCount) },
+ { k: 'Превишение', v: l.costOverrunRatio == null ? '—' : `${num(l.costOverrunRatio)}×` },
+ {
+ k: 'Отклонение от прогнозата',
+ v: l.estimateDevRatio == null ? '—' : pct(l.estimateDevRatio),
+ },
+ ],
+ d: [
+ { k: 'HHI на купувача', v: num(l.authorityHhi) },
+ {
+ k: 'Дял повторни печалби',
+ v: l.repeatWinIntensity == null ? '—' : pct(l.repeatWinIntensity),
+ },
+ {
+ k: 'Възраст на връзката',
+ v: l.edgeAgeYears == null ? '—' : `${num(l.edgeAgeYears, 1)} г.`,
+ },
+ ],
+ e: [
+ {
+ k: 'Дати',
+ v: l.dateFlag == null || l.dateFlag === 'ok' ? 'чисто' : 'подпис преди публикуване',
+ },
+ {
+ k: 'Подизпълнение',
+ v: l.subcontractPassthrough == null ? '—' : pct(l.subcontractPassthrough),
+ },
+ { k: 'Срок', v: l.durationDays == null ? '—' : `${count(l.durationDays)} дни` },
+ ],
+ };
+}
diff --git a/apps/web/app/routes/trends.test.ts b/apps/web/app/routes/trends.test.ts
new file mode 100644
index 000000000..19051a329
--- /dev/null
+++ b/apps/web/app/routes/trends.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { CpvGroupStat } from '@sigma/api-contract';
+
+const getCpvGroupMedians = vi.fn().mockResolvedValue([]);
+
+vi.mock('@sigma/db', () => ({
+ getDb: vi.fn().mockReturnValue({}),
+ getSpendingTrend: vi.fn().mockResolvedValue({ points: [], years: [] }),
+ getCpvGroupStats: vi.fn().mockResolvedValue({ groups: [], totalGroups: 0 }),
+ listOverviewContracts: vi.fn().mockResolvedValue([]),
+ getCpvGroupMedians,
+}));
+
+const { logMax, relLabel, loader } = await import('./trends');
+
+function makeGroup(maxEur: number): CpvGroupStat {
+ return {
+ group: '33600',
+ name: null,
+ contracts: 1,
+ medianEur: maxEur / 2,
+ p10Eur: 0,
+ p90Eur: maxEur,
+ maxEur,
+ sampleEur: [maxEur],
+ };
+}
+
+describe('logMax', () => {
+ it('does not bump an exact power of ten to the next decade', () => {
+ // Math.log10(1e7) can land a hair above 7 due to float rounding; the epsilon guard
+ // in logMax must keep 1e7 mapped to 1e7, not 1e8.
+ expect(logMax([makeGroup(1e7)])).toBe(1e7);
+ });
+
+ it('rounds a non-power-of-ten max up to the next decade', () => {
+ expect(logMax([makeGroup(2.5e7)])).toBe(1e8);
+ });
+
+ it('floors at 1e6 regardless of smaller group maxima', () => {
+ expect(logMax([makeGroup(100)])).toBe(1e6);
+ });
+});
+
+describe('relLabel', () => {
+ it('returns an empty label when the cohort median is zero', () => {
+ expect(relLabel(1000, 0)).toEqual({ text: '', cls: 'ov-rel-mid' });
+ });
+
+ it('returns an empty label when the cohort median is negative', () => {
+ expect(relLabel(1000, -5)).toEqual({ text: '', cls: 'ov-rel-mid' });
+ });
+
+ it('flags values well above the median', () => {
+ expect(relLabel(2000, 1000)).toEqual({ text: '×2 типичното', cls: 'ov-rel-hi' });
+ });
+
+ it('flags values well below the median', () => {
+ expect(relLabel(500, 1000)).toEqual({ text: 'под типичното', cls: 'ov-rel-lo' });
+ });
+
+ it('flags values near the median', () => {
+ expect(relLabel(1000, 1000)).toEqual({ text: '≈ типичното', cls: 'ov-rel-mid' });
+ });
+});
+
+describe('loader', () => {
+ function args(url: string) {
+ return {
+ request: new Request(url),
+ context: { cloudflare: { env: { DB: {} } } },
+ } as never;
+ }
+
+ it('skips the getCpvGroupMedians round-trip when nothing is missing from the top-N stats', async () => {
+ getCpvGroupMedians.mockClear();
+ const data = await loader(args('https://x/trends'));
+ expect(getCpvGroupMedians).not.toHaveBeenCalled();
+ expect(data.medians).toEqual([]);
+ });
+});
diff --git a/apps/web/app/routes/trends.tsx b/apps/web/app/routes/trends.tsx
index 42172a9c3..a31c0debf 100644
--- a/apps/web/app/routes/trends.tsx
+++ b/apps/web/app/routes/trends.tsx
@@ -1,23 +1,32 @@
-import { Form, useNavigation, useSearchParams, useSubmit } from 'react-router';
-import type { TrendYear } from '@sigma/api-contract';
-import { count, money, pct, signedPct } from '@sigma/shared';
-import { getSpendingTrend, getDb } from '@sigma/db';
+import { Link, useSearchParams } from 'react-router';
+import type { CpvGroupStat, TrendGranularity } from '@sigma/api-contract';
+import { count, date as fmtDate, money, plural } from '@sigma/shared';
+import {
+ getCpvGroupMedians,
+ getCpvGroupStats,
+ getDb,
+ getSpendingTrend,
+ listOverviewContracts,
+} from '@sigma/db';
import type { Route } from './+types/trends';
import { Breadcrumbs } from '../components/Breadcrumbs';
import { PageHeader } from '../components/PageHeader';
-import { DataTable, type Column } from '../components/DataTable';
-import { TrendChart } from '../components/TrendChart';
-import { Callout, Section } from '../components/ui';
+import { TotalsStrip, type Total } from '../components/TotalsStrip';
+import { ComboTrendChart } from '../components/ComboTrendChart';
+import { Callout } from '../components/ui';
import { publicCache } from '../lib/cache';
-import { singleSelectFilters } from '../lib/filters';
+
+// „Договори — обзор": one list of contracts looked at from three angles (lenses) — in time, per CPV
+// group, or both at once. Every control is a plain mutating the query string, so the page is
+// fully SSR/no-JS capable; the only hydrated behavior is the chart hover tooltip.
export function meta(_: Route.MetaArgs) {
return [
- { title: 'Тренд във времето — СИГМА' },
+ { title: 'Договори — обзор — СИГМА' },
{
name: 'description',
content:
- 'Как се движат разходите за обществени поръчки във времето, по месеци и години, със сезонните пикове. Изцяло върху наличните данни.',
+ 'Един и същи списък договори — сортиран по време, срязан по CPV код, или двете наведнъж. Обем и брой по месеци, тримесечия и години; типични цени по CPV групи.',
},
];
}
@@ -26,132 +35,527 @@ export function headers() {
return { 'Cache-Control': publicCache(1800) };
}
+type Angle = 'time' | 'cpv' | 'cross';
+type Step = 'm' | 'q' | 'y';
+
+function pick(raw: string | null, allowed: readonly T[], fallback: T): T {
+ return raw != null && (allowed as readonly string[]).includes(raw) ? (raw as T) : fallback;
+}
+
+const STEP_GRANULARITY: Record = {
+ m: 'month',
+ q: 'quarter',
+ y: 'year',
+};
+
export async function loader({ request, context }: Route.LoaderArgs) {
const sp = new URL(request.url).searchParams;
- const { sector, funding, unknownSector } = singleSelectFilters(sp);
- const granularity = sp.get('g') === 'year' ? 'year' : 'month';
const db = getDb(context.cloudflare.env);
- const data = await getSpendingTrend(db, { sector, funding, granularity });
- return { data, unknownSector };
+
+ const angle = pick(sp.get('angle'), ['time', 'cpv', 'cross'], 'time');
+ const step = pick(sp.get('step'), ['m', 'q', 'y'], 'q');
+ const sort = pick(sp.get('sort'), ['date', 'value'] as const, 'date');
+ const cpvSort = pick(sp.get('cpvSort'), ['n', 'med', 'code'] as const, 'n');
+ const yearRaw = sp.get('year');
+ const year = yearRaw && /^20\d\d$/.test(yearRaw) ? yearRaw : null;
+ const cpvRaw = sp.get('cpv');
+ const cpv = cpvRaw && /^\d{5}$/.test(cpvRaw) ? cpvRaw : null;
+
+ // The cross lens always shows the compact quarterly picker; the time lens follows the step toggle.
+ const granularity = angle === 'cross' ? 'quarter' : STEP_GRANULARITY[step];
+
+ const [trend, stats, contracts] = await Promise.all([
+ getSpendingTrend(db, { granularity }, { includeSectors: false }),
+ getCpvGroupStats(db, 10),
+ listOverviewContracts(db, { year, cpvGroup: cpv, sort, limit: 24 }),
+ ]);
+
+ // „Спрямо типичното" baselines for card groups outside the top-N stats (bounded: distinct groups
+ // on one card page, plus the selected group so its filter chip can carry a name).
+ const known = new Set(stats.groups.map((g) => g.group));
+ const missing = contracts
+ .map((c) => c.cpvGroup)
+ .filter((g): g is string => g != null && !known.has(g));
+ if (cpv && !known.has(cpv)) missing.push(cpv);
+ const medians = missing.length ? await getCpvGroupMedians(db, [...new Set(missing)]) : [];
+
+ return { angle, step, sort, cpvSort, year, cpv, trend, stats, contracts, medians };
+}
+
+// ── Presentational helpers ────────────────────────────────────────────────────────────────────────
+
+/** ×N with a Bulgarian decimal comma: 2.4 → '×2,4', 15 → '×15'. */
+function multText(mult: number): string {
+ if (mult >= 10) return `×${Math.round(mult)}`;
+ return `×${(Math.round(mult * 10) / 10).toString().replace('.', ',')}`;
+}
+
+export function relLabel(valueEur: number, medianEur: number): { text: string; cls: string } {
+ if (!(medianEur > 0)) return { text: '', cls: 'ov-rel-mid' };
+ const mult = valueEur / medianEur;
+ if (mult >= 1.3) return { text: `${multText(mult)} типичното`, cls: 'ov-rel-hi' };
+ if (mult <= 0.75) return { text: 'под типичното', cls: 'ov-rel-lo' };
+ return { text: '≈ типичното', cls: 'ov-rel-mid' };
}
+// Deterministic jitter for the dot cloud (presentation only — the x positions are real values).
+function jitter(seedText: string, i: number): number {
+ let h = 2166136261;
+ for (const ch of `${seedText}:${i}`) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
+ return ((h >>> 8) % 1000) / 1000 - 0.5;
+}
+
+const LOG_MIN = 1e3;
+
+export function logMax(groups: CpvGroupStat[]): number {
+ const max = Math.max(1e6, ...groups.map((g) => g.maxEur));
+ // epsilon guards exact powers of ten from float rounding nudging log10 just above the integer
+ return 10 ** Math.ceil(Math.log10(max) - 1e-9);
+}
+
+function axisLabel(v: number): string {
+ return v >= 1e6 ? `${v / 1e6}М` : `${v / 1e3}к`;
+}
+
+/** log-€ → x in the 320-wide distribution strip. */
+function makeLx(gMax: number) {
+ const lo = Math.log10(LOG_MIN);
+ const hi = Math.log10(gMax);
+ return (v: number) =>
+ 6 + ((Math.log10(Math.min(gMax, Math.max(LOG_MIN, v))) - lo) / (hi - lo)) * 308;
+}
+
+// Per-group distribution strip: p10–p90 box, real-value dot cloud (log x), median line. Dots at
+// ≥5× the group median are highlighted — the same "worth a look" cue as the card labels.
+function DistStrip({ g, gMax }: { g: CpvGroupStat; gMax: number }) {
+ const lx = makeLx(gMax);
+ return (
+
+
+
+ {g.sampleEur.map((v, i) => {
+ const hi = v >= g.medianEur * 5;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+function DistAxis({ gMax }: { gMax: number }) {
+ const lx = makeLx(gMax);
+ const ticks: number[] = [];
+ for (let v = LOG_MIN; v <= gMax; v *= 10) ticks.push(v);
+ return (
+
+ {ticks.map((v) => (
+
+
+
+ {axisLabel(v)}
+
+
+ ))}
+
+ );
+}
+
+// ── Page ──────────────────────────────────────────────────────────────────────────────────────────
+
export default function Trends({ loaderData }: Route.ComponentProps) {
- const { data, unknownSector } = loaderData;
+ const { angle, step, sort, cpvSort, year, cpv, trend, stats, contracts, medians } = loaderData;
const [sp] = useSearchParams();
- const submit = useSubmit();
- const navigating = useNavigation().state !== 'idle';
- const sel = (k: string) => sp.get(k) ?? '';
- const yearColumns: Column[] = [
- {
- key: 'year',
- header: 'Година',
- isTitle: true,
- cell: (r) => (
- <>
- {r.year}
- {r.partial && (частично) }
- >
- ),
- },
- { key: 'value', header: 'Стойност', align: 'money', cell: (r) => money(r.valueEur) },
- { key: 'contracts', header: 'Договори', align: 'num', cell: (r) => count(r.contracts) },
- {
- key: 'yoy',
- header: 'Спрямо предходната',
- align: 'num',
- cell: (r) => (r.yoyPct == null ? '' : signedPct(r.yoyPct)),
- },
+ // Every control is a Link that patches the query string (null deletes a key).
+ const hrefWith = (patch: Record): string => {
+ const next = new URLSearchParams(sp);
+ for (const [k, v] of Object.entries(patch)) {
+ if (v == null) next.delete(k);
+ else next.set(k, v);
+ }
+ const qs = next.toString();
+ return qs ? `/trends?${qs}` : '/trends';
+ };
+
+ // Cohort baseline per CPV group: top-N stats first, on-demand medians for the rest.
+ const cohorts = new Map();
+ for (const m of medians) cohorts.set(m.group, { name: m.name, medianEur: m.medianEur });
+ for (const g of stats.groups) cohorts.set(g.group, { name: g.name, medianEur: g.medianEur });
+
+ const datedContracts = trend.points.reduce((sum, p) => sum + p.contracts, 0);
+ const totals: Total[] = [
+ { num: money(trend.totalValueEur), label: 'обща стойност' },
+ { num: count(datedContracts), label: 'договора' },
+ { num: count(stats.totalGroups), label: 'CPV групи' },
];
+ const gMax = logMax(stats.groups);
+ const cpvRows = [...stats.groups].sort((a, b) =>
+ cpvSort === 'med'
+ ? b.medianEur - a.medianEur
+ : cpvSort === 'code'
+ ? a.group.localeCompare(b.group)
+ : b.contracts - a.contracts,
+ );
+
+ const chips: { label: string; clear: Record }[] = [];
+ if (cpv) chips.push({ label: `CPV ${cpv}`, clear: { cpv: null } });
+ if (year) chips.push({ label: year, clear: { year: null } });
+ const lensHint =
+ angle === 'time'
+ ? 'кликни година, за да филтрираш'
+ : angle === 'cpv'
+ ? 'кликни CPV ред, за да филтрираш'
+ : 'избери година и CPV код';
+
+ const scopeParts: string[] = [];
+ if (cpv) scopeParts.push(`CPV ${cpv}`);
+ if (year) scopeParts.push(year);
+ const scopeText = scopeParts.length ? scopeParts.join(' · ') : 'всички договори';
+
+ const angles: { key: Angle; label: string }[] = [
+ { key: 'time', label: 'Във времето' },
+ { key: 'cpv', label: 'По CPV код' },
+ { key: 'cross', label: 'Време × CPV' },
+ ];
+ const steps: { key: Step; label: string }[] = [
+ { key: 'm', label: 'Мес.' },
+ { key: 'q', label: 'Трим.' },
+ { key: 'y', label: 'Год.' },
+ ];
+ const cpvSorts = [
+ { key: 'n', label: 'Договори' },
+ { key: 'med', label: 'Типична' },
+ { key: 'code', label: 'CPV' },
+ ] as const;
+ const sorts = [
+ { key: 'date', label: 'Най-нови' },
+ { key: 'value', label: 'Стойност' },
+ ] as const;
+
+ const yearCards = trend.years.map((y) => ({
+ ...y,
+ active: y.year === year,
+ href: hrefWith({ year: y.year === year ? null : y.year }),
+ }));
+
+ const cpvPanel = (compact: boolean) => (
+
+
+
+
+ {compact ? (
+ <>
+ Стеснѝ по CPV код
+ >
+ ) : (
+ <>
+ Цени по CPV код
+ >
+ )}
+
+ {!compact && (
+
+ Всеки код събира сходни поръчки. Разсейването е нормално — обемите варират. Кликни
+ ред, за да видиш договорите. Показани са {stats.groups.length}-те групи с най-много
+ договори.
+
+ )}
+
+ {!compact && (
+
+ {cpvSorts.map((s) => (
+
+ {s.label}
+
+ ))}
+
+ )}
+
+ {!compact && (
+
+ CPV
+ Категория
+ Типична
+ Догов.
+ Разпределение · лог €
+
+ )}
+ {cpvRows.map((g) => {
+ const active = g.group === cpv;
+ return (
+
+ {compact && (
+
+ {active ? '✓' : ''}
+
+ )}
+
{g.group}
+
+ {g.name ?? `CPV група ${g.group}`}
+ {!compact && (
+
+ диапазон p10–p90 · {money(g.p10Eur)} – {money(g.p90Eur)}
+
+ )}
+
+
{money(g.medianEur)}
+ {!compact && (
+ <>
+
{count(g.contracts)}
+
+ >
+ )}
+
+ );
+ })}
+ {!compact && (
+
+
+
+ )}
+
+ );
+
return (
<>
-
+
+ Договори, погледнати под различен ъгъл
+ >
+ }
+ lede="Един и същи списък договори — сортиран по време, срязан по CPV код, или двете наведнъж. Изберѝ ъгъл; списъкът долу се сглобява от избора. Договорите без валидна дата или стойност не влизат в изгледа."
/>
-
diff --git a/apps/web/app/styles/components.css b/apps/web/app/styles/components.css
index 6acaffcff..cb495c35f 100644
--- a/apps/web/app/styles/components.css
+++ b/apps/web/app/styles/components.css
@@ -607,6 +607,140 @@ tbody td,
transform 1.2s ease-out,
opacity 0.1s ease;
}
+
+/* ===== metric-info popover ===== */
+.metric-info {
+ position: relative;
+ display: inline-flex;
+ vertical-align: middle;
+}
+
+/* ≥24px hit area via padding, pulled back with negative margin so the inline layout doesn't shift. */
+.metric-info-btn {
+ position: relative; /* anchors the ::after touch-target extension (pointer: coarse) */
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ margin: -6px -5px -6px 1px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--ink-soft);
+ cursor: help;
+ flex: none;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.metric-info-glyph {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ line-height: 1;
+}
+
+.metric-info-btn:hover .metric-info-glyph,
+.metric-info:focus-within .metric-info-btn .metric-info-glyph,
+.metric-info.is-open .metric-info-btn .metric-info-glyph {
+ color: var(--accent);
+}
+
+.metric-info-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -3px;
+ border-radius: 50%;
+}
+
+.metric-info-pop {
+ position: absolute;
+ z-index: 40;
+ top: calc(100% + 8px);
+ left: 0;
+ width: 320px;
+ /* never wider than the viewport — the JS shift in MetricInfo.tsx handles the horizontal clamp */
+ max-width: min(320px, calc(100vw - 16px));
+ /* the popover often sits inside a `th` (white-space: nowrap); reset inherited wrapping so the
+ title/summary/readout always wrap inside the card instead of overflowing it */
+ white-space: normal;
+ overflow-wrap: anywhere;
+ padding: 12px 14px;
+ background: var(--ink);
+ color: var(--paper);
+ border-radius: 5px;
+ box-shadow: 0 14px 34px color-mix(in oklch, var(--ink) 38%, transparent);
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ text-align: left;
+ text-transform: none;
+ letter-spacing: normal;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-3px);
+ transition:
+ opacity 0.14s ease,
+ transform 0.14s ease,
+ visibility 0.14s;
+ pointer-events: none;
+}
+
+.metric-info-pop.is-end {
+ left: auto;
+ right: 0;
+}
+
+.metric-info:hover .metric-info-pop,
+.metric-info:focus-within .metric-info-pop,
+.metric-info.is-open .metric-info-pop {
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ transform: translateY(0);
+}
+
+.metric-info-title {
+ font:
+ 600 9.5px/1 'IBM Plex Mono',
+ var(--font-mono);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: color-mix(in oklch, var(--paper) 70%, var(--ink));
+}
+
+.metric-info-summary {
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--paper);
+}
+
+.metric-info-readout {
+ margin-top: 1px;
+ padding-top: 7px;
+ border-top: 1px solid color-mix(in oklch, var(--paper) 22%, var(--ink));
+ font:
+ 500 11px/1.45 'IBM Plex Mono',
+ var(--font-mono);
+ color: color-mix(in oklch, var(--accent) 70%, var(--paper));
+}
+
+@media (max-width: 600px) {
+ .metric-info-pop {
+ width: 264px;
+ }
+}
+
+@media (pointer: coarse) {
+ /* invisible hit-area extension: 24px ⓘ glyph → ≥44px touch target */
+ .metric-info-btn::after {
+ content: '';
+ position: absolute;
+ inset: -10px;
+ }
+}
+
+/* ===== end metric-info popover ===== */
/* EU-benchmark indicator block (authority page „Конкуренция"): identical shape for both
indicators - hero share, meter with the two EU thresholds as hairline ticks, verdict and
counts in text. The meter is decorative; the fill wears the accent only over the „high"
diff --git a/apps/web/app/styles/pages.css b/apps/web/app/styles/pages.css
index fd6341d32..e18d111fb 100644
--- a/apps/web/app/styles/pages.css
+++ b/apps/web/app/styles/pages.css
@@ -651,3 +651,1408 @@
height: 12px;
border-radius: 2px;
}
+
+/* ── Contracts overview (/trends): lenses, distribution rows, contract cards ──
+ Translated from the „Договори — обзор" design mock into the site's token palette: ink line for
+ € volume, muted info bars for counts, accent red only for the selection/„над типичното" cues. */
+
+.ov-controls {
+ display: flex;
+ align-items: center;
+ gap: var(--s-4);
+ flex-wrap: wrap;
+ margin: var(--s-5) 0 var(--s-4);
+ padding: var(--s-3) 0;
+ border-top: 1px solid var(--rule);
+ border-bottom: 1px solid var(--rule);
+}
+.ov-controls-label {
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--text-faint);
+}
+.ov-chips {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ flex-wrap: wrap;
+}
+.ov-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font: 500 11px/1 var(--font-mono);
+ padding: 6px 9px;
+ border: 1px solid var(--accent);
+ border-radius: 3px;
+ background: var(--accent-bg);
+ color: var(--accent);
+ text-decoration: none;
+}
+.ov-chip:visited {
+ color: var(--accent);
+}
+.ov-chip span {
+ opacity: 0.6;
+}
+.ov-hint {
+ font: 400 12px/1 var(--font-mono);
+ color: var(--text-muted);
+}
+
+/* Segmented link controls (angle switcher, step, sorts) */
+.ov-seg {
+ display: inline-flex;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ overflow: hidden;
+}
+.ov-seg a {
+ font: 600 11px/1 var(--font-mono);
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding: 8px 13px;
+ color: var(--text-muted);
+ background: var(--surface);
+ text-decoration: none;
+ border-right: 1px solid var(--rule-soft);
+ white-space: nowrap;
+}
+.ov-seg a:last-child {
+ border-right: none;
+}
+.ov-seg a:hover {
+ color: var(--text);
+}
+.ov-seg a[aria-current] {
+ background: var(--ink);
+ color: var(--paper);
+}
+.ov-seg a:visited {
+ color: var(--text-muted);
+}
+.ov-seg a[aria-current]:visited {
+ color: var(--paper);
+}
+
+/* Panels */
+.ov-panel {
+ background: var(--surface);
+ border: 1px solid var(--rule);
+ border-radius: 5px;
+ padding: var(--s-4) var(--s-5);
+ margin-bottom: var(--s-5);
+}
+.ov-panel-head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--s-4);
+ flex-wrap: wrap;
+ margin-bottom: var(--s-3);
+}
+.ov-panel-title {
+ font: 600 20px/1.15 var(--font-serif);
+ margin: 0;
+}
+.ov-panel-title em {
+ color: var(--accent);
+}
+.ov-panel-hint {
+ margin: 6px 0 0;
+ font: 400 12px/1.45 var(--font-mono);
+ color: var(--text-faint);
+ max-width: 62ch;
+}
+.ov-panel-tools {
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ flex-wrap: wrap;
+}
+.ov-legend {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font: 400 11px/1 var(--font-mono);
+ color: var(--text-faint);
+}
+.ov-legend-bar {
+ width: 9px;
+ height: 9px;
+ background: oklch(55% 0.03 240 / 0.55);
+ border-radius: 1px;
+}
+.ov-legend-line {
+ width: 14px;
+ height: 2.4px;
+ background: var(--ink);
+ border-radius: 2px;
+ margin-left: 8px;
+}
+
+/* Combo chart (bars = contracts, line = € volume) */
+.combo-chart {
+ position: relative;
+ margin-top: var(--s-2);
+}
+.combo-grid {
+ stroke: var(--rule-soft);
+ stroke-width: 1;
+}
+.combo-bar {
+ fill: oklch(55% 0.03 240 / 0.5);
+}
+.combo-bar.is-hover {
+ fill: oklch(55% 0.03 240 / 0.9);
+}
+.combo-bar.is-partial {
+ fill: oklch(55% 0.03 240 / 0.25);
+}
+.combo-line {
+ fill: none;
+ stroke: var(--ink);
+ stroke-width: 2.2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+.combo-line-partial {
+ fill: none;
+ stroke: var(--ink);
+ stroke-width: 2;
+ stroke-dasharray: 4 4;
+ opacity: 0.7;
+}
+.combo-cursor {
+ stroke: var(--accent);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+}
+.combo-dot {
+ fill: var(--accent);
+ stroke: var(--paper);
+ stroke-width: 1.6;
+}
+.combo-xlab {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 5px;
+ padding: 0 2px;
+ font: 400 10px/1 var(--font-mono);
+ color: var(--text-faint);
+}
+.combo-tip {
+ position: absolute;
+ pointer-events: none;
+ transform: translate(-50%, -108%);
+ background: var(--ink);
+ color: var(--paper);
+ padding: 7px 10px;
+ border-radius: 3px;
+ white-space: nowrap;
+ z-index: 5;
+}
+.combo-tip-label {
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: 0.06em;
+ opacity: 0.75;
+}
+.combo-tip-row {
+ display: flex;
+ gap: var(--s-3);
+ justify-content: space-between;
+ margin-top: 5px;
+ font: 400 10px/1 var(--font-mono);
+}
+.combo-tip-row strong {
+ font: 600 11.5px/1 var(--font-mono);
+}
+
+/* Year cards under the chart */
+.ov-years {
+ display: flex;
+ gap: 7px;
+ margin-top: var(--s-4);
+ flex-wrap: wrap;
+}
+.ov-year {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 12px;
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ background: var(--surface);
+ min-width: 78px;
+ text-decoration: none;
+ color: var(--text);
+}
+.ov-year:visited {
+ color: var(--text);
+}
+.ov-year:hover {
+ border-color: var(--ink);
+}
+.ov-year.is-active {
+ border-color: var(--accent);
+ background: var(--accent-bg);
+ color: var(--accent);
+}
+.ov-year.is-active:visited {
+ color: var(--accent);
+}
+.ov-year.is-slim {
+ min-width: 0;
+}
+.ov-year-label {
+ font: 600 13px/1 var(--font-mono);
+}
+.ov-year-partial {
+ font: 400 9px/1 var(--font-mono);
+ color: var(--text-faint);
+}
+.ov-year-val {
+ font: 400 10px/1 var(--font-mono);
+ color: var(--text-faint);
+}
+.ov-year.is-active .ov-year-val,
+.ov-year.is-active .ov-year-partial {
+ color: var(--accent);
+}
+
+/* CPV lens: header + clickable distribution rows */
+.ov-cpv {
+ padding-left: 0;
+ padding-right: 0;
+}
+.ov-cpv .ov-panel-head,
+.ov-cpv-head,
+.ov-cpv-row,
+.ov-cpv-foot {
+ padding-left: var(--s-5);
+ padding-right: var(--s-5);
+}
+.ov-cpv-head,
+.ov-cpv-row {
+ display: grid;
+ grid-template-columns: 52px minmax(0, 1fr) 92px 56px minmax(180px, 320px);
+ gap: var(--s-3);
+ align-items: center;
+}
+.ov-cpv[data-compact] .ov-cpv-row {
+ grid-template-columns: 18px 52px minmax(0, 1fr) 92px;
+}
+.ov-cpv-head {
+ padding-top: 9px;
+ padding-bottom: 7px;
+ border-bottom: 1px solid var(--ink);
+ font: 500 9px/1.2 var(--font-mono);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-faint);
+}
+.ov-cpv-head .num {
+ text-align: right;
+}
+.ov-cpv-row {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid var(--rule-soft);
+ border-left: 2px solid transparent;
+ text-decoration: none;
+ color: var(--text);
+}
+.ov-cpv-row:visited {
+ color: var(--text);
+}
+.ov-cpv-row:hover {
+ background: oklch(48% 0.18 28 / 0.05);
+}
+.ov-cpv-row.is-active {
+ background: var(--accent-bg);
+ border-left-color: var(--accent);
+}
+.ov-cpv-code {
+ font: 600 11px/1 var(--font-mono);
+ color: var(--text-faint);
+}
+.ov-cpv-row.is-active .ov-cpv-code {
+ color: var(--accent);
+}
+.ov-cpv-name {
+ min-width: 0;
+}
+.ov-cpv-name .clamp {
+ display: block;
+ font-size: 13px;
+}
+.ov-cpv-row.is-active .ov-cpv-name .clamp {
+ font-weight: 600;
+}
+.ov-cpv-range {
+ display: block;
+ margin-top: 2px;
+ font: 400 10px/1 var(--font-mono);
+ color: var(--text-faint);
+}
+.ov-cpv-med {
+ text-align: right;
+ white-space: nowrap;
+ font: 600 12px/1 var(--font-mono);
+}
+.ov-cpv-n {
+ text-align: right;
+ white-space: nowrap;
+ font: 400 11.5px/1 var(--font-mono);
+ color: var(--text-muted);
+}
+.ov-check {
+ width: 14px;
+ height: 14px;
+ border-radius: 3px;
+ border: 1.5px solid var(--rule);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font: 700 9px/1 var(--font-mono);
+ color: var(--paper);
+}
+.ov-cpv-row.is-active .ov-check {
+ border-color: var(--accent);
+ background: var(--accent);
+}
+.ov-dist {
+ display: block;
+ width: 100%;
+ height: auto;
+ overflow: visible;
+}
+.ov-dist-axis {
+ stroke: var(--rule-soft);
+ stroke-width: 1;
+}
+.ov-dist-box {
+ fill: oklch(55% 0.03 240 / 0.16);
+}
+.ov-dot {
+ fill: oklch(18% 0.012 70 / 0.4);
+}
+.ov-dot.is-outlier {
+ fill: var(--accent);
+}
+.ov-dist-median {
+ stroke: var(--accent);
+ stroke-width: 1.6;
+}
+.ov-cpv-foot {
+ padding-top: 6px;
+ padding-bottom: var(--s-3);
+ display: grid;
+ grid-template-columns: 52px minmax(0, 1fr) 92px 56px minmax(180px, 320px);
+ gap: var(--s-3);
+}
+.ov-cpv-foot .ov-dist-ticks {
+ grid-column: 5;
+}
+.ov-dist-ticks line {
+ stroke: var(--rule);
+ stroke-width: 1;
+}
+.ov-dist-ticks text {
+ font: 400 8px var(--font-mono);
+ fill: var(--text-faint);
+}
+
+/* Cross lens: year picker + CPV picker side by side */
+.ov-cross {
+ display: grid;
+ grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
+ gap: var(--s-5);
+ align-items: start;
+}
+.ov-cross .ov-panel {
+ margin-bottom: 0;
+}
+.ov-cross + .ov-panel,
+.ov-cross {
+ margin-bottom: var(--s-5);
+}
+@media (max-width: 960px) {
+ .ov-cross {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* Shared contracts list: card grid */
+.ov-cards {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: var(--s-3);
+}
+.ov-card {
+ display: block;
+ border: 1px solid var(--rule-soft);
+ border-radius: 4px;
+ padding: 12px 14px;
+ background: var(--paper);
+ text-decoration: none;
+ color: var(--text);
+ transition:
+ box-shadow 0.15s,
+ border-color 0.15s;
+}
+.ov-card:visited {
+ color: var(--text);
+}
+.ov-card:hover {
+ border-color: var(--rule);
+ box-shadow: 0 2px 8px oklch(18% 0.012 70 / 0.06);
+}
+.ov-card .clamp {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.ov-card-top {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--s-2);
+}
+.ov-card-date {
+ font: 500 11px/1 var(--font-mono);
+ color: var(--text-faint);
+}
+.ov-card-val {
+ font: 600 13px/1 var(--font-mono);
+ white-space: nowrap;
+}
+.ov-card-buyer {
+ margin-top: 8px;
+ font-size: 13px;
+ font-weight: 600;
+}
+.ov-card-seller {
+ margin-top: 2px;
+ font-size: 12.5px;
+ color: var(--text-muted);
+}
+.ov-card-seller span {
+ color: var(--accent);
+}
+.ov-card-foot {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ margin-top: 9px;
+ padding-top: 9px;
+ border-top: 1px solid var(--rule-soft);
+ min-width: 0;
+}
+.ov-card-cpv {
+ font: 600 9.5px/1 var(--font-mono);
+ letter-spacing: 0.04em;
+ color: var(--text-faint);
+ border: 1px solid var(--rule);
+ border-radius: 2px;
+ padding: 3px 5px;
+ white-space: nowrap;
+}
+.ov-card-cohort {
+ flex: 1 1 auto;
+ min-width: 0;
+ font: 500 9.5px/1.2 var(--font-mono);
+ letter-spacing: 0.04em;
+ color: var(--text-faint);
+}
+.ov-card-rel {
+ margin-left: auto;
+ font: 600 10.5px/1 var(--font-mono);
+ white-space: nowrap;
+}
+.ov-rel-hi {
+ color: var(--accent);
+}
+.ov-rel-lo {
+ color: oklch(50% 0.05 240);
+}
+.ov-rel-mid {
+ color: var(--text-faint);
+}
+.ov-empty {
+ padding: var(--s-5) 0;
+ text-align: center;
+ font: 400 12px/1.5 var(--font-mono);
+ color: var(--text-faint);
+}
+.ov-note {
+ margin: var(--s-4) calc(-1 * var(--s-5)) calc(-1 * var(--s-4));
+ padding: 11px var(--s-5) 14px;
+ background: oklch(55% 0.03 240 / 0.06);
+ border-top: 1px solid oklch(55% 0.03 240 / 0.16);
+ border-radius: 0 0 5px 5px;
+ font: 400 11.5px/1.45 var(--font-sans);
+ color: var(--text-muted);
+}
+@media (max-width: 760px) {
+ .ov-cpv-head,
+ .ov-cpv-row {
+ grid-template-columns: 52px minmax(0, 1fr) 92px;
+ }
+ .ov-cpv-head .num + .num,
+ .ov-cpv-head span:last-child,
+ .ov-cpv-row .ov-cpv-n,
+ .ov-cpv-row .ov-dist,
+ .ov-cpv-foot {
+ display: none;
+ }
+}
+
+/* ── Quality index (/quality) — band tokens, pillar cards, histogram, scorecard ──────────────────
+ Band colors follow the design mock (good green / mid amber / weak = accent red); unknown stays
+ ink-soft so "insufficient data" never reads as a low score. */
+:root {
+ --q-good: oklch(52% 0.09 145);
+ --q-mid: oklch(66% 0.12 80);
+ --q-weak: var(--accent);
+ --q-unknown: var(--ink-soft);
+ --q-conf-medium: oklch(55% 0.05 230); /* the mock's slate-blue "medium confidence" */
+}
+.q-good {
+ color: var(--q-good);
+}
+.q-mid {
+ color: var(--q-mid);
+}
+.q-weak {
+ color: var(--q-weak);
+}
+.q-unknown {
+ color: var(--q-unknown);
+}
+
+/* pillar strip */
+.q-pillar-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ gap: var(--s-3);
+}
+.q-pillar-card {
+ border: 1px solid var(--rule);
+ background: var(--paper-warm);
+ padding: var(--s-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+}
+.q-pillar-card header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.q-pillar-card h3 {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.2;
+ min-block-size: 2.4em;
+}
+.q-letter {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 22px;
+ block-size: 22px;
+ background: var(--ink);
+ color: var(--paper);
+ font: 600 12px/1 var(--font-mono);
+}
+.q-letter.small {
+ inline-size: 19px;
+ block-size: 19px;
+ font-size: 11px;
+}
+.q-weight {
+ font: 600 11px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-pillar-val {
+ margin: 0;
+ font: 600 22px/1 var(--font-mono);
+}
+.q-pillar-val .muted {
+ font: 400 10px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-pillar-desc {
+ margin: 0;
+ font: 400 11px/1.35 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-track {
+ display: block;
+ block-size: 6px;
+ background: var(--paper-deep);
+ overflow: hidden;
+}
+.q-track i {
+ display: block;
+ block-size: 100%;
+ background: currentColor;
+}
+
+/* methodology */
+.q-method {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: var(--s-4);
+ margin-block: var(--s-3);
+}
+.q-method h4,
+.q-subhead {
+ margin: 0 0 var(--s-2);
+ font: 600 11px/1 var(--font-mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+.q-subhead {
+ margin-top: var(--s-4);
+}
+.q-method ol,
+.q-method ul {
+ margin: 0;
+ padding-left: 1.2em;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+ font-size: 13px;
+ line-height: 1.45;
+}
+.q-leaves-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ gap: var(--s-3);
+ margin-block: var(--s-2) var(--s-4);
+}
+.q-leaves-head {
+ margin: 0 0 var(--s-1);
+ font-size: 12px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.q-leaves-grid ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font: 400 11px/1.4 var(--font-mono);
+ color: var(--text-muted);
+}
+.q-leaves-grid li::before {
+ content: '· ';
+}
+.q-gate {
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+}
+.q-gate > div {
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ gap: var(--s-2);
+ align-items: baseline;
+}
+.q-gate dt {
+ font: 600 11px/1.3 var(--font-mono);
+}
+.q-gate dd {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.4;
+ color: var(--text-muted);
+}
+.q-tiers {
+ margin: 0 0 var(--s-2);
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+ font-size: 12px;
+}
+.q-tiers li {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+}
+.q-tier-range {
+ font: 600 11px/1 var(--font-mono);
+ inline-size: 90px;
+}
+.q-dot {
+ inline-size: 10px;
+ block-size: 10px;
+ flex: none;
+ background: var(--q-unknown);
+}
+.q-cov-dot-high {
+ background: var(--q-good);
+}
+.q-cov-dot-medium {
+ background: var(--q-conf-medium);
+}
+.q-cov-dot-low {
+ background: var(--q-mid);
+}
+.q-cov-dot-none {
+ background: var(--rule);
+}
+
+/* distribution + confidence */
+.q-dist {
+ display: grid;
+ grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr);
+ gap: var(--s-4);
+ align-items: start;
+}
+@media (max-width: 720px) {
+ .q-dist {
+ grid-template-columns: 1fr;
+ }
+ /* pillar strip (mock: "Индекс на качеството"): 5 cards no longer fit the auto-fit grid without
+ going cramped or wrapping to a 3rd row, so on phones it becomes an edge-to-edge swipe carousel
+ instead — bleed past `main`'s gutter and snap one card at a time. */
+ .q-pillar-grid {
+ display: flex;
+ grid-template-columns: none;
+ overflow-x: auto;
+ scroll-snap-type: x mandatory;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ margin-inline: calc(-1 * var(--gutter));
+ padding-inline: var(--gutter);
+ padding-bottom: 2px;
+ }
+ .q-pillar-grid::-webkit-scrollbar {
+ display: none;
+ }
+ .q-pillar-card {
+ flex: 0 0 auto;
+ min-inline-size: 158px;
+ scroll-snap-align: start;
+ }
+}
+.q-hist {
+ display: block;
+ inline-size: 100%;
+ block-size: auto;
+}
+/* clickable histogram bins/zones — GET links filtering the contracts list by score band */
+.q-bin-link,
+.q-zone-link {
+ cursor: pointer;
+ outline: none;
+}
+.q-bin-hit {
+ fill: transparent;
+}
+.q-bin-link:hover .q-bin,
+.q-bin-link:focus-visible .q-bin {
+ opacity: 0.75;
+}
+.q-bin-link:focus-visible .q-bin-hit,
+.q-zone-link:focus-visible .q-zone-label {
+ stroke: var(--accent);
+ stroke-width: 1.5;
+}
+.q-zone-link:hover .q-zone-label {
+ text-decoration: underline;
+}
+/* an active band dims everything outside the selection and outlines the selected bins */
+.q-hist.has-band .q-bin:not(.is-selected) {
+ opacity: 0.3;
+}
+.q-hist.has-band .q-bin.is-selected {
+ stroke: var(--ink);
+ stroke-width: 1;
+}
+.q-band-chip {
+ display: flex;
+ align-items: baseline;
+ gap: var(--s-3);
+ margin: var(--s-2) 0 0;
+ font: 500 12px/1.4 var(--font-mono);
+ color: var(--ink-mid);
+}
+.q-band-chip b {
+ color: var(--ink);
+}
+.q-band-tag {
+ font: 500 11px/1.4 var(--font-mono);
+ letter-spacing: 0.04em;
+ padding: 1px 6px;
+ border: 1px solid var(--rule);
+ background: var(--paper-warm);
+ color: var(--ink-mid);
+}
+.q-zone {
+ opacity: 0.35;
+}
+.q-zone-weak {
+ fill: var(--accent-bg);
+}
+.q-zone-mid {
+ fill: oklch(94% 0.05 90);
+}
+.q-zone-good {
+ fill: oklch(94% 0.04 150);
+}
+.q-zone-label {
+ font: 600 9px var(--font-mono);
+ letter-spacing: 0.1em;
+}
+.q-zone-label-weak {
+ fill: var(--q-weak);
+}
+.q-zone-label-mid {
+ fill: var(--q-mid);
+}
+.q-zone-label-good {
+ fill: var(--q-good);
+}
+.q-fill-good {
+ fill: var(--q-good);
+}
+.q-fill-mid {
+ fill: var(--q-mid);
+}
+.q-fill-weak {
+ fill: var(--q-weak);
+}
+.q-fill-unknown {
+ fill: var(--q-unknown);
+}
+.q-axis {
+ stroke: var(--rule);
+ stroke-width: 1;
+}
+.q-tick {
+ font: 400 9px var(--font-mono);
+ fill: var(--ink-soft);
+}
+.q-mean {
+ stroke: var(--ink);
+ stroke-width: 1.4;
+ stroke-dasharray: 4 3;
+}
+.q-mean-label {
+ font: 600 9px var(--font-mono);
+ fill: var(--ink);
+}
+.q-conf h4 {
+ margin: 0 0 var(--s-1);
+ font-size: 15px;
+}
+.q-confbar {
+ display: flex;
+ block-size: 16px;
+ overflow: hidden;
+ margin-block: var(--s-2);
+}
+.q-cov-fill-high {
+ background: var(--q-good);
+}
+.q-cov-fill-medium {
+ background: var(--q-conf-medium);
+}
+.q-cov-fill-low {
+ background: var(--q-mid);
+}
+.q-cov-fill-none {
+ background: var(--rule);
+}
+.q-conf-legend {
+ margin: 0 0 var(--s-2);
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+ font-size: 12.5px;
+}
+.q-conf-legend li {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+}
+.q-conf-legend b {
+ margin-left: auto;
+ font: 600 12px/1 var(--font-mono);
+}
+
+/* grain switcher + sort links */
+.q-grains {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0;
+ border: 1px solid var(--rule);
+ inline-size: fit-content;
+ max-inline-size: 100%;
+ margin-block: 0 var(--s-3);
+}
+.q-grains > a {
+ padding: 9px 13px;
+ font: 600 11px/1 var(--font-mono);
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ text-decoration: none;
+ color: var(--text-muted);
+ border-right: 1px solid var(--rule-soft);
+}
+.q-grains > a[aria-current] {
+ background: var(--ink);
+ color: var(--paper);
+}
+@media (max-width: 720px) {
+ /* grain switcher (mock): keep the segmented control on one scrollable line rather than letting
+ 6 grains wrap to a 2nd/3rd row and push the page content down. */
+ .q-grains {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+ .q-grains::-webkit-scrollbar {
+ display: none;
+ }
+ .q-grains > a {
+ white-space: nowrap;
+ }
+}
+.q-sort {
+ padding: 0 var(--s-3);
+ font: 500 11px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-sort.standalone {
+ padding: 0;
+ margin: 0 0 var(--s-3);
+ display: block;
+}
+.q-sort a {
+ color: var(--text-muted);
+ text-decoration: none;
+ padding: 4px 6px;
+}
+.q-sort a[aria-current] {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+/* „Разбивка" avg-index range filter (?rfrom/?rto) — a compact GET form in the filter-bar idiom */
+.q-range {
+ margin-bottom: var(--s-4);
+ gap: var(--s-3);
+}
+.q-range-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.q-range input[type='number'] {
+ inline-size: 5.5em;
+ padding: 4px var(--s-2);
+ font: 12px var(--font-mono);
+ border: 1px solid var(--rule);
+ background: var(--paper);
+ color: var(--ink);
+ letter-spacing: 0.04em;
+}
+.q-range button {
+ padding: 4px var(--s-3);
+ border: 1px solid var(--rule);
+ background: var(--paper);
+ cursor: pointer;
+ color: var(--ink-mid);
+ font: 500 11px/1.2 var(--font-mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+.q-range button:hover {
+ background: var(--ink);
+ color: var(--paper);
+ border-color: var(--ink);
+}
+.q-range a {
+ color: var(--ink);
+ text-decoration: none;
+ border: 1px solid var(--rule);
+ padding: 4px var(--s-2);
+ background: var(--wash, transparent);
+}
+
+/* index bar + pillar mini-pills (table cells and cards) */
+.q-index {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-inline-size: 120px;
+}
+.q-index-num {
+ font: 600 14px/1 var(--font-mono);
+ inline-size: 24px;
+ text-align: right;
+}
+.q-index-bar {
+ flex: 1;
+ block-size: 7px;
+ background: var(--paper-deep);
+ overflow: hidden;
+ min-inline-size: 56px;
+}
+.q-index-bar i {
+ display: block;
+ block-size: 100%;
+ background: currentColor;
+}
+.q-pills {
+ display: inline-flex;
+ align-items: flex-end;
+ gap: 4px;
+ block-size: 38px;
+}
+.q-pill {
+ display: inline-flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ inline-size: 14px;
+}
+.q-pill i {
+ display: block;
+ background: currentColor;
+}
+.q-pill i.q-unknown {
+ background: var(--rule);
+}
+.q-pill b {
+ margin-top: 3px;
+ text-align: center;
+ font: 500 8px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-cov {
+ display: inline-block;
+ padding: 4px 6px;
+ border: 1px solid var(--rule);
+ font: 600 10px/1 var(--font-mono);
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ color: var(--text-muted);
+}
+.q-cov-high {
+ color: var(--q-good);
+ border-color: color-mix(in oklab, var(--q-good) 45%, transparent);
+}
+.q-cov-medium {
+ color: var(--q-conf-medium);
+ border-color: color-mix(in oklab, var(--q-conf-medium) 45%, transparent);
+}
+.q-cov-low {
+ color: var(--q-mid);
+ border-color: color-mix(in oklab, var(--q-mid) 55%, transparent);
+}
+.q-cov-none {
+ color: var(--ink-soft);
+}
+.q-cov-text-high {
+ color: var(--q-good);
+}
+.q-cov-text-medium {
+ color: var(--q-conf-medium);
+}
+.q-cov-text-low {
+ color: var(--q-mid);
+}
+.q-cov-text-none {
+ color: var(--ink-soft);
+}
+.q-drill {
+ font: 500 11px/1 var(--font-mono);
+ white-space: nowrap;
+}
+
+/* contract cards */
+.q-contract-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: var(--s-3);
+ margin-block: 0 var(--s-3);
+}
+.q-card {
+ border: 1px solid var(--rule);
+ background: var(--paper-warm);
+ padding: var(--s-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+}
+.q-card.is-selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+.q-card header {
+ display: flex;
+ align-items: baseline;
+ gap: var(--s-2);
+}
+.q-card-date {
+ font: 500 11px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-card-cpv {
+ font: 600 9px/1 var(--font-mono);
+ color: var(--ink-soft);
+ border: 1px solid var(--rule);
+ padding: 2px 5px;
+ margin-left: 6px;
+ white-space: nowrap;
+}
+.q-card-score {
+ margin-left: auto;
+ font: 600 22px/1 var(--font-mono);
+}
+.q-card-buyer {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.q-card-seller {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.q-card-row {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--s-2);
+ margin-top: var(--s-1);
+}
+.q-card-row .q-pills {
+ block-size: 32px;
+}
+.q-card-value {
+ text-align: right;
+ font: 600 12px/1 var(--font-mono);
+}
+.q-card-value b {
+ display: block;
+ margin-top: 3px;
+ font: 400 9px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-card footer {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ margin-top: var(--s-1);
+}
+.q-card footer .q-drill {
+ margin-left: auto;
+}
+.q-card-note {
+ font: 400 10px/1.35 var(--font-mono);
+ color: var(--q-conf-medium);
+}
+
+/* scorecard */
+.q-scorecard {
+ border: 1px solid var(--rule);
+ background: var(--paper-warm);
+ padding: var(--s-4);
+}
+.q-sc-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--s-4);
+ flex-wrap: wrap;
+}
+.q-sc-identity {
+ min-inline-size: 0;
+ flex: 1 1 320px;
+}
+.q-sc-identity .q-card-buyer {
+ font-size: 15px;
+ white-space: normal;
+}
+.q-sc-value {
+ margin: var(--s-1) 0 0;
+ font: 600 13px/1.4 var(--font-mono);
+}
+.q-sc-side {
+ display: flex;
+ align-items: center;
+ gap: var(--s-4);
+ flex: none;
+}
+.q-sc-worst {
+ margin: 0;
+ text-align: right;
+}
+.q-sc-worst span {
+ display: block;
+ font: 400 9px/1 var(--font-mono);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+.q-sc-worst b {
+ display: block;
+ margin-top: 5px;
+ font: 600 12px/1.25 var(--font-mono);
+ color: var(--q-weak);
+ max-inline-size: 160px;
+}
+.q-sc-conf {
+ margin: 0;
+ font: 600 10px/1 var(--font-mono);
+ text-align: right;
+}
+.q-sc-ring {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ inline-size: 84px;
+ block-size: 84px;
+ border-radius: 50%;
+ border: 3px solid currentColor;
+ flex: none;
+}
+.q-sc-ring.is-unknown {
+ border-style: dashed;
+ color: var(--ink-soft);
+}
+.q-sc-ring b {
+ font: 600 28px/1 var(--font-mono);
+}
+.q-sc-ring span {
+ margin-top: 2px;
+ font: 500 8px/1 var(--font-mono);
+ color: var(--ink-soft);
+ text-transform: uppercase;
+}
+.q-sc-blend {
+ margin: var(--s-3) 0;
+ padding: var(--s-2) var(--s-3);
+ background: var(--paper-deep);
+ font: 500 12px/1.6 var(--font-mono);
+ color: var(--text-muted);
+}
+.q-sc-pillars {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: var(--s-2);
+}
+.q-sc-pillar {
+ border: 1px solid var(--rule-soft);
+ background: var(--paper);
+ padding: var(--s-2) var(--s-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-1);
+}
+.q-sc-pillar.is-worst {
+ border-color: color-mix(in oklab, var(--accent) 40%, transparent);
+ background: color-mix(in oklab, var(--accent) 5%, var(--paper));
+}
+.q-sc-pillar header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.q-sc-pillar h4 {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.2;
+ min-block-size: 2.4em;
+}
+.q-sc-pillar .q-pillar-val {
+ font-size: 18px;
+}
+.q-sc-leaves {
+ margin: var(--s-1) 0 0;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+.q-sc-leaves > div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--s-2);
+}
+.q-sc-leaves dt {
+ font: 400 10px/1.25 var(--font-mono);
+ color: var(--ink-soft);
+}
+.q-sc-leaves dd {
+ margin: 0;
+ font: 500 10px/1.25 var(--font-mono);
+ text-align: right;
+}
+.q-worst-badge {
+ margin: var(--s-1) 0 0;
+ padding: 4px 6px;
+ text-align: center;
+ font: 600 9px/1 var(--font-mono);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--accent);
+ background: var(--accent-bg);
+}
+.q-sc-covflags {
+ margin: var(--s-3) 0 0;
+ padding-top: var(--s-2);
+ border-top: 1px solid var(--rule-soft);
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ flex-wrap: wrap;
+}
+.q-covflags-label {
+ font: 600 10px/1 var(--font-mono);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+.q-covflag {
+ font: 400 11px/1 var(--font-mono);
+ color: var(--text-muted);
+ border: 1px solid var(--rule-soft);
+ padding: 5px 8px;
+}
+.q-gate-note {
+ margin: var(--s-2) 0 0;
+ padding: var(--s-2) var(--s-3);
+ background: var(--accent-bg);
+ border: 1px solid color-mix(in oklab, var(--accent) 25%, transparent);
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--text-muted);
+}
diff --git a/apps/web/workers/cache-key.test.ts b/apps/web/workers/cache-key.test.ts
index e2fd5b4fd..2faf7997b 100644
--- a/apps/web/workers/cache-key.test.ts
+++ b/apps/web/workers/cache-key.test.ts
@@ -118,6 +118,62 @@ describe('cacheKey', () => {
expect(cacheUrl('http://local/contracts?cursor=c5&page=2').search).not.toBe(
cacheUrl('http://local/contracts?cursor=c5&page=5').search,
);
+ // ?band (histogram score-band click-filter on /quality) narrows the contracts list — distinct
+ // bands and the unfiltered view must each get their own entry.
+ expect(cacheUrl('http://local/quality?band=6').search).not.toBe(
+ cacheUrl('http://local/quality').search,
+ );
+ expect(cacheUrl('http://local/quality?band=6').search).not.toBe(
+ cacheUrl('http://local/quality?band=weak').search,
+ );
+ // ?rdir flips the „Разбивка" row order; ?rfrom/?rto narrow its rows. Distinct values render
+ // different tables, so each must mint its own cache entry (CWE-349).
+ expect(cacheUrl('http://local/quality?rdir=desc').search).not.toBe(
+ cacheUrl('http://local/quality').search,
+ );
+ expect(cacheUrl('http://local/quality?rdir=desc').search).not.toBe(
+ cacheUrl('http://local/quality?rdir=asc').search,
+ );
+ expect(cacheUrl('http://local/quality?rfrom=10&rto=60').search).not.toBe(
+ cacheUrl('http://local/quality').search,
+ );
+ expect(cacheUrl('http://local/quality?rfrom=10&rto=60').search).not.toBe(
+ cacheUrl('http://local/quality?rfrom=10&rto=70').search,
+ );
+ expect(cacheUrl('http://local/quality?rfrom=10').search).not.toBe(
+ cacheUrl('http://local/quality?rto=10').search,
+ );
+ });
+
+ it('keys every recently-added param so a future refactor cannot silently drop it (CWE-349)', () => {
+ // /trends: angle (lens), cpv (5-digit group filter), cpvSort (CPV list ordering), step (series
+ // granularity) — each narrows or reorders the rendered list/chart.
+ expect(cacheUrl('http://local/trends?angle=cpv').search).not.toBe(
+ cacheUrl('http://local/trends').search,
+ );
+ expect(cacheUrl('http://local/trends?cpv=45233').search).not.toBe(
+ cacheUrl('http://local/trends').search,
+ );
+ expect(cacheUrl('http://local/trends?cpvSort=med').search).not.toBe(
+ cacheUrl('http://local/trends').search,
+ );
+ expect(cacheUrl('http://local/trends?step=year').search).not.toBe(
+ cacheUrl('http://local/trends?step=month').search,
+ );
+ // /quality: csort (contract list ordering), contract (scorecard subject), grain (rollup grain),
+ // sel (selected ranking row scoping the contracts list).
+ expect(cacheUrl('http://local/quality?csort=value').search).not.toBe(
+ cacheUrl('http://local/quality').search,
+ );
+ expect(cacheUrl('http://local/quality?contract=c1').search).not.toBe(
+ cacheUrl('http://local/quality').search,
+ );
+ expect(cacheUrl('http://local/quality?grain=supplier').search).not.toBe(
+ cacheUrl('http://local/quality?grain=year').search,
+ );
+ expect(cacheUrl('http://local/quality?sel=auth:1').search).not.toBe(
+ cacheUrl('http://local/quality').search,
+ );
});
});
diff --git a/docs/README.md b/docs/README.md
index 568550ad6..644734d62 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,7 @@
- [`integrity-gate.md`](integrity-gate.md) — reconciliation gate-ът: hard asserts върху тоталите при import/CI.
- [`anomaly-report.md`](anomaly-report.md) — cross-row аномалии при опресняване: какво `value_flag` не хваща на ниво отделен договор.
- [`deploy.md`](deploy.md) — деплой към Cloudflare: двата Worker-а (`sigma`, `sigma-etl`) и споделеният D1 per environment.
+- [`security-advisories.md`](security-advisories.md) — регистър на CVE/GHSA препоръки, засечени от dependency audit-а на CI, и как са адресирани (patch, override или суспендирани в `osv-scanner.toml`).
- [`api.md`](api.md) — публичните данни и машинно четими endpoint-и (CSV/JSON/sitemap), query грамата на филтрите и лицензът — за разработчици, които строят върху данните.
- [`accessibility.md`](accessibility.md) — достъпност (WCAG 2.1 AA / EN 301 549): какво покрива платформата и наблюденията за вградената приставка за достъпност.
- [`spec/ai-assistant.md`](spec/ai-assistant.md) — спецификация на разговорния аналитичен слой над СИГМА (BgGPT, текст и глас).
diff --git a/docs/etl.md b/docs/etl.md
index 6c6246204..f089f5254 100644
--- a/docs/etl.md
+++ b/docs/etl.md
@@ -432,6 +432,17 @@ web app-ът го чете без повторен import. Work базата (`d
- Remote D1 deploy (схема + domain ship към remote) — нужно е явно одобрение за всеки deploy.
- Извеждане от употреба на наследения CLI slice път.
+## Индекс на качеството (health derive)
+
+След `precompute.sql` пълният derive пуска още две фази: `scripts/derive-health.sql`
+(HHI/концентрационни rollups + `health_percentiles`) и `scripts/derive-contract-features.sql`
+(`contract_features` с оценка `score_overall` в [0,1] на договор + шестте `*_quality_totals`).
+Самостоятелно пускане: `node scripts/import.mjs --derive=health`; проверка:
+`node scripts/validate-health.mjs` (изход 0 = всички проверки минават). Дневният slice път и
+`ship-domain.mjs` пускат същите фази след precompute — пълно преизчисление, не инкрементално.
+Проверката за годишно покритие (`missingYears()`) нормализира двете страни с `String()`, така че
+остава коректна независимо дали D1 връща `year` като INTEGER или TEXT.
+
## Свързани документи
- [`architecture.md`](architecture.md) — архитектурата на платформата.
diff --git a/docs/security-advisories.md b/docs/security-advisories.md
new file mode 100644
index 000000000..e4486a02d
--- /dev/null
+++ b/docs/security-advisories.md
@@ -0,0 +1,28 @@
+# Security advisories
+
+## react-router 7.18.0 / postcss 8.5.15 / valibot 1.4.0
+
+Three advisories flagged by the "Dependency audit" CI step (`osv-scanner scan source -L
+pnpm-lock.yaml`):
+
+- `postcss@8.5.15` — GHSA-r28c-9q8g-f849 (path traversal via sourceMappingURL auto-load),
+ patch-level fix in `8.5.18`. Fixed by a pnpm override.
+- `valibot@1.4.0` — GHSA-5qjj-4xww-7phc (`flatten()` crashes on inherited-property keys),
+ patch-level fix in `1.4.2`. Fixed by a pnpm override.
+- `react-router@7.18.0` — four advisories fixed within the 7.x line (SSR hydration
+ constructor injection GHSA-337j-9hxr-rhxg, unauthenticated DoS via inefficient route
+ matching GHSA-chx6-hx7r-mcp5, RSCErrorHandler XSS GHSA-h8fp-f39c-q6mh, open redirect via
+ backslash GHSA-wrjc-x8rr-h8h6); fixed by bumping the `react-router`/`@react-router/dev`
+ pnpm overrides to `^7.18.0`. A fifth advisory, GHSA-qwww-vcr4-c8h2 (CSRF, CVSS 7.1), is
+ scoped to react-router's unstable RSC APIs, which this app does not use (verified via
+ repo-wide grep), and has no fix in the 7.x line — it is suppressed via `osv-scanner.toml`
+ rather than forcing a major-version bump to react-router 8.x.
+
+Verified clean (modulo the documented RSC-only suppression) with OSV-Scanner v2.4.0
+(`osv-scanner scan source -L pnpm-lock.yaml`).
+
+Rollout, by branch:
+
+| Branch | Commit SHA |
+| --- | --- |
+| `feat/contract-health-index` (PR #188) | `21df242` |
diff --git a/osv-scanner.toml b/osv-scanner.toml
index dad96e316..083a89dc8 100644
--- a/osv-scanner.toml
+++ b/osv-scanner.toml
@@ -10,6 +10,21 @@
# outlive the vulnerability they cover. `pnpm why ` shows the resolved version and
# what pulls it in.
+# ── react-router 7.18.0 — GHSA-qwww-vcr4-c8h2 (High, CVSS 7.1), fixed in 8.3.0 ──────────
+# WHY IGNORED: this CVE is a CSRF flaw in react-router's UNSTABLE RSC (React Server
+# Components) code paths only — "this only affects your application if you are using the
+# unstable RSC APIs" per the advisory. Verified via `git grep` across this repo for RSC
+# usage (unstable_.*RSC, react-server, unstable_RSCPayload, unstable_routeRSCServerRequest):
+# zero hits. This app does not use RSC. No fix exists in the 7.x line (introduced in 7.12.0,
+# only patched in 8.3.0) — upgrading to react-router 8.x is a major, breaking version bump
+# out of scope for a security patch to a code path this app never exercises.
+# REMOVE WHEN: this app adopts react-router's RSC APIs (re-evaluate applicability first), or
+# a deliberate, separately-planned major-version upgrade to react-router 8.x lands.
+[[IgnoredVulns]]
+id = "GHSA-qwww-vcr4-c8h2"
+ignoreUntil = 2026-10-01T00:00:00Z
+reason = "CSRF in react-router's unstable RSC code paths only (GHSA-qwww-vcr4-c8h2) - this app does not use RSC (verified via repo-wide grep for RSC APIs). No fix in the 7.x line; upgrading to 8.x is a major breaking change out of scope for a security patch to an unused code path."
+
# ── sharp 0.34.5 — GHSA-f88m-g3jw-g9cj (High, CVSS 7.0), fixed in 0.35.0 ──────────────────
# WHY IGNORED: sharp is a DEV-ONLY, TRANSITIVE dependency pulled in only by `miniflare`
# (Cloudflare's local Workers simulator, used by `wrangler dev` and the test suite).
diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts
index 4d8408676..721f28dcf 100644
--- a/packages/api-contract/src/index.ts
+++ b/packages/api-contract/src/index.ts
@@ -442,8 +442,10 @@ export interface NetworkData {
// Procurement spend by period for the /trends chart. Contracts without a usable signing date are
// excluded from the series and reported as coverage, never silently dropped.
+export type TrendGranularity = 'month' | 'quarter' | 'year';
+
export interface TrendPoint {
- period: string; // 'YYYY-MM' (month granularity) or 'YYYY' (year)
+ period: string; // 'YYYY-MM' (month), 'YYYY-Qn' (quarter) or 'YYYY' (year)
valueEur: number;
contracts: number;
partial: boolean; // the final period (the as_of period) is still being filled; rendered dashed
@@ -458,7 +460,7 @@ export interface TrendYear {
}
export interface TrendData {
- granularity: 'month' | 'year';
+ granularity: TrendGranularity;
points: TrendPoint[]; // continuous and zero-filled, sorted by period
years: TrendYear[]; // per-year summary with year-over-year change
sectors: SectorRef[]; // options for the sector select
@@ -467,10 +469,42 @@ export interface TrendData {
scope: {
sector: string | null;
funding: 'all' | 'eu' | 'national';
- granularity: 'month' | 'year';
+ granularity: TrendGranularity;
};
}
+// ── Contracts overview (/trends lenses) ──────────────────────────────────────────────────────────
+// Per-CPV-group price distribution and the shared filtered contract cards for the overview surface.
+// A "group" is the 5-digit CPV class prefix — fine enough that contracts inside it are comparable,
+// coarse enough that cohorts stay populated.
+
+export interface CpvGroupStat {
+ group: string; // 5-digit CPV prefix, e.g. '33600'
+ name: string | null; // representative cpv_description within the group (most common among the sample)
+ contracts: number; // contracts with a positive EUR value in the group
+ medianEur: number;
+ p10Eur: number;
+ p90Eur: number;
+ maxEur: number;
+ sampleEur: number[]; // real contract values: a quantile ladder plus the top outliers (dot cloud)
+}
+
+export interface CpvGroupMedian {
+ group: string;
+ name: string | null;
+ contracts: number;
+ medianEur: number;
+}
+
+export interface OverviewContract {
+ id: string; // contract slug for /contracts/:id
+ signedAt: string | null;
+ valueEur: number;
+ authorityName: string;
+ bidderName: string; // display name (consortiums folded to 'X и др.')
+ cpvGroup: string | null; // 5-digit CPV prefix, null when the tender has no usable CPV
+}
+
// ── Regions (map) ─────────────────────────────────────────────────────────────────────────────────
// Spend per Bulgarian region (NUTS3) for the /map choropleth. Region is known for ~half of
// authorities, so the unattributed bucket and coverage are first-class, never hidden.
@@ -598,6 +632,129 @@ export interface CompetitionData {
};
}
+// ── Quality index ───────────────────────────────────────────────────────────────────────────────
+// The Contract Quality / Health Index page (/quality). All scores are [0, 1] REALs from the ETL's
+// contract_features / *_quality_totals tables (Contract Quality / Health Index spec §12.0); NULL means
+// "insufficient data" — never zero. A low score is a weak-quality SIGNAL, not proof of wrongdoing.
+
+export type QualityGrain = 'authority' | 'supplier' | 'sector' | 'region' | 'year' | 'funding';
+export type QualityRankSort = 'score' | 'contracts';
+/** Ranking direction over the active sort key; default: score → 'asc' (weakest first), contracts → 'desc'. */
+export type QualityRankDir = 'asc' | 'desc';
+export type QualityContractSort = 'score' | 'value';
+/** §6.2 confidence tiers over score_coverage; 'none' = withheld („недостатъчно данни"). */
+export type QualityCoverageTier = 'high' | 'medium' | 'low' | 'none';
+
+/** Per-pillar scores/averages in [0,1]; null = not available for this row/grain. */
+export interface QualityPillars {
+ a: number | null; // Contestability
+ b: number | null; // Procedure openness
+ c: number | null; // Value integrity
+ d: number | null; // Relationship health
+ e: number | null; // Transparency / data quality
+}
+
+export interface QualityOverview {
+ totalContracts: number;
+ scoredContracts: number; // score_overall IS NOT NULL
+ suspectContracts: number; // value_flag = 'value_suspect' (unscored, excluded from averages)
+ avgOverall: number | null; // corpus mean of score_overall (scored rows only), [0,1]
+ meanCoverage: number | null; // corpus mean of score_coverage, [0,1]
+ pillars: QualityPillars; // corpus per-pillar means (non-NULL rows only)
+ histogram: { bin: number; count: number }[]; // 20 equal bins over score_overall (bin 0 = [0,.05))
+ confidence: { high: number; medium: number; low: number; none: number }; // contract counts
+}
+
+export interface QualityRankRow {
+ key: string; // raw grain key: authority_id / bidder_id / division / nuts / year / funding_key
+ href: string | null; // entity page for authority/supplier grains
+ name: string;
+ sub: string | null; // type label / NUTS code / grain caption
+ avgOverall: number; // [0,1]
+ pillars: QualityPillars; // only the pillar averages the rollup table carries
+ totalContracts: number;
+ scoredContracts: number;
+ meanCoverage: number | null;
+ coverageTier: QualityCoverageTier;
+}
+
+export interface QualityContractRow {
+ id: string;
+ slug: string; // /contracts/:slug
+ signedAt: string | null;
+ cpvDivision: string | null;
+ authorityName: string;
+ authoritySlug: string;
+ bidderDisplayName: string;
+ bidderSlug: string;
+ amountEur: number | null;
+ overall: number | null; // null = „недостатъчно данни" (never rendered as 0)
+ pillars: QualityPillars;
+ coverage: number | null;
+ coverageTier: QualityCoverageTier;
+ valueFlag: string | null; // ok | review | value_low | annex_suspect | value_suspect
+}
+
+/** Raw leaf values behind one contract's scorecard — formatted at render time, kept raw here. */
+export interface QualityLeaves {
+ bidsReceived: number | null;
+ singleOffer: boolean | null;
+ smeRate: number | null;
+ isEauction: boolean | null;
+ procedureType: string | null;
+ isAccelerated: boolean | null;
+ bidWindowDays: number | null;
+ annexCount: number | null;
+ costOverrunRatio: number | null;
+ estimateDevRatio: number | null;
+ firstAmendShock: boolean | null;
+ authorityHhi: number | null;
+ repeatWinIntensity: number | null;
+ edgeAgeYears: number | null;
+ sectorWinShare: number | null;
+ dateFlag: string | null;
+ subcontractPassthrough: number | null;
+ durationDays: number | null;
+ correctionsCount: number | null;
+}
+
+export interface QualityScorecard extends QualityContractRow {
+ known: boolean; // false → the „НЕОЦЕНЕН / недостатъчно данни" card
+ wmean: number | null; // weighted mean over non-NULL pillars, weights renormalized (§3.3)
+ worst: number | null; // weakest non-NULL pillar
+ worstPillar: keyof QualityPillars | null;
+ effectiveWeights: QualityPillars; // renormalized weight per pillar (0-weight when pillar is NULL)
+ leaves: QualityLeaves;
+ coverageFlags: { bids: boolean; sme: boolean; estimate: boolean; overrun: boolean };
+}
+
+export interface QualityData {
+ overview: QualityOverview;
+ ranking: QualityRankRow[];
+ contracts: QualityContractRow[];
+ scorecard: QualityScorecard | null;
+ scope: {
+ grain: QualityGrain;
+ sort: QualityRankSort;
+ sortDir: QualityRankDir; // effective ranking direction (defaulted per sort key)
+ contractSort: QualityContractSort;
+ sel: string | null; // selected ranking key filtering the contracts list
+ band: string | null; // histogram score band: bin index '0'–'19' (5-point bins) or 'weak'|'mid'|'good'
+ contractId: string | null; // scorecard subject (explicit ?contract or the default weakest-listed id)
+ rankFrom: number | null; // „Разбивка“ avg-index range bounds, display-scale ints 0–100 (from ≤ to)
+ rankTo: number | null;
+ top: number;
+ minScored: number; // floor applied to authority/supplier rankings (small-sample noise)
+ };
+}
+
+export interface QualitySummary {
+ totalContracts: number;
+ scoredContracts: number;
+ avgOverall: number | null;
+ meanCoverage: number | null;
+}
+
// ── Search ──────────────────────────────────────────────────────────────────────────────────────
export interface SearchHit {
diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql
index 90f98dace..114b9c5dc 100644
--- a/packages/db/migrations/0000_init.sql
+++ b/packages/db/migrations/0000_init.sql
@@ -378,3 +378,124 @@ CREATE INDEX idx_authority_totals_type ON authority_totals(type_group);
CREATE INDEX idx_authority_totals_name ON authority_totals(name);
CREATE INDEX idx_flow_pairs_won ON flow_pairs(won_eur DESC);
CREATE INDEX idx_flow_pairs_authority ON flow_pairs(authority_id);
+
+-- ===================================================================================
+-- 1c) CONTRACT QUALITY / HEALTH INDEX — Phase 4 entity rollups (scripts/derive-health.sql).
+-- Built on the served D1 after precompute.sql; the per-contract scoring (Phase 5) joins
+-- against these. See the Contract Quality / Health Index design spec §7.2.
+-- ===================================================================================
+
+CREATE TABLE authority_health_rollup (
+ authority_id TEXT PRIMARY KEY REFERENCES authorities(id),
+ hhi REAL, -- SUM((won/total)*(won/total)) over the authority's bidders
+ single_offer_share REAL, -- bids_received=1 / known-bids contracts
+ direct_award_share REAL, -- procedure_type='Пряко договаряне' / total
+ avg_annex_count REAL,
+ avg_cost_overrun REAL, -- mean current/signing where it grew
+ cancelled_share REAL, -- tenders.cancelled=1 / tenders (authority)
+ contracts_with_bids INTEGER,
+ total_contracts INTEGER
+);
+CREATE TABLE bidder_health_rollup (
+ bidder_id TEXT PRIMARY KEY REFERENCES bidders(id),
+ buyer_hhi REAL, -- SUM((won_from_buyer/total_won)^2) across buyers
+ buyer_count INTEGER,
+ avg_repeat_share REAL,
+ total_contracts INTEGER
+);
+CREATE TABLE sector_concentration (
+ cpv_division TEXT NOT NULL,
+ bidder_id TEXT NOT NULL REFERENCES bidders(id),
+ won_eur REAL NOT NULL,
+ contracts INTEGER NOT NULL,
+ division_total_eur REAL NOT NULL,
+ win_share REAL NOT NULL,
+ PRIMARY KEY (cpv_division, bidder_id)
+);
+CREATE INDEX idx_sector_concentration_bidder ON sector_concentration(bidder_id);
+CREATE TABLE health_percentiles ( -- corpus distribution snapshot (calibration + validation)
+ signal TEXT PRIMARY KEY, p05 REAL, p25 REAL, p50 REAL, p75 REAL, p95 REAL
+);
+
+-- ===================================================================================
+-- 1d) CONTRACT QUALITY / HEALTH INDEX — Phase 5 per-contract feature store
+-- (scripts/derive-contract-features.sql). See the Contract Quality / Health Index design spec §7.3.
+-- score_a..score_e / score_overall are REALs in [0,1]; populated by the scoring UPDATEs
+-- in scripts/derive-contract-features.sql (NULL = unknown/withheld, never zero).
+-- ===================================================================================
+
+CREATE TABLE contract_features (
+ contract_id TEXT PRIMARY KEY REFERENCES contracts(id),
+ -- peer + coverage
+ effective_peer_key TEXT, peer_n INTEGER,
+ coverage_bids INTEGER, coverage_sme INTEGER, coverage_estimate INTEGER,
+ coverage_overrun INTEGER, coverage_ocds INTEGER, score_coverage REAL,
+ -- A
+ bids_received INTEGER, single_offer INTEGER, sme_rate REAL, disq_rate REAL,
+ -- B
+ is_open_procedure INTEGER, is_direct_award INTEGER, has_exemption INTEGER,
+ is_outside_zop INTEGER, is_dps INTEGER, is_meat INTEGER, is_accelerated INTEGER,
+ is_framework INTEGER, is_eauction INTEGER, bid_window_days REAL, scoring_regime TEXT,
+ -- C
+ annex_count INTEGER, cost_overrun_ratio REAL, estimate_dev_ratio REAL,
+ value_flag TEXT, has_reason_text INTEGER, first_amend_shock INTEGER,
+ -- D
+ authority_hhi REAL, bidder_buyer_hhi REAL, repeat_win_intensity REAL,
+ sector_win_share REAL, pair_first_date TEXT, edge_age_years REAL, authority_suppliers INTEGER,
+ -- E
+ date_flag TEXT, eu_funded INTEGER, subcontract_passthrough REAL, corrections_count INTEGER,
+ duration_days INTEGER, winner_size TEXT, bidder_nuts TEXT, awarded_to_group INTEGER,
+ -- sub-scores [0,1], NULL when unknown
+ score_a REAL, score_b REAL, score_c REAL, score_d REAL, score_e REAL,
+ score_overall REAL, computed_at TEXT,
+ -- A1 leaf, auditable (§5.5/§5.6 PERCENT_RANK floor)
+ score_a_bids REAL, peer_has_multi INTEGER
+);
+CREATE INDEX idx_contract_features_overall ON contract_features(score_overall);
+CREATE INDEX idx_contract_features_peer ON contract_features(effective_peer_key);
+
+-- ===================================================================================
+-- 1e) CONTRACT QUALITY / HEALTH INDEX — Phase 5e aggregate UI rollups (six *_quality_totals
+-- grains, built last by scripts/derive-contract-features.sql). See spec §7.4/§9/§12.7.
+-- ===================================================================================
+
+CREATE TABLE authority_quality_totals (
+ authority_id TEXT PRIMARY KEY REFERENCES authorities(id), name TEXT NOT NULL, type_group TEXT,
+ avg_overall REAL, avg_a REAL, avg_b REAL, avg_c REAL, avg_d REAL, avg_e REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER NOT NULL, unknown_contracts INTEGER,
+ single_offer_count INTEGER, direct_award_count INTEGER, amended_count INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE bidder_quality_totals (
+ bidder_id TEXT PRIMARY KEY REFERENCES bidders(id), name TEXT NOT NULL,
+ avg_overall REAL, avg_c REAL, avg_d REAL, buyer_hhi REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER NOT NULL, amended_count INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE sector_quality_totals ( -- CPV division
+ division TEXT PRIMARY KEY, avg_overall REAL, avg_a REAL, avg_c REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, single_offer_pct REAL,
+ direct_award_pct REAL, mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE region_quality_totals ( -- NUTS of performance (tenders.place_of_performance)
+ nuts TEXT PRIMARY KEY, nuts_label TEXT, avg_overall REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE year_quality_totals ( -- count-weighted (trend comparability)
+ year TEXT PRIMARY KEY, avg_overall REAL, avg_a REAL, avg_b REAL, avg_c REAL, avg_d REAL, avg_e REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE funding_quality_totals ( -- eu_funded 0/1
+ funding_key TEXT PRIMARY KEY, -- 'eu' | 'national'
+ avg_overall REAL, total_contracts INTEGER NOT NULL, scored_contracts INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+
+-- ===================================================================================
+-- 1f) Pipeline diagnostics — one-row-per-metric counters populated by scripts/precompute.sql
+-- (currently: fx_rate_gap_rows) and surfaced in its run summary, so a systemic gap doesn't
+-- go silently unnoticed.
+-- ===================================================================================
+CREATE TABLE pipeline_diag (
+ metric TEXT PRIMARY KEY, value INTEGER NOT NULL, computed_at TEXT NOT NULL
+);
diff --git a/packages/db/migrations/0003_contract_health.sql b/packages/db/migrations/0003_contract_health.sql
new file mode 100644
index 000000000..62b20c3c1
--- /dev/null
+++ b/packages/db/migrations/0003_contract_health.sql
@@ -0,0 +1,25 @@
+-- Health-index foundation: add the nine columns required by the Contract Quality / Health Index
+-- spec (§7.1). Columns added after a table's creating migration live ONLY here — they are
+-- intentionally NOT folded into 0000_init.sql, because SQLite has no ADD COLUMN IF NOT EXISTS and
+-- `wrangler d1 migrations apply` on a fresh D1 runs the whole chain (0000 then 0003 would hit
+-- "duplicate column"). The work-DB backfill (scripts/import.mjs) applies the full migration chain
+-- for the same reason. The health rollup tables need no ALTERs here: they ship in 0000_init.sql
+-- for fresh DBs and are (re)created idempotently by the ETL derives (scripts/derive-health.sql,
+-- scripts/derive-contract-features.sql) on already-migrated DBs.
+-- Numbered 0003 to leave 0002 to `0002_contracts_overrun_index` (PRs #170/#171).
+-- Ordering assumption: `wrangler d1 migrations apply` runs migrations in filename order, so if
+-- 0002_contracts_overrun_index lands after this file is already applied, it will run AFTER 0003 on
+-- any DB that already has 0003. These nine ALTERs are purely additive (new nullable columns on
+-- existing tables) and read no state introduced by 0002, so applying out of numeric order is safe
+-- here — but any FUTURE 0002 migration that these columns/tables depend on would break that
+-- assumption and must be re-numbered above 0003 instead.
+
+ALTER TABLE contracts ADD COLUMN exemption_legal_basis TEXT;
+ALTER TABLE contracts ADD COLUMN outside_zop INTEGER;
+ALTER TABLE contracts ADD COLUMN dps_contract INTEGER;
+ALTER TABLE amendments ADD COLUMN reason TEXT;
+ALTER TABLE amendments ADD COLUMN circumstances TEXT;
+ALTER TABLE tenders ADD COLUMN corrections_count INTEGER;
+ALTER TABLE tenders ADD COLUMN estimated_value_eur REAL;
+ALTER TABLE flow_pairs ADD COLUMN first_date TEXT;
+ALTER TABLE flow_pairs ADD COLUMN last_date TEXT;
diff --git a/packages/db/src/contractor-identity-sql.test.ts b/packages/db/src/contractor-identity-sql.test.ts
index 92befd798..10cccedc1 100644
--- a/packages/db/src/contractor-identity-sql.test.ts
+++ b/packages/db/src/contractor-identity-sql.test.ts
@@ -11,6 +11,10 @@ const migration2 = readFileSync(
resolve(root, 'packages/db/migrations/0002_current_value_currency.sql'),
'utf8',
);
+const migration3 = readFileSync(
+ resolve(root, 'packages/db/migrations/0003_contract_health.sql'),
+ 'utf8',
+);
const staging = readFileSync(resolve(root, 'scripts/work-staging-schema.sql'), 'utf8');
const normalize = readFileSync(resolve(root, 'scripts/normalize-raw.sql'), 'utf8');
const precompute = readFileSync(resolve(root, 'scripts/precompute.sql'), 'utf8');
@@ -61,6 +65,7 @@ function build(path: 'normalize' | 'refresh'): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(schema);
db.exec(migration2);
+ db.exec(migration3);
db.exec(staging);
db.exec(seed);
if (path === 'normalize') {
diff --git a/packages/db/src/derive-health.test.ts b/packages/db/src/derive-health.test.ts
new file mode 100644
index 000000000..d3bb01c97
--- /dev/null
+++ b/packages/db/src/derive-health.test.ts
@@ -0,0 +1,73 @@
+///
+import { execFileSync } from 'node:child_process';
+import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const migrationsDir = resolve(root, 'packages/db/migrations');
+const migrations = readdirSync(migrationsDir)
+ .filter((f) => f.endsWith('.sql'))
+ .sort()
+ .map((f) => resolve(migrationsDir, f));
+const deriveHealth = resolve(root, 'scripts/derive-health.sql');
+
+function sqlite(dbPath: string, sql: string): string {
+ return execFileSync('sqlite3', ['-bail', dbPath], { input: sql, encoding: 'utf8' });
+}
+
+function readScript(dbPath: string, path: string): void {
+ execFileSync('sqlite3', ['-bail', dbPath], { input: `.read ${path}\n`, stdio: 'pipe' });
+}
+
+describe('derive-health.sql', () => {
+ // Regression for the fresh-derive abort: a CPV division whose priced contracts sum to 0 EUR
+ // (here a single amount_eur=0 contract) used to make win_share 0/0 = NULL and abort the whole
+ // script on sector_concentration.win_share NOT NULL. The zero-sum division must be skipped
+ // (its share is unknowable — never fabricated as 0) while every other division still lands.
+ it('completes when a CPV division sums to 0 EUR and skips that division', () => {
+ const dir = mkdtempSync(resolve(tmpdir(), 'sigma-derive-health-'));
+ const dbPath = resolve(dir, 'test.sqlite');
+ try {
+ for (const migration of migrations) readScript(dbPath, migration);
+
+ sqlite(
+ dbPath,
+ `
+ INSERT INTO authorities (id, name) VALUES ('auth:1', 'Възложител 1');
+ INSERT INTO bidders (id, name) VALUES ('eik:100', 'Изпълнител 1'), ('eik:200', 'Изпълнител 2');
+ -- Division 30: one priced contract at exactly 0 EUR → division total 0 (the abort case).
+ INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type)
+ VALUES ('t:1', 'unp-1', 'Тръжна 30', 'auth:1', '30200000', 'Открита процедура');
+ INSERT INTO contracts (id, tender_id, bidder_id, amount, amount_eur)
+ VALUES ('c:1', 't:1', 'eik:100', 0, 0);
+ -- Division 45: a normal priced division that must still be rolled up.
+ INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type)
+ VALUES ('t:2', 'unp-2', 'Тръжна 45', 'auth:1', '45200000', 'Открита процедура');
+ INSERT INTO contracts (id, tender_id, bidder_id, amount, amount_eur)
+ VALUES ('c:2', 't:2', 'eik:200', 1000, 511.29);
+ `,
+ );
+
+ // Must not throw: before the HAVING guard this aborted with
+ // "NOT NULL constraint failed: sector_concentration.win_share".
+ readScript(dbPath, deriveHealth);
+
+ // The zero-sum division is absent — no fabricated 0 (or NULL) win_share row.
+ expect(
+ sqlite(dbPath, "SELECT COUNT(*) FROM sector_concentration WHERE cpv_division='30';").trim(),
+ ).toBe('0');
+ // The healthy division still gets its rollup, with a real share.
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM sector_concentration WHERE cpv_division='45' AND win_share=1.0;",
+ ).trim(),
+ ).toBe('1');
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/db/src/etl-entity-canonicalization-sql.test.ts b/packages/db/src/etl-entity-canonicalization-sql.test.ts
index d413cddf1..e4b45d114 100644
--- a/packages/db/src/etl-entity-canonicalization-sql.test.ts
+++ b/packages/db/src/etl-entity-canonicalization-sql.test.ts
@@ -8,7 +8,9 @@ import { describe, expect, it } from 'vitest';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
+const migration1Path = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3Path = resolve(root, 'packages/db/migrations/0003_contract_health.sql');
const stagingPath = resolve(root, 'scripts/work-staging-schema.sql');
const etlPaths = [
['normalize-raw', resolve(root, 'scripts/normalize-raw.sql')],
@@ -36,7 +38,9 @@ function withEtlDb(label: string, run: (dbPath: string) => void): void {
const dbPath = resolve(dir, 'test.sqlite');
try {
readScript(dbPath, schemaPath);
+ readScript(dbPath, migration1Path);
readScript(dbPath, migration2Path);
+ readScript(dbPath, migration3Path);
readScript(dbPath, stagingPath);
run(dbPath);
} finally {
diff --git a/packages/db/src/integrity-checks.test.ts b/packages/db/src/integrity-checks.test.ts
index 04b4cbdd3..ae3e5fb12 100644
--- a/packages/db/src/integrity-checks.test.ts
+++ b/packages/db/src/integrity-checks.test.ts
@@ -6,13 +6,14 @@
// are async (they `await runner`), so the call sites await; a synchronous runner still works because
// awaiting its array result is transparent.
import { execFileSync } from 'node:child_process';
-import { mkdtempSync, rmSync } from 'node:fs';
+import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
assertIntegrity,
+ checkContractFeaturesIntegrity,
checkCurrentAmountParity,
checkDateSanity,
checkEikValidity,
@@ -23,10 +24,16 @@ import {
} from '../../../scripts/integrity-checks.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
-const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
-const migration1Path = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
-const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+// The full migration chain, in apply order — the ETL scripts under test (precompute.sql) now
+// reference columns added by later migrations (e.g. 0003's health-index columns), exactly like
+// scripts/import.mjs, which also applies the whole chain to a fresh work DB.
+const migrationsDir = resolve(root, 'packages/db/migrations');
+const migrationPaths = readdirSync(migrationsDir)
+ .filter((f) => f.endsWith('.sql'))
+ .sort()
+ .map((f) => resolve(migrationsDir, f));
const precomputePath = resolve(root, 'scripts/precompute.sql');
+const deriveContractFeaturesPath = resolve(root, 'scripts/derive-contract-features.sql');
function sqlite(dbPath: string, sql: string): void {
execFileSync('sqlite3', ['-bail', dbPath], { input: sql, encoding: 'utf8', stdio: 'pipe' });
@@ -65,9 +72,7 @@ VALUES
function freshDb(): string {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-integrity-'));
const dbPath = resolve(dir, 'test.sqlite');
- readScript(dbPath, schemaPath);
- readScript(dbPath, migration1Path);
- readScript(dbPath, migration2Path);
+ for (const migration of migrationPaths) readScript(dbPath, migration);
sqlite(dbPath, CLEAN_FIXTURE);
return dbPath;
}
@@ -76,6 +81,10 @@ function precompute(dbPath: string): void {
readScript(dbPath, precomputePath);
}
+function deriveContractFeatures(dbPath: string): void {
+ readScript(dbPath, deriveContractFeaturesPath);
+}
+
let dirs: string[] = [];
function track(dbPath: string): string {
dirs.push(dirname(dbPath));
@@ -310,3 +319,81 @@ describe('reconciliation gate — injected violations', () => {
).rejects.toThrow(/integrity gate failed/);
});
});
+
+// Contract Quality / Health Index hard gate (PR #188 review): derive-contract-features.sql's own
+// summary SELECT computed these invariants but never asserted them. CLEAN_FIXTURE's tender
+// procedure_type is deliberately lowercase (a real-world casing variant) so it does NOT match the
+// §12.2 exact-case vocabulary map — exercising that on its own would report unmapped_procedure_rows,
+// so this fixture uses the correctly-cased 'Открита процедура' to get a genuinely clean derive.
+describe('contract-features-integrity gate', () => {
+ function deriveFixture(): string {
+ const db = freshDb();
+ sqlite(db, "UPDATE tenders SET procedure_type = 'Открита процедура';");
+ precompute(db);
+ deriveContractFeatures(db);
+ return db;
+ }
+
+ it('self-skips before derive-contract-features.sql has run', async () => {
+ const db = track(freshDb());
+ const result = await checkContractFeaturesIntegrity(runner(db));
+ expect(result.skipped).toBe(true);
+ expect(result.ok).toBe(true);
+ });
+
+ it('passes clean after a real derive-contract-features.sql run', async () => {
+ const db = track(deriveFixture());
+ const result = await checkContractFeaturesIntegrity(runner(db));
+ expect(result.ok).toBe(true);
+ expect(result.skipped).toBe(false);
+ }, 30_000);
+
+ it('catches an orphaned/dropped contract_features row (contracts_rows mismatch)', async () => {
+ const db = track(deriveFixture());
+ sqlite(
+ db,
+ 'DELETE FROM contract_features WHERE contract_id = (SELECT MIN(contract_id) FROM contract_features);',
+ );
+ const result = await checkContractFeaturesIntegrity(runner(db));
+ expect(result.ok).toBe(false);
+ expect(result.detail).toMatch(/contract_features_rows .* != contracts_rows/);
+ }, 30_000);
+
+ it('catches an unmapped procedure_type (§12.2 vocabulary gap)', async () => {
+ const db = track(freshDb());
+ // one tender left with the fixture's lowercase, out-of-vocabulary procedure_type
+ sqlite(db, "UPDATE tenders SET procedure_type = 'Открита процедура' WHERE id = 't:2';");
+ precompute(db);
+ deriveContractFeatures(db);
+ const result = await checkContractFeaturesIntegrity(runner(db));
+ expect(result.ok).toBe(false);
+ expect(result.detail).toMatch(/unmapped procedure_type/);
+ }, 30_000);
+
+ it('catches a direct-award contract with a nonzero score_b', async () => {
+ const db = track(deriveFixture());
+ sqlite(
+ db,
+ "UPDATE tenders SET procedure_type = 'Пряко договаряне' WHERE id = (SELECT tender_id FROM contracts WHERE id = 'c:1');" +
+ "UPDATE contract_features SET score_b = 0.5 WHERE contract_id = 'c:1';",
+ );
+ const result = await checkContractFeaturesIntegrity(runner(db));
+ expect(result.ok).toBe(false);
+ expect(result.detail).toMatch(/direct-award .* nonzero score_b/);
+ }, 30_000);
+
+ it('assertIntegrity with the narrowed checks array gates only contract-features-integrity', async () => {
+ const db = track(deriveFixture());
+ sqlite(
+ db,
+ 'DELETE FROM contract_features WHERE contract_id = (SELECT MIN(contract_id) FROM contract_features);',
+ );
+ await expect(
+ assertIntegrity(runner(db), {
+ label: 'test-contract-features',
+ exit: false,
+ checks: [checkContractFeaturesIntegrity],
+ }),
+ ).rejects.toThrow(/integrity gate failed/);
+ }, 30_000);
+});
diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts
index 72e4e48b2..d40798471 100644
--- a/packages/db/src/migrations.test.ts
+++ b/packages/db/src/migrations.test.ts
@@ -1,15 +1,34 @@
///
import { execFileSync } from 'node:child_process';
-import { mkdtempSync, rmSync } from 'node:fs';
+import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
+const migrationsDir = resolve(root, 'packages/db/migrations');
+// The FULL chain in apply order — exactly what `wrangler d1 migrations apply` runs on a fresh D1
+// and what scripts/import.mjs applies to a fresh work DB. Every migration must apply cleanly after
+// the ones before it (e.g. 0003's ADD COLUMNs must not duplicate columns already in 0000).
+// The exact expected chain, kept in sync by hand: a soft `length >= N` check would still pass if a
+// migration file were accidentally deleted (as long as N remained), silently dropping schema from
+// `wrangler d1 migrations apply` on a fresh D1. Asserting the exact file set makes a lost migration
+// fail loudly instead of passing quietly.
+const EXPECTED_MIGRATION_FILES = [
+ '0000_init.sql',
+ '0001_flow_pairs_bidder_index.sql',
+ '0002_current_value_currency.sql',
+ '0003_contract_health.sql',
+];
+const migrationFiles = readdirSync(migrationsDir)
+ .filter((f) => f.endsWith('.sql'))
+ .sort();
+const migrations = migrationFiles.map((f) => resolve(migrationsDir, f));
const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql');
const migration1 = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
const migration2 = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3 = resolve(root, 'packages/db/migrations/0003_contract_health.sql');
const backfill = resolve(root, 'scripts/backfill-current-value-currency.sql');
const precompute = resolve(root, 'scripts/precompute.sql');
@@ -28,9 +47,8 @@ describe('served migrations', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-migrations-'));
const dbPath = resolve(dir, 'test.sqlite');
try {
- readScript(dbPath, migration0);
- readScript(dbPath, migration1);
- readScript(dbPath, migration2);
+ expect(migrationFiles).toEqual(EXPECTED_MIGRATION_FILES);
+ for (const migration of migrations) readScript(dbPath, migration);
expect(
sqlite(
@@ -85,6 +103,41 @@ describe('served migrations', () => {
).trim(),
).toBe('1');
+ // 0003 adds the health-index foundation columns (contract quality spec §7.1) — additive
+ // ALTERs only, deliberately NOT folded into 0000 (SQLite has no ADD COLUMN IF NOT EXISTS,
+ // so duplicating them there would break the fresh-DB chain apply this test exercises).
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM pragma_table_info('contracts') WHERE name IN ('exemption_legal_basis','outside_zop','dps_contract');",
+ ).trim(),
+ ).toBe('3');
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM pragma_table_info('flow_pairs') WHERE name IN ('first_date','last_date');",
+ ).trim(),
+ ).toBe('2');
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM pragma_table_info('tenders') WHERE name IN ('corrections_count','estimated_value_eur');",
+ ).trim(),
+ ).toBe('2');
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM pragma_table_info('amendments') WHERE name IN ('reason','circumstances');",
+ ).trim(),
+ ).toBe('2');
+ // The health rollup tables ship in the base schema (rebuilt idempotently by the ETL derive).
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('authority_health_rollup','contract_features','year_quality_totals');",
+ ).trim(),
+ ).toBe('3');
+
// The served schema must never carry raw_* staging tables.
expect(
sqlite(dbPath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'raw_%';").trim(),
@@ -129,6 +182,7 @@ describe('served migrations', () => {
'2026-06-03', 'eop:annexes:2026-06-01');`,
);
readScript(dbPath, migration2);
+ readScript(dbPath, migration3);
readScript(dbPath, backfill);
readScript(dbPath, precompute);
diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts
index 2ed922e7b..cccf597f4 100644
--- a/packages/db/src/queries/index.ts
+++ b/packages/db/src/queries/index.ts
@@ -16,6 +16,7 @@ export * from './network';
export * from './trend';
export * from './regions';
export * from './competition';
+export * from './quality';
export * from './search';
export * from './details';
export * from './sitemaps';
diff --git a/packages/db/src/queries/quality.test.ts b/packages/db/src/queries/quality.test.ts
new file mode 100644
index 000000000..b244b0f3e
--- /dev/null
+++ b/packages/db/src/queries/quality.test.ts
@@ -0,0 +1,627 @@
+///
+import { readFileSync, readdirSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
+import { fileURLToPath } from 'node:url';
+import { beforeAll, describe, expect, it } from 'vitest';
+import {
+ coverageTier,
+ getQuality,
+ getQualityScorecard,
+ getQualitySummary,
+ qualityBlend,
+} from './quality';
+
+// Integration test for the /quality query module. Unlike competition.test.ts's canned-row fake D1,
+// the quality tables (contract_features + the six *_quality_totals rollups) are NEW — so this builds
+// a real SQLite (node:sqlite; the sqlite3 CLI harness of competition-sql.test.ts is not guaranteed
+// on every dev box) from the production migration PLUS the exact DDL of scripts/
+// derive-contract-features.sql, loads a deterministic fixture, and runs the actual module SQL + JS
+// mapping against it. The D1 adapter below is the minimal prepare/bind/all/first surface the module
+// uses.
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
+
+// DDL copied verbatim from scripts/derive-contract-features.sql (the ETL owns those files; this test
+// only mirrors the shape it will read in production).
+const QUALITY_DDL = `
+CREATE TABLE contract_features (
+ contract_id TEXT PRIMARY KEY REFERENCES contracts(id),
+ effective_peer_key TEXT, peer_n INTEGER,
+ coverage_bids INTEGER, coverage_sme INTEGER, coverage_estimate INTEGER,
+ coverage_overrun INTEGER, coverage_ocds INTEGER, score_coverage REAL,
+ bids_received INTEGER, single_offer INTEGER, sme_rate REAL, disq_rate REAL,
+ is_open_procedure INTEGER, is_direct_award INTEGER, has_exemption INTEGER,
+ is_outside_zop INTEGER, is_dps INTEGER, is_meat INTEGER, is_accelerated INTEGER,
+ is_framework INTEGER, is_eauction INTEGER, bid_window_days REAL, scoring_regime TEXT,
+ annex_count INTEGER, cost_overrun_ratio REAL, estimate_dev_ratio REAL,
+ value_flag TEXT, has_reason_text INTEGER, first_amend_shock INTEGER,
+ authority_hhi REAL, bidder_buyer_hhi REAL, repeat_win_intensity REAL,
+ sector_win_share REAL, pair_first_date TEXT, edge_age_years REAL, authority_suppliers INTEGER,
+ date_flag TEXT, eu_funded INTEGER, subcontract_passthrough REAL, corrections_count INTEGER,
+ duration_days INTEGER, winner_size TEXT, bidder_nuts TEXT, awarded_to_group INTEGER,
+ score_a REAL, score_b REAL, score_c REAL, score_d REAL, score_e REAL,
+ score_overall REAL, computed_at TEXT,
+ score_a_bids REAL, peer_has_multi INTEGER
+);
+CREATE TABLE authority_quality_totals (
+ authority_id TEXT PRIMARY KEY REFERENCES authorities(id), name TEXT NOT NULL, type_group TEXT,
+ avg_overall REAL, avg_a REAL, avg_b REAL, avg_c REAL, avg_d REAL, avg_e REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER NOT NULL, unknown_contracts INTEGER,
+ single_offer_count INTEGER, direct_award_count INTEGER, amended_count INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE bidder_quality_totals (
+ bidder_id TEXT PRIMARY KEY REFERENCES bidders(id), name TEXT NOT NULL,
+ avg_overall REAL, avg_c REAL, avg_d REAL, buyer_hhi REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER NOT NULL, amended_count INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE sector_quality_totals (
+ division TEXT PRIMARY KEY, avg_overall REAL, avg_a REAL, avg_c REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, single_offer_pct REAL,
+ direct_award_pct REAL, mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE region_quality_totals (
+ nuts TEXT PRIMARY KEY, nuts_label TEXT, avg_overall REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE year_quality_totals (
+ year TEXT PRIMARY KEY, avg_overall REAL, avg_a REAL, avg_b REAL, avg_c REAL, avg_d REAL, avg_e REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, mean_coverage REAL, computed_at TEXT
+);
+CREATE TABLE funding_quality_totals (
+ funding_key TEXT PRIMARY KEY,
+ avg_overall REAL, total_contracts INTEGER NOT NULL, scored_contracts INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+`;
+
+// Two authorities, two suppliers, four contracts:
+// c:1 weak (auth:100000001 × eik:200000001, 45): pillars .2/.4/.55/.3/.84 → wmean .4015, worst .2 → overall .321
+// c:2 strong (auth:100000002 × eik:200000002, 33): pillars .8/.9/.9/.7/1.0 → wmean .84, worst .7 → overall .784
+// c:4 mid (auth:100000002 × eik:200000001, 33, EU): pillars .5/.6/.7/.5/.9 → wmean .605, worst .5 → overall .563
+// c:3 value_suspect (auth:100000001 × eik:200000002): all scores NULL — must surface as unscored, never as 0.
+const FIXTURE = `
+INSERT INTO authorities (id, name, bulstat, type_group) VALUES
+ ('auth:100000001', 'Институция А', '100000001', 'община'),
+ ('auth:100000002', 'Институция Б', '100000002', 'болница');
+INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES
+ ('eik:200000001', 'Фирма Х', '200000001', '200000001', 1, 'company'),
+ ('eik:200000002', 'Фирма У', '200000002', '200000002', 1, 'company');
+INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status, place_of_performance) VALUES
+ ('t:A', 'UNP-A', 'Поръчка А', 'auth:100000001', '45233120', 'Открита процедура', 'awarded', 'BG411'),
+ ('t:B', 'UNP-B', 'Поръчка Б', 'auth:100000002', '33600000', 'Публично състезание', 'awarded', 'BG421');
+INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, bids_received, eu_funded, value_flag, amount_eur) VALUES
+ ('c:1', 't:A', 'eik:200000001', 1000, 'EUR', '2024-03-01', 1, 0, 'ok', 1000),
+ ('c:2', 't:B', 'eik:200000002', 2000, 'EUR', '2025-06-01', 4, 0, 'ok', 2000),
+ ('c:3', 't:A', 'eik:200000002', 99999999, 'BGN', '2024-05-01', 1, 0, 'value_suspect', NULL),
+ ('c:4', 't:B', 'eik:200000001', 1500, 'EUR', '2024-09-01', 2, 1, 'ok', 1500);
+INSERT INTO contract_features (
+ contract_id, score_coverage, value_flag,
+ score_a, score_b, score_c, score_d, score_e, score_overall,
+ bids_received, single_offer, sme_rate, is_eauction, is_accelerated, bid_window_days,
+ annex_count, cost_overrun_ratio, estimate_dev_ratio, first_amend_shock,
+ authority_hhi, repeat_win_intensity, edge_age_years, sector_win_share,
+ date_flag, subcontract_passthrough, duration_days, corrections_count,
+ coverage_bids, coverage_sme, coverage_estimate, coverage_overrun
+) VALUES
+ ('c:1', 0.78, 'ok', 0.2, 0.4, 0.55, 0.3, 0.84, 0.321,
+ 1, 1, NULL, 0, 0, 22, 2, 1.4, 0.35, 0,
+ 0.74, 0.71, 9.0, 0.4, 'ok', NULL, 720, NULL, 1, 0, 1, 1),
+ ('c:2', 0.85, 'ok', 0.8, 0.9, 0.9, 0.7, 1.0, 0.784,
+ 4, 0, 0.5, 1, 0, 35, 0, 1.0, 0.04, 0,
+ 0.22, 0.19, 1.2, 0.1, 'ok', NULL, 365, NULL, 1, 1, 1, 1),
+ ('c:3', 0.30, 'value_suspect', NULL, NULL, NULL, NULL, NULL, NULL,
+ 1, 1, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+ 0.74, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 0, 0, 0),
+ ('c:4', 0.50, 'ok', 0.5, 0.6, 0.7, 0.5, 0.9, 0.563,
+ 2, 0, 0.5, 0, 0, 30, 1, 1.1, 0.2, 0,
+ 0.22, 0.42, 4.0, 0.2, 'ok', NULL, 400, NULL, 1, 1, 1, 1);
+-- Rollups as the ETL would write them (scored_contracts inflated past the module's authority/
+-- supplier small-sample floor so the ranking queries return the fixture rows).
+INSERT INTO authority_quality_totals VALUES
+ ('auth:100000001', 'Институция А', 'община', 0.321, 0.2, 0.4, 0.55, 0.3, 0.84, 40, 25, 15, 30, 5, 10, 0.78, '2026-07-01'),
+ ('auth:100000002', 'Институция Б', 'болница', 0.690, 0.68, 0.78, 0.82, 0.62, 0.96, 60, 50, 10, 5, 1, 8, 0.85, '2026-07-01');
+INSERT INTO bidder_quality_totals VALUES
+ ('eik:200000001', 'Фирма Х', 0.40, 0.60, 0.38, 0.5, 45, 30, 12, 0.66, '2026-07-01'),
+ ('eik:200000002', 'Фирма У', 0.784, 0.9, 0.7, 0.2, 30, 25, 2, 0.85, '2026-07-01');
+INSERT INTO sector_quality_totals VALUES
+ ('45', 0.42, 0.3, 0.5, 100, 80, 41.0, 12.0, 0.74, '2026-07-01'),
+ ('33', 0.66, 0.6, 0.8, 90, 85, 20.0, 5.0, 0.82, '2026-07-01'),
+ ('NA', 0.5, 0.5, 0.5, 10, 5, 0, 0, 0.5, '2026-07-01');
+INSERT INTO region_quality_totals VALUES
+ ('BG411', 'София (столица)', 0.61, 120, 100, 0.84, '2026-07-01'),
+ ('BG421', 'Пловдив', 0.64, 60, 55, 0.83, '2026-07-01'),
+ ('NA', NULL, 0.5, 9, 4, 0.4, '2026-07-01');
+INSERT INTO year_quality_totals VALUES
+ ('2024', 0.44, 0.35, 0.5, 0.62, 0.4, 0.72, 90, 70, 0.58, '2026-07-01'),
+ ('2025', 0.63, 0.6, 0.7, 0.84, 0.62, 0.9, 100, 95, 0.9, '2026-07-01'),
+ ('NA', 0.5, NULL, NULL, NULL, NULL, NULL, 3, 1, 0.4, '2026-07-01');
+INSERT INTO funding_quality_totals VALUES
+ ('eu', 0.54, 44164, 40000, 0.82, '2026-07-01'),
+ ('national', 0.60, 150320, 140000, 0.85, '2026-07-01');
+`;
+
+/**
+ * Extracts the column names a `CREATE TABLE [IF NOT EXISTS] (...)` statement declares, from
+ * raw SQL text — paren-depth aware (so `REFERENCES foo(id)` and similar don't split the column
+ * list early), comments stripped, table-level constraints (PRIMARY/FOREIGN/UNIQUE/CHECK/CONSTRAINT)
+ * excluded. Used by the schema-drift guard below to compare QUALITY_DDL against the real DDL in
+ * scripts/derive-contract-features.sql without executing either.
+ *
+ * Staging-swap aware: if `table` is never CREATEd directly but is the target of an
+ * `ALTER TABLE RENAME TO ` (the atomic staging-swap pattern derive-contract-features.sql
+ * uses for contract_features — build under `_next`, then swap), the columns are read from
+ * the CREATE TABLE for `` instead, since that's the DDL that actually defines the schema.
+ */
+function extractTableColumns(sql: string, table: string): string[] {
+ const noComments = sql.replace(/--[^\n]*/g, '');
+ const renameMatch = noComments.match(
+ new RegExp(`ALTER TABLE\\s+(\\S+)\\s+RENAME TO\\s+${table}\\b`, 'i'),
+ );
+ const createName = renameMatch ? renameMatch[1] : table;
+ const start = noComments.search(
+ new RegExp(`CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?${createName}\\s*\\(`, 'i'),
+ );
+ if (start === -1) throw new Error(`CREATE TABLE ${createName} not found`);
+ const openParen = noComments.indexOf('(', start);
+ let depth = 0;
+ let end = openParen;
+ for (let i = openParen; i < noComments.length; i += 1) {
+ if (noComments[i] === '(') depth += 1;
+ else if (noComments[i] === ')') {
+ depth -= 1;
+ if (depth === 0) {
+ end = i;
+ break;
+ }
+ }
+ }
+ const body = noComments.slice(openParen + 1, end);
+ // Split on top-level commas only (depth-0), so REFERENCES foo(id) stays inside one column entry.
+ const parts: string[] = [];
+ let depth2 = 0;
+ let cur = '';
+ for (const ch of body) {
+ if (ch === '(') depth2 += 1;
+ else if (ch === ')') depth2 -= 1;
+ if (ch === ',' && depth2 === 0) {
+ parts.push(cur);
+ cur = '';
+ } else {
+ cur += ch;
+ }
+ }
+ parts.push(cur);
+ const constraintKeywords = new Set(['PRIMARY', 'FOREIGN', 'UNIQUE', 'CHECK', 'CONSTRAINT']);
+ return parts
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0)
+ .map((p) => p.split(/\s+/)[0]!)
+ .filter((name) => !constraintKeywords.has(name.toUpperCase()));
+}
+
+/** Minimal D1 surface over node:sqlite — enough for the module's prepare().bind().all()/first(). */
+function asD1(db: DatabaseSync): D1Database {
+ return {
+ prepare(sql: string) {
+ let args: (string | number | null)[] = [];
+ const stmt = {
+ bind(...a: (string | number | null)[]) {
+ args = a;
+ return stmt;
+ },
+ async all() {
+ return { results: db.prepare(sql).all(...args) as T[] };
+ },
+ async first() {
+ return (db.prepare(sql).get(...args) ?? null) as T | null;
+ },
+ };
+ return stmt;
+ },
+ } as unknown as D1Database;
+}
+
+let d1: D1Database;
+
+beforeAll(() => {
+ const db = new DatabaseSync(':memory:');
+ // Full migration chain — the fixture writes 0003's health-index columns.
+ const migrationsDir = resolve(root, 'packages/db/migrations');
+ for (const f of readdirSync(migrationsDir)
+ .filter((n) => n.endsWith('.sql'))
+ .sort()) {
+ db.exec(readFileSync(resolve(migrationsDir, f), 'utf8'));
+ }
+ // 0000_init ships the quality tables too; drop them so QUALITY_DDL (the ETL-derive shape this
+ // test mirrors verbatim) is the single source of the schema under test.
+ for (const t of [
+ 'contract_features',
+ 'authority_quality_totals',
+ 'bidder_quality_totals',
+ 'sector_quality_totals',
+ 'region_quality_totals',
+ 'year_quality_totals',
+ 'funding_quality_totals',
+ ]) {
+ db.exec(`DROP TABLE IF EXISTS ${t};`);
+ }
+ db.exec(QUALITY_DDL);
+ db.exec(FIXTURE);
+ d1 = asD1(db);
+});
+
+// Schema-drift guard: QUALITY_DDL above is a third hand-copy of the quality schema (besides
+// 0000_init.sql and scripts/derive-contract-features.sql). Rather than trust it by eyeball, diff its
+// column set against the real ETL DDL it's supposed to mirror for every quality table — so a future
+// column added to one and not the other fails CI instead of silently drifting.
+describe('QUALITY_DDL schema-drift guard', () => {
+ const etlSql = readFileSync(resolve(root, 'scripts/derive-contract-features.sql'), 'utf8');
+ const initSql = readFileSync(resolve(root, 'packages/db/migrations/0000_init.sql'), 'utf8');
+ const tables = [
+ 'contract_features',
+ 'authority_quality_totals',
+ 'bidder_quality_totals',
+ 'sector_quality_totals',
+ 'region_quality_totals',
+ 'year_quality_totals',
+ 'funding_quality_totals',
+ ];
+
+ it.each(tables)('%s columns match scripts/derive-contract-features.sql exactly', (table) => {
+ const testCols = extractTableColumns(QUALITY_DDL, table).sort();
+ const etlCols = extractTableColumns(etlSql, table).sort();
+ expect(testCols).toEqual(etlCols);
+ });
+
+ it.each(tables)('%s columns match packages/db/migrations/0000_init.sql exactly', (table) => {
+ const testCols = extractTableColumns(QUALITY_DDL, table).sort();
+ const initCols = extractTableColumns(initSql, table).sort();
+ expect(testCols).toEqual(initCols);
+ });
+});
+
+describe('coverageTier', () => {
+ it('maps §6.2 thresholds; null/withheld → none, never a fabricated low tier', () => {
+ expect(coverageTier(0.9)).toBe('high');
+ expect(coverageTier(0.8)).toBe('high');
+ expect(coverageTier(0.79)).toBe('medium');
+ expect(coverageTier(0.6)).toBe('medium');
+ expect(coverageTier(0.59)).toBe('low');
+ expect(coverageTier(0.4)).toBe('low');
+ expect(coverageTier(0.39)).toBe('none');
+ expect(coverageTier(null)).toBe('none');
+ });
+});
+
+describe('qualityBlend', () => {
+ it('renormalizes weights over non-NULL pillars and finds the worst link', () => {
+ const b = qualityBlend({ a: 0.2, b: 0.4, c: 0.55, d: 0.3, e: 0.84 });
+ expect(b.wmean).toBeCloseTo(0.4015, 4);
+ expect(b.worst).toBe(0.2);
+ expect(b.worstPillar).toBe('a');
+ // 0.6 × wmean + 0.4 × worst reproduces the ETL's stored score_overall
+ expect(0.6 * b.wmean! + 0.4 * b.worst!).toBeCloseTo(0.321, 3);
+ });
+
+ it('drops NULL pillars and renormalizes to sum 1 (spec §3.3 step 3)', () => {
+ const b = qualityBlend({ a: 0.5, b: null, c: 0.5, d: null, e: null });
+ // weights a=.30, c=.25 → renormalized .5455/.4545
+ expect(b.effectiveWeights.a).toBeCloseTo(0.3 / 0.55, 4);
+ expect(b.effectiveWeights.c).toBeCloseTo(0.25 / 0.55, 4);
+ expect(b.effectiveWeights.b).toBeNull();
+ expect(b.wmean).toBeCloseTo(0.5, 6);
+ });
+
+ it('returns all-null for a fully unscored contract — unknown, not zero', () => {
+ const b = qualityBlend({ a: null, b: null, c: null, d: null, e: null });
+ expect(b.wmean).toBeNull();
+ expect(b.worst).toBeNull();
+ expect(b.worstPillar).toBeNull();
+ });
+});
+
+describe('getQuality — overview', () => {
+ it('counts total/scored/suspect and averages only scored rows', async () => {
+ const { overview } = await getQuality(d1, {});
+ expect(overview.totalContracts).toBe(4);
+ expect(overview.scoredContracts).toBe(3);
+ expect(overview.suspectContracts).toBe(1);
+ // mean of .321/.784/.563 — the NULL row never drags this toward 0
+ expect(overview.avgOverall).toBeCloseTo((0.321 + 0.784 + 0.563) / 3, 6);
+ expect(overview.pillars.a).toBeCloseTo((0.2 + 0.8 + 0.5) / 3, 6);
+ });
+
+ it('builds the 20-bin histogram over scored contracts only', async () => {
+ const { overview } = await getQuality(d1, {});
+ const byBin = new Map(overview.histogram.map((b) => [b.bin, b.count]));
+ expect(byBin.get(6)).toBe(1); // .321
+ expect(byBin.get(11)).toBe(1); // .563
+ expect(byBin.get(15)).toBe(1); // .784
+ expect(overview.histogram.reduce((t, b) => t + b.count, 0)).toBe(3);
+ });
+
+ it('tiers the confidence mix and buckets unscored rows as „няма оценка"', async () => {
+ const { overview } = await getQuality(d1, {});
+ expect(overview.confidence).toEqual({ high: 1, medium: 1, low: 1, none: 1 });
+ });
+});
+
+describe('getQuality — ranking', () => {
+ it('ranks authorities weakest-first with slug hrefs, type labels and coverage tiers', async () => {
+ const { ranking } = await getQuality(d1, { grain: 'authority' });
+ expect(ranking.map((r) => r.key)).toEqual(['auth:100000001', 'auth:100000002']);
+ expect(ranking[0]).toMatchObject({
+ href: '/authorities/100000001',
+ name: 'Институция А',
+ sub: 'община',
+ avgOverall: 0.321,
+ coverageTier: 'medium',
+ });
+ expect(ranking[0]!.pillars).toEqual({ a: 0.2, b: 0.4, c: 0.55, d: 0.3, e: 0.84 });
+ });
+
+ it('sorts by volume when asked', async () => {
+ const { ranking } = await getQuality(d1, { grain: 'authority', sort: 'contracts' });
+ expect(ranking.map((r) => r.key)).toEqual(['auth:100000002', 'auth:100000001']);
+ });
+
+ it('labels sectors from the CPV config and drops the NA bucket', async () => {
+ const { ranking } = await getQuality(d1, { grain: 'sector' });
+ expect(ranking.map((r) => r.key)).toEqual(['45', '33']);
+ expect(ranking[0]!.name.startsWith('45 · ')).toBe(true);
+ // sector rollup only carries A and C averages — the others stay null, not 0
+ expect(ranking[0]!.pillars).toEqual({ a: 0.3, b: null, c: 0.5, d: null, e: null });
+ });
+
+ it('serves region, year and funding grains with their labels', async () => {
+ const region = await getQuality(d1, { grain: 'region' });
+ expect(region.ranking.map((r) => r.key)).toEqual(['BG411', 'BG421']);
+ expect(region.ranking[0]!.name).toBe('София (столица)');
+
+ const year = await getQuality(d1, { grain: 'year' });
+ expect(year.ranking.map((r) => r.key)).toEqual(['2024', '2025']);
+
+ const funding = await getQuality(d1, { grain: 'funding' });
+ expect(funding.ranking.map((r) => r.key)).toEqual(['eu', 'national']);
+ expect(funding.ranking[0]!.name).toBe('Европейско финансиране');
+ });
+
+ it('links suppliers to their company pages', async () => {
+ const { ranking } = await getQuality(d1, { grain: 'supplier' });
+ expect(ranking[0]).toMatchObject({ key: 'eik:200000001', href: '/companies/200000001' });
+ });
+});
+
+describe('getQuality — ranking direction', () => {
+ it('flips the score sort to best-first on dir=desc (exact first row both ways)', async () => {
+ const asc = await getQuality(d1, { grain: 'authority' });
+ expect(asc.ranking.map((r) => r.key)).toEqual(['auth:100000001', 'auth:100000002']);
+ expect(asc.scope.sortDir).toBe('asc'); // score defaults to weakest-first
+
+ const desc = await getQuality(d1, { grain: 'authority', dir: 'desc' });
+ expect(desc.ranking.map((r) => r.key)).toEqual(['auth:100000002', 'auth:100000001']);
+ expect(desc.ranking[0]!.avgOverall).toBe(0.69);
+ expect(desc.scope.sortDir).toBe('desc');
+ });
+
+ it('flips the contracts sort to fewest-first on dir=asc', async () => {
+ const desc = await getQuality(d1, { grain: 'authority', sort: 'contracts' });
+ expect(desc.ranking.map((r) => r.key)).toEqual(['auth:100000002', 'auth:100000001']);
+ expect(desc.scope.sortDir).toBe('desc'); // contracts defaults to biggest-first
+
+ const asc = await getQuality(d1, { grain: 'authority', sort: 'contracts', dir: 'asc' });
+ expect(asc.ranking.map((r) => r.key)).toEqual(['auth:100000001', 'auth:100000002']);
+ });
+
+ it('drops a malformed dir at the query boundary — default order, never raw SQL', async () => {
+ const r = await getQuality(d1, { grain: 'authority', dir: 'up; DROP TABLE x' as never });
+ expect(r.ranking.map((x) => x.key)).toEqual(['auth:100000001', 'auth:100000002']);
+ expect(r.scope.sortDir).toBe('asc');
+ });
+});
+
+describe('getQuality — ranking avg-index range (?rfrom/?rto)', () => {
+ // Authority rollup avg_overall: А 0.321 · Б 0.690 (display 32 and 69 on the 0–100 scale).
+ it('narrows the rollup to rows inside [from, to] with exact row counts', async () => {
+ const low = await getQuality(d1, { grain: 'authority', rankFrom: 0, rankTo: 50 });
+ expect(low.ranking.map((r) => r.key)).toEqual(['auth:100000001']);
+
+ const high = await getQuality(d1, { grain: 'authority', rankFrom: 35, rankTo: 100 });
+ expect(high.ranking.map((r) => r.key)).toEqual(['auth:100000002']);
+
+ const all = await getQuality(d1, { grain: 'authority', rankFrom: 0, rankTo: 100 });
+ expect(all.ranking).toHaveLength(2);
+ });
+
+ it('keeps both bounds inclusive — from=to pins rows sitting exactly on the boundary', async () => {
+ const pin = await getQuality(d1, { grain: 'authority', rankFrom: 69, rankTo: 69 });
+ expect(pin.ranking.map((r) => r.key)).toEqual(['auth:100000002']); // avg 0.690 = 69/100
+
+ const empty = await getQuality(d1, { grain: 'authority', rankFrom: 68, rankTo: 68 });
+ expect(empty.ranking).toEqual([]);
+ });
+
+ it('supports one-sided ranges and swaps an inverted pair', async () => {
+ const from = await getQuality(d1, { grain: 'authority', rankFrom: 50 });
+ expect(from.ranking.map((r) => r.key)).toEqual(['auth:100000002']);
+
+ const to = await getQuality(d1, { grain: 'authority', rankTo: 50 });
+ expect(to.ranking.map((r) => r.key)).toEqual(['auth:100000001']);
+
+ const swapped = await getQuality(d1, { grain: 'authority', rankFrom: 50, rankTo: 0 });
+ expect(swapped.ranking.map((r) => r.key)).toEqual(['auth:100000001']);
+ expect(swapped.scope.rankFrom).toBe(0);
+ expect(swapped.scope.rankTo).toBe(50);
+ });
+
+ it('filters the other grains too (year rollup)', async () => {
+ const y = await getQuality(d1, { grain: 'year', rankFrom: 60, rankTo: 100 });
+ expect(y.ranking.map((r) => r.key)).toEqual(['2025']); // avg 0.63; 2024 (0.44) is out
+ });
+
+ it('drops malformed bounds at the query boundary — non-int / out-of-range never reach SQL', async () => {
+ for (const bad of [-5, 101, 3.5, Number.NaN, Number.POSITIVE_INFINITY]) {
+ const r = await getQuality(d1, { grain: 'authority', rankFrom: bad, rankTo: bad });
+ expect(r.scope.rankFrom).toBeNull();
+ expect(r.scope.rankTo).toBeNull();
+ expect(r.ranking).toHaveLength(2); // unfiltered — the bogus bound was dropped, not clamped
+ }
+ const str = await getQuality(d1, { grain: 'authority', rankFrom: '10; --' as never });
+ expect(str.scope.rankFrom).toBeNull();
+ expect(str.ranking).toHaveLength(2);
+ });
+
+ it('composes with sort and direction', async () => {
+ const r = await getQuality(d1, {
+ grain: 'authority',
+ sort: 'contracts',
+ dir: 'asc',
+ rankFrom: 0,
+ rankTo: 50,
+ });
+ expect(r.ranking.map((x) => x.key)).toEqual(['auth:100000001']); // range ∧ fewest-first
+ });
+});
+
+describe('getQuality — contracts list & scoping', () => {
+ it('lists scored contracts weakest-first, unscored value_suspect rows last (never as 0)', async () => {
+ const { contracts } = await getQuality(d1, {});
+ expect(contracts.map((c) => c.id)).toEqual(['c:1', 'c:4', 'c:2', 'c:3']);
+ const suspect = contracts[3]!;
+ expect(suspect.overall).toBeNull();
+ expect(suspect.valueFlag).toBe('value_suspect');
+ expect(suspect.coverageTier).toBe('none');
+ });
+
+ it('sorts by value when asked (NULL-value suspect rows sink)', async () => {
+ const { contracts } = await getQuality(d1, { contractSort: 'value' });
+ expect(contracts.map((c) => c.id)).toEqual(['c:2', 'c:4', 'c:1', 'c:3']);
+ });
+
+ it('scopes the list to a selected authority', async () => {
+ const { contracts } = await getQuality(d1, { grain: 'authority', sel: 'auth:100000001' });
+ expect(contracts.map((c) => c.id)).toEqual(['c:1', 'c:3']);
+ });
+
+ it('scopes by sector, year and funding', async () => {
+ const sector = await getQuality(d1, { grain: 'sector', sel: '33' });
+ expect(sector.contracts.map((c) => c.id)).toEqual(['c:4', 'c:2']);
+
+ const year = await getQuality(d1, { grain: 'year', sel: '2025' });
+ expect(year.contracts.map((c) => c.id)).toEqual(['c:2']);
+
+ const eu = await getQuality(d1, { grain: 'funding', sel: 'eu' });
+ expect(eu.contracts.map((c) => c.id)).toEqual(['c:4']);
+ });
+
+ it('defaults the scorecard to the weakest listed contract', async () => {
+ const { scorecard } = await getQuality(d1, {});
+ expect(scorecard?.id).toBe('c:1');
+ });
+
+ it('keeps scope.contractId null when no ?contract was requested — an auto-picked default must not get baked into preserved links', async () => {
+ const auto = await getQuality(d1, {});
+ expect(auto.scorecard?.id).toBe('c:1'); // auto-picked for display
+ expect(auto.scope.contractId).toBeNull(); // but not echoed back as "the" selection
+
+ const explicit = await getQuality(d1, { contractId: 'c:2' });
+ expect(explicit.scorecard?.id).toBe('c:2');
+ expect(explicit.scope.contractId).toBe('c:2'); // explicit ?contract IS preserved
+ });
+
+ it('score-band filter narrows to the exact histogram bin (bounds match the overview bins)', async () => {
+ // Overall scores: c:1 .321 → bin 6 [.30,.35) · c:4 .563 → bin 11 [.55,.60) · c:2 .784 → bin 15.
+ const bin6 = await getQuality(d1, { band: '6' });
+ expect(bin6.contracts.map((c) => c.id)).toEqual(['c:1']);
+
+ const bin11 = await getQuality(d1, { band: '11' });
+ expect(bin11.contracts.map((c) => c.id)).toEqual(['c:4']);
+
+ // Adjacent empty bin: honest empty set, and the unscored c:3 never leaks into any band.
+ const bin7 = await getQuality(d1, { band: '7' });
+ expect(bin7.contracts).toEqual([]);
+
+ // Top bin closes at 1.0 inclusive (mirrors the `>= 1.0 → 19` histogram clause).
+ const bin19 = await getQuality(d1, { band: '19' });
+ expect(bin19.contracts).toEqual([]);
+ });
+
+ it('named zone bands map to the page zones: weak [0,.5) · mid [.5,.7) · good [.7,1]', async () => {
+ const byBand = async (band: string) =>
+ (await getQuality(d1, { band })).contracts.map((c) => c.id);
+ expect(await byBand('weak')).toEqual(['c:1']); // .321
+ expect(await byBand('mid')).toEqual(['c:4']); // .563
+ expect(await byBand('good')).toEqual(['c:2']); // .784
+ });
+
+ it('band composes with the sel scope (AND) and malformed values never reach SQL', async () => {
+ // sel (authority Б → c:2, c:4) ∧ band=good → only c:2.
+ const scoped = await getQuality(d1, {
+ grain: 'authority',
+ sel: 'auth:100000002',
+ band: 'good',
+ });
+ expect(scoped.contracts.map((c) => c.id)).toEqual(['c:2']);
+ expect(scoped.scope.band).toBe('good');
+
+ // Malformed shapes: out-of-range, negative, fractional, SQL-ish, wrong name — all dropped.
+ for (const band of ['20', '-1', '1.5', '6 OR 1=1', 'strong', '']) {
+ const r = await getQuality(d1, { band });
+ expect(r.scope.band).toBeNull();
+ expect(r.contracts).toHaveLength(4); // unfiltered — the bogus value never reached a WHERE
+ }
+ });
+});
+
+describe('getQualityScorecard', () => {
+ it('reproduces the ETL blend and maps the raw leaves', async () => {
+ const card = await getQualityScorecard(d1, 'c:1');
+ expect(card).not.toBeNull();
+ expect(card!.known).toBe(true);
+ expect(card!.overall).toBe(0.321);
+ expect(card!.wmean).toBeCloseTo(0.4015, 4);
+ expect(card!.worst).toBe(0.2);
+ expect(card!.worstPillar).toBe('a');
+ expect(0.6 * card!.wmean! + 0.4 * card!.worst!).toBeCloseTo(card!.overall!, 3);
+ expect(card!.leaves).toMatchObject({
+ bidsReceived: 1,
+ singleOffer: true,
+ isEauction: false,
+ procedureType: 'Открита процедура',
+ annexCount: 2,
+ costOverrunRatio: 1.4,
+ authorityHhi: 0.74,
+ repeatWinIntensity: 0.71,
+ edgeAgeYears: 9.0,
+ });
+ expect(card!.coverageFlags).toEqual({ bids: true, sme: false, estimate: true, overrun: true });
+ expect(card!.cpvDivision).toBe('45');
+ expect(card!.authoritySlug).toBe('100000001');
+ expect(card!.slug).toBe('1'); // /contracts/1
+ });
+
+ it('returns the unknown card for a value_suspect contract — unscored, not zero', async () => {
+ const card = await getQualityScorecard(d1, 'c:3');
+ expect(card!.known).toBe(false);
+ expect(card!.overall).toBeNull();
+ expect(card!.wmean).toBeNull();
+ expect(card!.worstPillar).toBeNull();
+ expect(card!.valueFlag).toBe('value_suspect');
+ });
+
+ it('returns null for an unknown contract id', async () => {
+ expect(await getQualityScorecard(d1, 'c:missing')).toBeNull();
+ });
+});
+
+describe('getQualitySummary', () => {
+ it('rolls up the hub-card numbers', async () => {
+ const s = await getQualitySummary(d1);
+ expect(s.totalContracts).toBe(4);
+ expect(s.scoredContracts).toBe(3);
+ expect(s.avgOverall).toBeCloseTo(0.556, 3);
+ });
+});
diff --git a/packages/db/src/queries/quality.ts b/packages/db/src/queries/quality.ts
new file mode 100644
index 000000000..d7b6b37a4
--- /dev/null
+++ b/packages/db/src/queries/quality.ts
@@ -0,0 +1,606 @@
+// Quality index: read-only queries over the Contract Quality / Health Index tables the ETL builds
+// (scripts/derive-contract-features.sql — contract_features + the six *_quality_totals rollups).
+// Scores are [0,1] REALs (spec §12.0); NULL means "insufficient data" and is NEVER coerced to 0 —
+// unscored (value_suspect / coverage < 0.40) contracts are excluded from every average upstream, and
+// this layer only reads what the ETL wrote. In the site's neutrality stance (§1.3, mirrors
+// competition.ts): a low score is a weak-quality SIGNAL, not proof of wrongdoing.
+
+import type {
+ QualityContractRow,
+ QualityContractSort,
+ QualityCoverageTier,
+ QualityData,
+ QualityGrain,
+ QualityLeaves,
+ QualityOverview,
+ QualityPillars,
+ QualityRankDir,
+ QualityRankRow,
+ QualityRankSort,
+ QualityScorecard,
+ QualitySummary,
+} from '@sigma/api-contract';
+import { CPV_SECTORS } from '@sigma/config';
+import { cleanName, entityName } from '@sigma/shared';
+import { authoritySlug, companySlug, contractSlug } from './identity';
+import { typeLabel } from './rows';
+
+export interface QualityParams {
+ grain?: QualityGrain;
+ sort?: QualityRankSort;
+ dir?: QualityRankDir | null; // ranking direction; defaulted per sort key (see qualityRankDefaultDir)
+ contractSort?: QualityContractSort;
+ sel?: string | null; // selected ranking key → scopes the contracts list
+ contractId?: string | null; // scorecard subject; defaults to the weakest listed contract
+ band?: string | null; // histogram score-band filter over the contracts list (validated here)
+ top?: number;
+ rankFrom?: number | null; // „Разбивка" avg-index range, display-scale ints 0–100 (validated here)
+ rankTo?: number | null;
+}
+
+const DEFAULT_TOP = 20;
+const MAX_TOP = 50;
+const CONTRACT_LIMIT = 12;
+// Authority/supplier rows need a minimal scored sample before an average is meaningful (same
+// small-sample guard as competition's minContracts). Sector/region/year/funding are corpus-wide cuts.
+const MIN_SCORED = 20;
+
+/**
+ * Default ranking direction per sort key — the page's historical reading order: score lists the
+ * weakest rows first (asc), contracts lists the biggest samples first (desc). ?rdir flips it.
+ */
+export function qualityRankDefaultDir(sort: QualityRankSort): QualityRankDir {
+ return sort === 'contracts' ? 'desc' : 'asc';
+}
+
+/** Pillar weights (spec §3.2); the ETL renormalizes over non-NULL pillars, we mirror that here. */
+export const QUALITY_WEIGHTS: Record = {
+ a: 0.3,
+ b: 0.15,
+ c: 0.25,
+ d: 0.2,
+ e: 0.1,
+};
+
+/** §6.2 confidence label over score_coverage. 'none' = withheld („недостатъчно данни"). */
+export function coverageTier(coverage: number | null | undefined): QualityCoverageTier {
+ if (coverage == null || coverage < 0.4) return 'none';
+ if (coverage >= 0.8) return 'high';
+ if (coverage >= 0.6) return 'medium';
+ return 'low';
+}
+
+interface OverviewRow {
+ total: number;
+ scored: number;
+ suspect: number;
+ avg_overall: number | null;
+ mean_coverage: number | null;
+ avg_a: number | null;
+ avg_b: number | null;
+ avg_c: number | null;
+ avg_d: number | null;
+ avg_e: number | null;
+ conf_high: number;
+ conf_medium: number;
+ conf_low: number;
+ conf_none: number;
+}
+
+async function qualityOverview(db: D1Database): Promise {
+ const [row, bins] = await Promise.all([
+ db
+ .prepare(
+ // AVG ignores NULLs, so every mean is over the rows that actually carry that score — an
+ // unscored contract never drags an average toward zero (§1.3).
+ `SELECT COUNT(*) AS total,
+ SUM(CASE WHEN score_overall IS NOT NULL THEN 1 ELSE 0 END) AS scored,
+ SUM(CASE WHEN value_flag = 'value_suspect' THEN 1 ELSE 0 END) AS suspect,
+ AVG(score_overall) AS avg_overall,
+ AVG(score_coverage) AS mean_coverage,
+ AVG(score_a) AS avg_a, AVG(score_b) AS avg_b, AVG(score_c) AS avg_c,
+ AVG(score_d) AS avg_d, AVG(score_e) AS avg_e,
+ SUM(CASE WHEN score_overall IS NOT NULL AND score_coverage >= 0.8 THEN 1 ELSE 0 END) AS conf_high,
+ SUM(CASE WHEN score_overall IS NOT NULL AND score_coverage >= 0.6 AND score_coverage < 0.8 THEN 1 ELSE 0 END) AS conf_medium,
+ SUM(CASE WHEN score_overall IS NOT NULL AND score_coverage < 0.6 THEN 1 ELSE 0 END) AS conf_low,
+ SUM(CASE WHEN score_overall IS NULL THEN 1 ELSE 0 END) AS conf_none
+ FROM contract_features`,
+ )
+ .first(),
+ db
+ .prepare(
+ // 20 equal bins over [0,1]; a perfect 1.0 lands in the top bin instead of a phantom 21st.
+ `SELECT CASE WHEN score_overall >= 1.0 THEN 19 ELSE CAST(score_overall * 20 AS INTEGER) END AS bin,
+ COUNT(*) AS count
+ FROM contract_features WHERE score_overall IS NOT NULL
+ GROUP BY bin ORDER BY bin`,
+ )
+ .all<{ bin: number; count: number }>(),
+ ]);
+ return {
+ totalContracts: row?.total ?? 0,
+ scoredContracts: row?.scored ?? 0,
+ suspectContracts: row?.suspect ?? 0,
+ avgOverall: row?.avg_overall ?? null,
+ meanCoverage: row?.mean_coverage ?? null,
+ pillars: {
+ a: row?.avg_a ?? null,
+ b: row?.avg_b ?? null,
+ c: row?.avg_c ?? null,
+ d: row?.avg_d ?? null,
+ e: row?.avg_e ?? null,
+ },
+ histogram: bins.results,
+ confidence: {
+ high: row?.conf_high ?? 0,
+ medium: row?.conf_medium ?? 0,
+ low: row?.conf_low ?? 0,
+ none: row?.conf_none ?? 0,
+ },
+ };
+}
+
+interface RankRow {
+ key: string;
+ name: string | null;
+ sub: string | null;
+ avg_overall: number;
+ avg_a: number | null;
+ avg_b: number | null;
+ avg_c: number | null;
+ avg_d: number | null;
+ avg_e: number | null;
+ total_contracts: number;
+ scored_contracts: number;
+ mean_coverage: number | null;
+}
+
+const FUNDING_LABELS: Record = {
+ eu: 'Европейско финансиране',
+ national: 'Национално финансиране',
+};
+
+// One SELECT per grain over its *_quality_totals rollup. Each SELECT projects the same column list
+// (missing pillar averages as NULL — e.g. the sector rollup only stores avg_a/avg_c), so the mapper
+// below is grain-agnostic. Weakest-first is the page's default reading order.
+function rankSql(grain: QualityGrain): string {
+ const cols = (a: string, b: string, c: string, d: string, e: string) =>
+ `${a} AS avg_a, ${b} AS avg_b, ${c} AS avg_c, ${d} AS avg_d, ${e} AS avg_e`;
+ switch (grain) {
+ case 'authority':
+ return `SELECT authority_id AS key, name, type_group AS sub, avg_overall,
+ ${cols('avg_a', 'avg_b', 'avg_c', 'avg_d', 'avg_e')},
+ total_contracts, scored_contracts, mean_coverage
+ FROM authority_quality_totals
+ WHERE avg_overall IS NOT NULL AND scored_contracts >= ?`;
+ case 'supplier':
+ return `SELECT bidder_id AS key, name, NULL AS sub, avg_overall,
+ ${cols('NULL', 'NULL', 'avg_c', 'avg_d', 'NULL')},
+ total_contracts, scored_contracts, mean_coverage
+ FROM bidder_quality_totals
+ WHERE avg_overall IS NOT NULL AND scored_contracts >= ?`;
+ case 'sector':
+ return `SELECT division AS key, NULL AS name, NULL AS sub, avg_overall,
+ ${cols('avg_a', 'NULL', 'avg_c', 'NULL', 'NULL')},
+ total_contracts, scored_contracts, mean_coverage
+ FROM sector_quality_totals
+ WHERE avg_overall IS NOT NULL AND division <> 'NA' AND scored_contracts >= ?`;
+ case 'region':
+ return `SELECT nuts AS key, nuts_label AS name, nuts AS sub, avg_overall,
+ ${cols('NULL', 'NULL', 'NULL', 'NULL', 'NULL')},
+ total_contracts, scored_contracts, mean_coverage
+ FROM region_quality_totals
+ WHERE avg_overall IS NOT NULL AND nuts <> 'NA' AND scored_contracts >= ?`;
+ case 'year':
+ return `SELECT year AS key, year AS name, NULL AS sub, avg_overall,
+ ${cols('avg_a', 'avg_b', 'avg_c', 'avg_d', 'avg_e')},
+ total_contracts, scored_contracts, mean_coverage
+ FROM year_quality_totals
+ WHERE avg_overall IS NOT NULL AND year <> 'NA' AND scored_contracts >= ?`;
+ case 'funding':
+ return `SELECT funding_key AS key, NULL AS name, NULL AS sub, avg_overall,
+ ${cols('NULL', 'NULL', 'NULL', 'NULL', 'NULL')},
+ total_contracts, scored_contracts, mean_coverage
+ FROM funding_quality_totals
+ WHERE avg_overall IS NOT NULL AND scored_contracts >= ?`;
+ default:
+ throw new Error(`rankSql: unhandled grain ${grain satisfies never}`);
+ }
+}
+
+async function qualityRanking(
+ db: D1Database,
+ grain: QualityGrain,
+ sort: QualityRankSort,
+ dir: QualityRankDir,
+ top: number,
+ minScored: number,
+ range: { from: number | null; to: number | null }, // avg_overall bounds in [0,1], both inclusive
+): Promise {
+ // Direction is an allow-listed literal ('asc'|'desc' validated in getQuality) — never raw input.
+ const d = dir === 'desc' ? 'DESC' : 'ASC';
+ const order =
+ sort === 'contracts'
+ ? `ORDER BY total_contracts ${d}, avg_overall ASC, key`
+ : // ties break toward the larger sample (the more telling case)
+ `ORDER BY avg_overall ${d}, total_contracts DESC, key`;
+ // Avg-index range filter (?rfrom/?rto, already divided from the 0–100 display scale): bound
+ // params appended after the grain SQL's minScored placeholder. Both bounds are inclusive, so a
+ // from=to pin keeps rows sitting exactly on the boundary.
+ const rangeWhere =
+ (range.from != null ? ' AND avg_overall >= ?' : '') +
+ (range.to != null ? ' AND avg_overall <= ?' : '');
+ const rangeParams = [range.from, range.to].filter((v): v is number => v != null);
+ const { results } = await db
+ .prepare(`${rankSql(grain)}${rangeWhere} ${order} LIMIT ?`)
+ .bind(minScored, ...rangeParams, top)
+ .all();
+ const sectorByCode = new Map(CPV_SECTORS.map((s) => [s.code, s.short ?? s.label]));
+ return results.map((r) => {
+ let href: string | null = null;
+ let name = r.name ?? r.key;
+ let sub = r.sub;
+ if (grain === 'authority') {
+ href = `/authorities/${authoritySlug(r.key)}`;
+ name = cleanName(name);
+ sub = typeLabel(sub);
+ } else if (grain === 'supplier') {
+ href = `/companies/${companySlug(r.key)}`;
+ name = cleanName(name);
+ sub = 'доставчик';
+ } else if (grain === 'sector') {
+ name = `${r.key} · ${sectorByCode.get(r.key) ?? 'CPV дивизия'}`;
+ sub = 'CPV дивизия';
+ } else if (grain === 'region') {
+ name = r.name ?? r.key;
+ } else if (grain === 'year') {
+ sub = 'период';
+ } else if (grain === 'funding') {
+ name = FUNDING_LABELS[r.key] ?? r.key;
+ }
+ return {
+ key: r.key,
+ href,
+ name,
+ sub,
+ avgOverall: r.avg_overall,
+ pillars: { a: r.avg_a, b: r.avg_b, c: r.avg_c, d: r.avg_d, e: r.avg_e },
+ totalContracts: r.total_contracts,
+ scoredContracts: r.scored_contracts,
+ meanCoverage: r.mean_coverage,
+ coverageTier: coverageTier(r.mean_coverage),
+ };
+ });
+}
+
+// Contracts-list / scorecard scope: a selected ranking row narrows the list to its contracts. The
+// key shapes match what the ETL grouped by in the corresponding *_quality_totals build.
+function contractScope(
+ grain: QualityGrain,
+ sel: string | null,
+): { where: string; params: unknown[] } {
+ if (!sel) return { where: '', params: [] };
+ switch (grain) {
+ case 'authority':
+ return { where: 'AND t.authority_id = ?', params: [sel] };
+ case 'supplier':
+ return { where: 'AND c.bidder_id = ?', params: [sel] };
+ case 'sector':
+ return { where: 'AND substr(t.cpv_code, 1, 2) = ?', params: [sel] };
+ case 'region':
+ // Invariant confirmed against scripts/derive-contract-features.sql: region_quality_totals.nuts
+ // is built as `COALESCE(t.place_of_performance, 'NA')` — the same raw column filtered here, so
+ // a ranking row's `nuts` key always matches contracts by exact `t.place_of_performance` equality.
+ return { where: 'AND t.place_of_performance = ?', params: [sel] };
+ case 'year':
+ return { where: 'AND substr(c.signed_at, 1, 4) = ?', params: [sel] };
+ case 'funding':
+ return sel === 'eu'
+ ? { where: 'AND c.eu_funded = 1', params: [] }
+ : { where: 'AND (c.eu_funded IS NULL OR c.eu_funded = 0)', params: [] };
+ default:
+ throw new Error(`contractScope: unhandled grain ${grain satisfies never}`);
+ }
+}
+
+interface ContractRowRaw {
+ id: string;
+ signed_at: string | null;
+ cpv_code: string | null;
+ authority_id: string;
+ authority_name: string;
+ bidder_id: string;
+ bidder_name: string;
+ bidder_kind: 'company' | 'consortium';
+ amount_eur: number | null;
+ score_overall: number | null;
+ score_a: number | null;
+ score_b: number | null;
+ score_c: number | null;
+ score_d: number | null;
+ score_e: number | null;
+ score_coverage: number | null;
+ value_flag: string | null;
+}
+
+function mapContractRow(r: ContractRowRaw): QualityContractRow {
+ const bidderName = cleanName(r.bidder_name);
+ return {
+ id: r.id,
+ slug: contractSlug(r.id),
+ signedAt: r.signed_at,
+ cpvDivision: r.cpv_code && r.cpv_code.trim().length >= 2 ? r.cpv_code.trim().slice(0, 2) : null,
+ authorityName: cleanName(r.authority_name),
+ authoritySlug: authoritySlug(r.authority_id),
+ bidderDisplayName: entityName(bidderName, r.bidder_kind),
+ bidderSlug: companySlug(r.bidder_id),
+ amountEur: r.amount_eur,
+ overall: r.score_overall,
+ pillars: { a: r.score_a, b: r.score_b, c: r.score_c, d: r.score_d, e: r.score_e },
+ coverage: r.score_coverage,
+ coverageTier: coverageTier(r.score_coverage),
+ valueFlag: r.value_flag,
+ };
+}
+
+const CONTRACT_SELECT = `
+ SELECT c.id, c.signed_at, t.cpv_code, t.authority_id, a.name AS authority_name,
+ c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind, c.amount_eur,
+ f.score_overall, f.score_a, f.score_b, f.score_c, f.score_d, f.score_e,
+ f.score_coverage, f.value_flag
+ FROM contract_features f
+ JOIN contracts c ON c.id = f.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ JOIN authorities a ON a.id = t.authority_id
+ JOIN bidders b ON b.id = c.bidder_id`;
+
+/**
+ * Histogram score-band filter → [lo, hi) over score_overall. Bin index '0'–'19' maps to the exact
+ * 5-point bins the overview histogram is built from (bin 19 closes at 1.0 inclusive, mirroring the
+ * `score >= 1.0 → 19` clause in qualityOverview); 'weak'|'mid'|'good' map to the page's zone bands.
+ * Returns null for anything else — an unknown value must never reach SQL.
+ */
+export function qualityBandRange(band: string): { lo: number; hi: number | null } | null {
+ if (/^(?:[0-9]|1[0-9])$/.test(band)) {
+ const bin = Number(band);
+ return { lo: bin / 20, hi: bin === 19 ? null : (bin + 1) / 20 };
+ }
+ if (band === 'weak') return { lo: 0, hi: 0.5 };
+ if (band === 'mid') return { lo: 0.5, hi: 0.7 };
+ if (band === 'good') return { lo: 0.7, hi: null };
+ return null;
+}
+
+async function qualityContracts(
+ db: D1Database,
+ grain: QualityGrain,
+ sel: string | null,
+ sort: QualityContractSort,
+ band: string | null,
+): Promise {
+ const scope = contractScope(grain, sel);
+ // NULL score_overall never satisfies >= — unscored contracts stay outside every band, not at 0.
+ const range = band ? qualityBandRange(band) : null;
+ const bandWhere = range
+ ? `AND f.score_overall >= ?${range.hi != null ? ' AND f.score_overall < ?' : ''}`
+ : '';
+ const bandParams = range ? (range.hi != null ? [range.lo, range.hi] : [range.lo]) : [];
+ // Scored contracts lead (weakest first); unscored value_suspect rows are still listed — after the
+ // scored ones — so exclusion is visible, not silent. Coverage-withheld rows stay off this list.
+ const order =
+ sort === 'value'
+ ? 'ORDER BY c.amount_eur DESC, c.id'
+ : 'ORDER BY (f.score_overall IS NULL), f.score_overall ASC, c.amount_eur DESC, c.id';
+ const { results } = await db
+ .prepare(
+ `${CONTRACT_SELECT}
+ WHERE (f.score_overall IS NOT NULL OR f.value_flag = 'value_suspect') ${scope.where} ${bandWhere}
+ ${order} LIMIT ?`,
+ )
+ .bind(...scope.params, ...bandParams, CONTRACT_LIMIT)
+ .all();
+ return results.map(mapContractRow);
+}
+
+interface ScorecardRowRaw extends ContractRowRaw {
+ procedure_type: string | null;
+ bids_received: number | null;
+ single_offer: number | null;
+ sme_rate: number | null;
+ is_eauction: number | null;
+ is_accelerated: number | null;
+ bid_window_days: number | null;
+ annex_count: number | null;
+ cost_overrun_ratio: number | null;
+ estimate_dev_ratio: number | null;
+ first_amend_shock: number | null;
+ authority_hhi: number | null;
+ repeat_win_intensity: number | null;
+ edge_age_years: number | null;
+ sector_win_share: number | null;
+ date_flag: string | null;
+ subcontract_passthrough: number | null;
+ duration_days: number | null;
+ corrections_count: number | null;
+ coverage_bids: number | null;
+ coverage_sme: number | null;
+ coverage_estimate: number | null;
+ coverage_overrun: number | null;
+}
+
+const bool = (v: number | null): boolean | null => (v == null ? null : v === 1);
+
+/** Mirror of the ETL blend (§3.3/§12.0): weights renormalized over non-NULL pillars. */
+export function qualityBlend(pillars: QualityPillars): {
+ wmean: number | null;
+ worst: number | null;
+ worstPillar: keyof QualityPillars | null;
+ effectiveWeights: QualityPillars;
+} {
+ const keys = Object.keys(QUALITY_WEIGHTS) as (keyof QualityPillars)[];
+ const present = keys.filter((k) => pillars[k] != null);
+ const effectiveWeights: QualityPillars = { a: null, b: null, c: null, d: null, e: null };
+ if (present.length === 0)
+ return { wmean: null, worst: null, worstPillar: null, effectiveWeights };
+ const wsum = present.reduce((t, k) => t + QUALITY_WEIGHTS[k], 0);
+ let wmean = 0;
+ let worst: number | null = null;
+ let worstPillar: keyof QualityPillars | null = null;
+ for (const k of present) {
+ const w = QUALITY_WEIGHTS[k] / wsum;
+ effectiveWeights[k] = w;
+ const s = pillars[k] as number;
+ wmean += w * s;
+ if (worst == null || s < worst) {
+ worst = s;
+ worstPillar = k;
+ }
+ }
+ return { wmean, worst, worstPillar, effectiveWeights };
+}
+
+export async function getQualityScorecard(
+ db: D1Database,
+ contractId: string,
+): Promise {
+ const row = await db
+ .prepare(
+ `SELECT c.id, c.signed_at, t.cpv_code, t.authority_id, a.name AS authority_name,
+ c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind, c.amount_eur,
+ f.score_overall, f.score_a, f.score_b, f.score_c, f.score_d, f.score_e,
+ f.score_coverage, f.value_flag,
+ t.procedure_type, f.bids_received, f.single_offer, f.sme_rate, f.is_eauction,
+ f.is_accelerated, f.bid_window_days, f.annex_count, f.cost_overrun_ratio,
+ f.estimate_dev_ratio, f.first_amend_shock, f.authority_hhi, f.repeat_win_intensity,
+ f.edge_age_years, f.sector_win_share, f.date_flag, f.subcontract_passthrough,
+ f.duration_days, f.corrections_count,
+ f.coverage_bids, f.coverage_sme, f.coverage_estimate, f.coverage_overrun
+ FROM contract_features f
+ JOIN contracts c ON c.id = f.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ JOIN authorities a ON a.id = t.authority_id
+ JOIN bidders b ON b.id = c.bidder_id
+ WHERE f.contract_id = ?`,
+ )
+ .bind(contractId)
+ .first();
+ if (!row) return null;
+ const base = mapContractRow(row);
+ const blend = qualityBlend(base.pillars);
+ const leaves: QualityLeaves = {
+ bidsReceived: row.bids_received,
+ singleOffer: bool(row.single_offer),
+ smeRate: row.sme_rate,
+ isEauction: bool(row.is_eauction),
+ procedureType: row.procedure_type === 'неизвестна' ? null : row.procedure_type,
+ isAccelerated: bool(row.is_accelerated),
+ bidWindowDays: row.bid_window_days,
+ annexCount: row.annex_count,
+ costOverrunRatio: row.cost_overrun_ratio,
+ estimateDevRatio: row.estimate_dev_ratio,
+ firstAmendShock: bool(row.first_amend_shock),
+ authorityHhi: row.authority_hhi,
+ repeatWinIntensity: row.repeat_win_intensity,
+ edgeAgeYears: row.edge_age_years,
+ sectorWinShare: row.sector_win_share,
+ dateFlag: row.date_flag,
+ subcontractPassthrough: row.subcontract_passthrough,
+ durationDays: row.duration_days,
+ correctionsCount: row.corrections_count,
+ };
+ return {
+ ...base,
+ known: base.overall != null,
+ ...blend,
+ leaves,
+ coverageFlags: {
+ bids: row.coverage_bids === 1,
+ sme: row.coverage_sme === 1,
+ estimate: row.coverage_estimate === 1,
+ overrun: row.coverage_overrun === 1,
+ },
+ };
+}
+
+const GRAINS: QualityGrain[] = ['authority', 'supplier', 'sector', 'region', 'year', 'funding'];
+
+export async function getQuality(db: D1Database, p: QualityParams = {}): Promise {
+ const grain: QualityGrain = p.grain && GRAINS.includes(p.grain) ? p.grain : 'authority';
+ const sort: QualityRankSort = p.sort === 'contracts' ? 'contracts' : 'score';
+ // Direction: strict allow-list, anything else falls back to the sort key's default order.
+ const sortDir: QualityRankDir =
+ p.dir === 'asc' || p.dir === 'desc' ? p.dir : qualityRankDefaultDir(sort);
+ // Avg-index range: display-scale ints 0–100 only (divided to [0,1] at the SQL boundary below);
+ // a malformed bound is dropped, an inverted pair is swapped — never passed into SQL as-is.
+ const rankBound = (v: number | null | undefined): number | null =>
+ typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 100 ? v : null;
+ let rankFrom = rankBound(p.rankFrom);
+ let rankTo = rankBound(p.rankTo);
+ if (rankFrom != null && rankTo != null && rankFrom > rankTo)
+ [rankFrom, rankTo] = [rankTo, rankFrom];
+ const contractSort: QualityContractSort = p.contractSort === 'value' ? 'value' : 'score';
+ const sel = p.sel ?? null;
+ // Validate at the query boundary (the route validates too, but this module must not trust its
+ // callers): a bogus band is dropped, never passed into SQL.
+ const band = p.band && qualityBandRange(p.band) ? p.band : null;
+ const top = p.top && p.top > 0 ? Math.min(Math.floor(p.top), MAX_TOP) : DEFAULT_TOP;
+ const minScored = grain === 'authority' || grain === 'supplier' ? MIN_SCORED : 1;
+ const [overview, ranking, contracts] = await Promise.all([
+ qualityOverview(db),
+ qualityRanking(db, grain, sort, sortDir, top, minScored, {
+ from: rankFrom != null ? rankFrom / 100 : null,
+ to: rankTo != null ? rankTo / 100 : null,
+ }),
+ qualityContracts(db, grain, sel, contractSort, band),
+ ]);
+ // p.contractId is the explicit ?contract request; scorecardId also falls back to the weakest
+ // listed contract so a card always renders. Those must stay distinct in `scope`: only the explicit
+ // request is echoed back for links to preserve (scope.contractId), or the auto-picked default
+ // would get "baked into" the URL on the very first navigation and pin every later view to it.
+ const explicitContractId = p.contractId ?? null;
+ const scorecardId = explicitContractId ?? contracts[0]?.id ?? null;
+ const scorecard = scorecardId ? await getQualityScorecard(db, scorecardId) : null;
+ return {
+ overview,
+ ranking,
+ contracts,
+ scorecard,
+ scope: {
+ grain,
+ sort,
+ sortDir,
+ contractSort,
+ sel,
+ band,
+ contractId: explicitContractId,
+ rankFrom,
+ rankTo,
+ top,
+ minScored,
+ },
+ };
+}
+
+/** Lightweight rollup for the /analytics hub card. */
+export async function getQualitySummary(db: D1Database): Promise {
+ const row = await db
+ .prepare(
+ `SELECT COUNT(*) AS total,
+ SUM(CASE WHEN score_overall IS NOT NULL THEN 1 ELSE 0 END) AS scored,
+ AVG(score_overall) AS avg_overall,
+ AVG(score_coverage) AS mean_coverage
+ FROM contract_features`,
+ )
+ .first<{
+ total: number;
+ scored: number;
+ avg_overall: number | null;
+ mean_coverage: number | null;
+ }>();
+ return {
+ totalContracts: row?.total ?? 0,
+ scoredContracts: row?.scored ?? 0,
+ avgOverall: row?.avg_overall ?? null,
+ meanCoverage: row?.mean_coverage ?? null,
+ };
+}
diff --git a/packages/db/src/queries/trend.test.ts b/packages/db/src/queries/trend.test.ts
index 4a92e0b77..2b42a1d5a 100644
--- a/packages/db/src/queries/trend.test.ts
+++ b/packages/db/src/queries/trend.test.ts
@@ -1,5 +1,10 @@
-import { describe, expect, it } from 'vitest';
-import { getSpendingTrend } from './trend';
+import { describe, expect, it, vi } from 'vitest';
+import {
+ getCpvGroupMedians,
+ getCpvGroupStats,
+ getSpendingTrend,
+ listOverviewContracts,
+} from './trend';
// Fake D1 keyed by call type (same approach as competition.test.ts / regions.test.ts). Verifies the
// JS-side shaping: zero-filling gaps in the period series, the per-year summary with year-over-year
@@ -153,6 +158,36 @@ describe('getSpendingTrend', () => {
expect(series.args).toEqual(['2020-01-01', 'auth:111']);
});
+ it('folds monthly rows into a continuous quarterly series (queried at month grain)', async () => {
+ const sqls: string[] = [];
+ const { points, granularity } = await getSpendingTrend(fakeDb(sqls), {
+ granularity: 'quarter',
+ });
+ // Quarters come from the monthly substr, not a SQL quarter expression.
+ expect(sqls.some((s) => s.includes('substr(c.signed_at, 1, 7)'))).toBe(true);
+ expect(granularity).toBe('quarter');
+ expect(points.map((p) => p.period)).toEqual([
+ '2022-Q1',
+ '2022-Q2',
+ '2022-Q3',
+ '2022-Q4',
+ '2023-Q1',
+ ]);
+ // 2022-01 + 2022-03 land in the same quarter; the gap quarters are zero-filled.
+ expect(points[0]).toMatchObject({ valueEur: 4000, contracts: 40 });
+ expect(points[1]).toMatchObject({ valueEur: 0, contracts: 0 });
+ expect(points.at(-1)).toMatchObject({ valueEur: 5000, contracts: 50 });
+ });
+
+ it('marks the as_of quarter partial', async () => {
+ const { points, years } = await getSpendingTrend(fakeDb(undefined, '2023-01-15'), {
+ granularity: 'quarter',
+ });
+ expect(points.at(-1)).toMatchObject({ period: '2023-Q1', partial: true });
+ expect(points.find((p) => p.period === '2022-Q1')).toMatchObject({ partial: false });
+ expect(years.find((y) => y.year === '2023')).toMatchObject({ partial: true, yoyPct: null });
+ });
+
it('scopes the trend by bidderId through the contract bidder', async () => {
const national = await getSpendingTrend(scopedFakeDb([]), { granularity: 'year' });
const calls: QueryCall[] = [];
@@ -174,3 +209,217 @@ describe('getSpendingTrend', () => {
expect(series.args).toEqual(['2020-01-01', 'eik:222']);
});
});
+
+// ── Contracts overview queries ───────────────────────────────────────────────────────────────────
+
+// Fake D1 that routes each prepared statement by SQL shape and records { sql, args } for assertions.
+function overviewDb(handlers: {
+ all?: (sql: string, args: unknown[]) => unknown[];
+ first?: (sql: string, args: unknown[]) => unknown;
+ calls?: QueryCall[];
+}): D1Database {
+ return {
+ prepare(sql: string) {
+ return {
+ args: [] as unknown[],
+ bind(...args: unknown[]) {
+ this.args = args;
+ handlers.calls?.push({ sql, args });
+ return this;
+ },
+ async all() {
+ return { results: (handlers.all?.(sql, this.args) ?? []) as T[] };
+ },
+ async first() {
+ return (handlers.first?.(sql, this.args) ?? null) as T;
+ },
+ };
+ },
+ } as unknown as D1Database;
+}
+
+describe('getCpvGroupStats', () => {
+ // cnt=101 → floor-rank percentiles: p10 at rn 11, median at rn 51, p90 at rn 91 (matches the SQL's
+ // integer division). The rows below stand in for the quantile ladder the query returns.
+ const DIST_33600 = [
+ { v: 100, name: 'Фармацевтични продукти', rn: 1, cnt: 101 },
+ { v: 1000, name: 'Фармацевтични продукти', rn: 11, cnt: 101 },
+ { v: 38000, name: 'Фармацевтични продукти', rn: 51, cnt: 101 },
+ { v: 200000, name: 'Медицински консумативи', rn: 91, cnt: 101 },
+ { v: 900000, name: null, rn: 101, cnt: 101 },
+ ];
+ const DIST_45000 = [{ v: 5000, name: 'Строителни работи', rn: 1, cnt: 1 }];
+
+ function db(calls: QueryCall[]): D1Database {
+ return overviewDb({
+ calls,
+ all(sql, args) {
+ if (sql.includes('GROUP BY grp')) {
+ return [
+ { grp: '33600', contracts: 101 },
+ { grp: '45000', contracts: 1 },
+ ];
+ }
+ if (args[0] === '33600') return DIST_33600;
+ if (args[0] === '45000') return DIST_45000;
+ return [];
+ },
+ first(sql) {
+ if (sql.includes('COUNT(DISTINCT')) return { n: 2045 };
+ return null;
+ },
+ });
+ }
+
+ it('returns top groups with exact floor-rank percentiles from one bounded pass per group', async () => {
+ const { groups, totalGroups } = await getCpvGroupStats(db([]), 2);
+ expect(totalGroups).toBe(2045);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]).toMatchObject({
+ group: '33600',
+ contracts: 101,
+ p10Eur: 1000,
+ medianEur: 38000,
+ p90Eur: 200000,
+ maxEur: 900000,
+ name: 'Фармацевтични продукти', // most common description among the sample
+ });
+ expect(groups[0]!.sampleEur).toEqual([100, 1000, 38000, 200000, 900000]);
+ // A single-contract group degenerates to that one value everywhere.
+ expect(groups[1]).toMatchObject({
+ group: '45000',
+ p10Eur: 5000,
+ medianEur: 5000,
+ p90Eur: 5000,
+ });
+ });
+
+ it('scans each group through a half-open cpv_code prefix range (indexable)', async () => {
+ const calls: QueryCall[] = [];
+ await getCpvGroupStats(db(calls), 2);
+ const dist = calls.filter((c) => c.sql.includes('ROW_NUMBER() OVER'));
+ expect(dist.map((c) => c.args)).toEqual([
+ ['33600', '33601'],
+ ['45000', '45001'],
+ ]);
+ expect(dist[0]!.sql).toContain('t.cpv_code >= ? AND t.cpv_code < ?');
+ // The distribution query never sorts by anything unindexed and returns only picked ranks.
+ expect(dist[0]!.sql).toContain('rn = (cnt - 1) * 5 / 10 + 1');
+ });
+
+ it('logs and falls back to the sample minimum when an expected rank is missing from the sample', async () => {
+ // A group whose sample is missing rank 1 (a stand-in for a future GROUP_DIST_SQL regression
+ // dropping an expected rank) must still return a value — the minimum in the sample — but must
+ // not fail silently.
+ const brokenDb = overviewDb({
+ calls: [],
+ all(sql, args) {
+ if (sql.includes('GROUP BY grp')) return [{ grp: '45000', contracts: 1 }];
+ if (args[0] === '45000') return [{ v: 5000, name: 'x', rn: 2, cnt: 1 }];
+ return [];
+ },
+ first: () => ({ n: 1 }),
+ });
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { groups } = await getCpvGroupStats(brokenDb, 1);
+ expect(groups[0]).toMatchObject({ medianEur: 5000, p10Eur: 5000, p90Eur: 5000 });
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('rank 1 missing'));
+ errSpy.mockRestore();
+ });
+});
+
+describe('getCpvGroupMedians', () => {
+ it('returns the lower median per group, dedupes and drops malformed groups', async () => {
+ const calls: QueryCall[] = [];
+ const db = overviewDb({
+ calls,
+ first(sql, args) {
+ if (!sql.includes('rn = (cnt - 1) * 5 / 10 + 1')) return null;
+ if (args[0] === '22112') return { v: 6300, name: ' Училищни учебници ', cnt: 10 };
+ if (args[0] === '99999') return { v: 100, name: null, cnt: 3 };
+ return null;
+ },
+ });
+ const medians = await getCpvGroupMedians(db, ['22112', '22112', 'bogus', '99999', '4500']);
+ expect(medians).toEqual([
+ { group: '22112', name: 'Училищни учебници', contracts: 10, medianEur: 6300 },
+ { group: '99999', name: null, contracts: 3, medianEur: 100 },
+ ]);
+ // Two valid unique groups → exactly two median statements; '…9' prefix rolls to the next char.
+ const medianCalls = calls.filter((c) => c.sql.includes('rn = (cnt - 1)'));
+ expect(medianCalls).toHaveLength(2);
+ expect(medianCalls[1]!.args).toEqual(['99999', '9999:']);
+ });
+
+ it('is a no-op for an empty group list', async () => {
+ expect(await getCpvGroupMedians(overviewDb({}), [])).toEqual([]);
+ });
+});
+
+describe('listOverviewContracts', () => {
+ const ROWS = [
+ {
+ id: 'c:abc',
+ signed_at: '2025-06-01',
+ amount_eur: 125000,
+ cpv_code: '33600000',
+ authority_name: 'УМБАЛ Александровска ЕАД',
+ bidder_name: 'Апекс Инженеринг ООД',
+ bidder_kind: 'company',
+ },
+ {
+ id: 'c:def',
+ signed_at: '2025-05-01',
+ amount_eur: 500,
+ cpv_code: null,
+ authority_name: 'Община Брегово',
+ bidder_name: 'Фирма А; Фирма Б',
+ bidder_kind: 'consortium',
+ },
+ ];
+
+ it('maps rows to overview cards (slug, display names, 5-digit group)', async () => {
+ const db = overviewDb({ all: () => ROWS });
+ const items = await listOverviewContracts(db, {});
+ expect(items).toEqual([
+ {
+ id: 'abc',
+ signedAt: '2025-06-01',
+ valueEur: 125000,
+ authorityName: 'УМБАЛ Александровска ЕАД',
+ bidderName: 'Апекс Инженеринг ООД',
+ cpvGroup: '33600',
+ },
+ {
+ id: 'def',
+ signedAt: '2025-05-01',
+ valueEur: 500,
+ authorityName: 'Община Брегово',
+ bidderName: 'Фирма А и др.', // consortium folded like the rest of the site
+ cpvGroup: null,
+ },
+ ]);
+ });
+
+ it('applies year and CPV-group cuts and the value sort, all bounded by LIMIT', async () => {
+ const calls: QueryCall[] = [];
+ const db = overviewDb({ calls, all: () => [] });
+ await listOverviewContracts(db, { year: '2024', cpvGroup: '45233', sort: 'value', limit: 12 });
+ const call = calls[0]!;
+ expect(call.sql).toContain('substr(c.signed_at, 1, 4) = ?');
+ expect(call.sql).toContain('t.cpv_code >= ? AND t.cpv_code < ?');
+ expect(call.sql).toContain('ORDER BY c.amount_eur DESC');
+ expect(call.args).toEqual(['2020-01-01', '2024', '45233', '45234', 12]);
+ });
+
+ it('defaults to newest-first within the trend window on the same value basis', async () => {
+ const calls: QueryCall[] = [];
+ const db = overviewDb({ calls, all: () => [] });
+ await listOverviewContracts(db, {});
+ const call = calls[0]!;
+ expect(call.sql).toContain('ORDER BY c.signed_at DESC');
+ expect(call.sql).toContain('c.amount_eur > 0');
+ expect(call.sql).toContain('substr(c.signed_at, 1, 4) GLOB');
+ expect(call.args).toEqual(['2020-01-01', 24]);
+ });
+});
diff --git a/packages/db/src/queries/trend.ts b/packages/db/src/queries/trend.ts
index 576a90432..f48c91345 100644
--- a/packages/db/src/queries/trend.ts
+++ b/packages/db/src/queries/trend.ts
@@ -4,13 +4,23 @@
// usable signing date are excluded from the series and reported as coverage. Edge-cached at the route,
// like getFlows; precompute is a possible follow-up.
-import type { TrendData, TrendPoint, TrendYear } from '@sigma/api-contract';
+import type {
+ CpvGroupMedian,
+ CpvGroupStat,
+ OverviewContract,
+ TrendData,
+ TrendGranularity,
+ TrendPoint,
+ TrendYear,
+} from '@sigma/api-contract';
+import { cleanName, entityName } from '@sigma/shared';
+import { contractSlug } from './identity';
import { sectorOptions } from './sectors';
export interface TrendParams {
sector?: string | null;
funding?: 'all' | 'eu' | 'national';
- granularity?: 'month' | 'year';
+ granularity?: TrendGranularity;
authorityId?: string | null;
bidderId?: string | null;
}
@@ -59,13 +69,28 @@ function scope(p: TrendParams): { join: string; where: string[]; params: unknown
return { join, where, params };
}
+// 'YYYY-MM' → 'YYYY-Qn'. Quarter series is queried monthly and folded here (no SQL date math).
+function quarterOf(month: string): string {
+ const [y, m] = month.split('-') as [string, string];
+ return `${y}-Q${Math.ceil(Number(m) / 3)}`;
+}
+
// Continuous period keys (inclusive) for zero-filling gaps, so the chart has no holes.
-function fillPeriods(first: string, last: string, granularity: 'month' | 'year'): string[] {
+function fillPeriods(first: string, last: string, granularity: TrendGranularity): string[] {
if (granularity === 'year') {
const out: string[] = [];
for (let y = Number(first); y <= Number(last); y += 1) out.push(String(y));
return out;
}
+ if (granularity === 'quarter') {
+ const [fy, fq] = first.split('-Q').map(Number) as [number, number];
+ const [ly, lq] = last.split('-Q').map(Number) as [number, number];
+ const out: string[] = [];
+ for (let q = fy * 4 + (fq - 1); q <= ly * 4 + (lq - 1); q += 1) {
+ out.push(`${Math.floor(q / 4)}-Q${(q % 4) + 1}`);
+ }
+ return out;
+ }
const [fy, fm] = first.split('-').map(Number) as [number, number];
const [ly, lm] = last.split('-').map(Number) as [number, number];
const out: string[] = [];
@@ -81,7 +106,9 @@ export async function getSpendingTrend(
options: TrendQueryOptions = {},
): Promise {
const includeSectors = options.includeSectors ?? true;
- const granularity = p.granularity === 'year' ? 'year' : 'month';
+ const granularity: TrendGranularity =
+ p.granularity === 'year' || p.granularity === 'quarter' ? p.granularity : 'month';
+ // Quarters are queried at month grain (substr can't cut a quarter) and folded below.
const periodLen = granularity === 'year' ? 4 : 7; // substr length: 'YYYY' vs 'YYYY-MM'
const s = scope(p);
@@ -112,10 +139,24 @@ export async function getSpendingTrend(
// The final period (the as_of period) is still being filled; mark it so the chart and table do not
// read its dip as a real decline, and so YoY is not computed against a partial year.
const asOf = asOfRow?.as_of ?? null;
- const partialPeriod = asOf ? asOf.slice(0, periodLen) : null;
+ const asOfPeriod = asOf ? asOf.slice(0, periodLen) : null;
+ const partialPeriod =
+ asOfPeriod && granularity === 'quarter' ? quarterOf(asOfPeriod) : asOfPeriod;
const partialYear = asOf ? asOf.slice(0, 4) : null;
- const rows = series.results;
+ let rows = series.results;
+ if (granularity === 'quarter' && rows.length) {
+ // Fold the monthly rows into quarters (input is sorted by period, so quarters stay in order).
+ const byQuarter = new Map();
+ for (const r of rows) {
+ const period = quarterOf(r.period);
+ const acc = byQuarter.get(period) ?? { period, value_eur: 0, contracts: 0 };
+ acc.value_eur += r.value_eur;
+ acc.contracts += r.contracts;
+ byQuarter.set(period, acc);
+ }
+ rows = [...byQuarter.values()];
+ }
let points: TrendPoint[] = [];
if (rows.length) {
const byPeriod = new Map(rows.map((r) => [r.period, r]));
@@ -171,3 +212,242 @@ export async function getSpendingTrend(
scope: { sector: p.sector ?? null, funding: p.funding ?? 'all', granularity },
};
}
+
+// ── Contracts overview: per-CPV-group price distributions + the filtered contract cards ──────────
+//
+// A CPV "group" is the 5-digit class prefix of tenders.cpv_code. There is no precomputed percentile
+// rollup (sector_totals is per 2-digit division, count/sum only), so percentiles are computed live —
+// but bounded: only the top-N groups by contract count get the full distribution, and every per-group
+// scan rides idx_tenders_cpv via a half-open prefix range (cpv_code >= G AND cpv_code < succ(G)).
+// The route is edge-cached, so these scans run once per cache window, not per request.
+
+// A usable CPV group is 5 leading digits.
+const CPV_GROUP_GLOB = "t.cpv_code GLOB '[0-9][0-9][0-9][0-9][0-9]*'";
+
+/** Half-open index range covering every cpv_code with the 5-digit prefix (works for '…9' too). */
+function cpvGroupRange(group: string): [string, string] {
+ const hi = group.slice(0, -1) + String.fromCharCode(group.charCodeAt(group.length - 1) + 1);
+ return [group, hi];
+}
+
+// One pass over a group's positive-EUR contracts (sorted by value, via the CPV index range) that
+// returns only ~30 rows: the exact p10/p50/p90 ranks, a ~5%-step quantile ladder for the dot cloud,
+// and the top outliers. Rank arithmetic is integer (SQLite '/' floors), mirrored in JS below.
+const GROUP_DIST_SQL = `
+ WITH s AS (
+ SELECT c.amount_eur AS v, t.cpv_description AS name,
+ ROW_NUMBER() OVER (ORDER BY c.amount_eur) AS rn,
+ COUNT(*) OVER () AS cnt
+ FROM contracts c JOIN tenders t ON t.id = c.tender_id
+ WHERE t.cpv_code >= ? AND t.cpv_code < ? AND c.amount_eur > 0
+ )
+ SELECT v, name, rn, cnt FROM s
+ WHERE rn = 1 OR rn = cnt
+ OR rn = (cnt - 1) * 1 / 10 + 1
+ OR rn = (cnt - 1) * 5 / 10 + 1
+ OR rn = (cnt - 1) * 9 / 10 + 1
+ OR (rn - 1) % (CASE WHEN cnt > 21 THEN (cnt - 1) / 20 ELSE 1 END) = 0
+ OR rn > cnt - 5
+ ORDER BY rn`;
+
+interface GroupDistRow {
+ v: number;
+ name: string | null;
+ rn: number;
+ cnt: number;
+}
+
+/** floor-rank of quantile q among cnt sorted rows (1-based) — must match GROUP_DIST_SQL. */
+const rankOf = (cnt: number, q10: number) => Math.floor(((cnt - 1) * q10) / 10) + 1;
+
+// Most common non-empty description among the sampled rows — a representative human label for the
+// group without a separate dictionary scan.
+function sampleName(rows: GroupDistRow[]): string | null {
+ const freq = new Map();
+ for (const r of rows) {
+ const name = r.name?.trim();
+ if (name) freq.set(name, (freq.get(name) ?? 0) + 1);
+ }
+ let best: string | null = null;
+ let bestN = 0;
+ for (const [name, n] of freq) {
+ if (n > bestN) {
+ best = name;
+ bestN = n;
+ }
+ }
+ return best;
+}
+
+function toGroupStat(group: string, rows: GroupDistRow[]): CpvGroupStat | null {
+ if (!rows.length) return null;
+ const cnt = rows[0]!.cnt;
+ // Every rank requested here is produced by GROUP_DIST_SQL for the current cnt, so this should
+ // never miss. If a future change to GROUP_DIST_SQL's rank set drops one, fail loud instead of
+ // silently returning the sample minimum (which would masquerade as a real percentile).
+ const at = (rank: number) => {
+ const hit = rows.find((r) => r.rn === rank);
+ if (!hit) {
+ console.error(
+ `getCpvGroupStats: rank ${rank} missing from GROUP_DIST_SQL sample (cnt=${cnt})`,
+ );
+ return rows[0]!.v;
+ }
+ return hit.v;
+ };
+ return {
+ group,
+ name: sampleName(rows),
+ contracts: cnt,
+ medianEur: at(rankOf(cnt, 5)),
+ p10Eur: at(rankOf(cnt, 1)),
+ p90Eur: at(rankOf(cnt, 9)),
+ maxEur: rows[rows.length - 1]!.v,
+ sampleEur: rows.map((r) => r.v),
+ };
+}
+
+export interface CpvGroupStatsResult {
+ groups: CpvGroupStat[]; // top-N by contract count, in that order
+ totalGroups: number; // distinct 5-digit groups in the corpus (the headline KPI)
+}
+
+/**
+ * Top-N CPV groups by contract count, each with median / p10–p90 / max and a real-value sample for
+ * the distribution row. One grouped scan for the ranking (same precedent as the live sector facet in
+ * queries/contracts.ts), then one bounded indexed pass per group.
+ */
+export async function getCpvGroupStats(db: D1Database, limit = 10): Promise {
+ const [top, totalRow] = await Promise.all([
+ db
+ .prepare(
+ `SELECT substr(t.cpv_code, 1, 5) AS grp, COUNT(*) AS contracts
+ FROM contracts c JOIN tenders t ON t.id = c.tender_id
+ WHERE c.amount_eur > 0 AND ${CPV_GROUP_GLOB}
+ GROUP BY grp ORDER BY contracts DESC, grp LIMIT ?`,
+ )
+ .bind(limit)
+ .all<{ grp: string; contracts: number }>(),
+ // Deliberately no `amount_eur > 0` filter here: this is the corpus-wide group count (every
+ // 5-digit CPV group that appears in any tender), while the ranking above only considers groups
+ // with positive-value contracts. The two counts intentionally differ — totalGroups can exceed
+ // the number of groups that could ever appear in the top-N ranking.
+ db
+ .prepare(
+ `SELECT COUNT(DISTINCT substr(cpv_code, 1, 5)) AS n
+ FROM tenders t WHERE ${CPV_GROUP_GLOB}`,
+ )
+ .first<{ n: number }>(),
+ ]);
+
+ const dists = await Promise.all(
+ top.results.map((r) =>
+ db
+ .prepare(GROUP_DIST_SQL)
+ .bind(...cpvGroupRange(r.grp))
+ .all(),
+ ),
+ );
+
+ const groups = top.results
+ .map((r, i) => toGroupStat(r.grp, dists[i]!.results))
+ .filter((g): g is CpvGroupStat => g !== null);
+ return { groups, totalGroups: totalRow?.n ?? 0 };
+}
+
+/**
+ * Median (plus count and a representative name) for arbitrary CPV groups — the „спрямо типичното"
+ * cohort baseline for contract cards whose group is outside the top-N stats. Bounded by the caller:
+ * one indexed pass per requested group, and the card page has at most a handful of distinct groups.
+ */
+export async function getCpvGroupMedians(
+ db: D1Database,
+ groups: string[],
+): Promise {
+ const unique = [...new Set(groups)].filter((g) => /^\d{5}$/.test(g));
+ if (!unique.length) return [];
+ const rows = await Promise.all(
+ unique.map((g) =>
+ db
+ .prepare(
+ `WITH s AS (
+ SELECT c.amount_eur AS v, t.cpv_description AS name,
+ ROW_NUMBER() OVER (ORDER BY c.amount_eur) AS rn,
+ COUNT(*) OVER () AS cnt
+ FROM contracts c JOIN tenders t ON t.id = c.tender_id
+ WHERE t.cpv_code >= ? AND t.cpv_code < ? AND c.amount_eur > 0
+ )
+ SELECT v, name, cnt FROM s WHERE rn = (cnt - 1) * 5 / 10 + 1`,
+ )
+ .bind(...cpvGroupRange(g))
+ .first<{ v: number; name: string | null; cnt: number }>(),
+ ),
+ );
+ const out: CpvGroupMedian[] = [];
+ unique.forEach((group, i) => {
+ const r = rows[i];
+ if (r) out.push({ group, name: r.name?.trim() || null, contracts: r.cnt, medianEur: r.v });
+ });
+ return out;
+}
+
+export interface OverviewContractsParams {
+ year?: string | null; // 'YYYY'
+ cpvGroup?: string | null; // 5-digit prefix
+ sort?: 'date' | 'value';
+ limit?: number;
+}
+
+interface OverviewRow {
+ id: string;
+ signed_at: string | null;
+ amount_eur: number;
+ cpv_code: string | null;
+ authority_name: string;
+ bidder_name: string;
+ bidder_kind: 'company' | 'consortium';
+}
+
+/**
+ * The shared contract cards under the overview lenses: same value/date basis as the trend series
+ * (positive EUR, real signing date inside the window), optionally cut by year and/or CPV group,
+ * newest-first or biggest-first. Bounded LIMIT; rides idx_contracts_signed / idx_contracts_amount_eur
+ * (and idx_tenders_cpv for the group cut).
+ */
+export async function listOverviewContracts(
+ db: D1Database,
+ p: OverviewContractsParams,
+): Promise {
+ const where = ['c.amount_eur > 0', YEAR_KNOWN, 'c.signed_at >= ?', "c.signed_at <= date('now')"];
+ const params: unknown[] = [START];
+ if (p.year) {
+ where.push('substr(c.signed_at, 1, 4) = ?');
+ params.push(p.year);
+ }
+ if (p.cpvGroup && /^\d{5}$/.test(p.cpvGroup)) {
+ where.push('t.cpv_code >= ? AND t.cpv_code < ?');
+ params.push(...cpvGroupRange(p.cpvGroup));
+ }
+ const order =
+ p.sort === 'value' ? 'ORDER BY c.amount_eur DESC, c.id' : 'ORDER BY c.signed_at DESC, c.id';
+ const { results } = await db
+ .prepare(
+ `SELECT c.id, c.signed_at, c.amount_eur, t.cpv_code,
+ a.name AS authority_name, b.name AS bidder_name, b.kind AS bidder_kind
+ FROM contracts c
+ JOIN tenders t ON t.id = c.tender_id
+ JOIN authorities a ON a.id = t.authority_id
+ JOIN bidders b ON b.id = c.bidder_id
+ WHERE ${where.join(' AND ')} ${order} LIMIT ?`,
+ )
+ .bind(...params, p.limit ?? 24)
+ .all();
+ return results.map((r) => ({
+ id: contractSlug(r.id),
+ signedAt: r.signed_at,
+ valueEur: r.amount_eur,
+ authorityName: cleanName(r.authority_name),
+ bidderName: entityName(cleanName(r.bidder_name), r.bidder_kind),
+ cpvGroup: r.cpv_code && /^\d{5}/.test(r.cpv_code) ? r.cpv_code.slice(0, 5) : null,
+ }));
+}
diff --git a/packages/db/src/refresh-slice.test.ts b/packages/db/src/refresh-slice.test.ts
index 6ee7471ff..ae36eab9b 100644
--- a/packages/db/src/refresh-slice.test.ts
+++ b/packages/db/src/refresh-slice.test.ts
@@ -1,6 +1,6 @@
///
import { execFileSync } from 'node:child_process';
-import { mkdtempSync, rmSync } from 'node:fs';
+import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,9 +8,13 @@ import { describe, expect, it } from 'vitest';
import { assertIntegrity } from '../../../scripts/integrity-checks.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
-const schemaPath = resolve(root, 'packages/db/migrations/0000_init.sql');
-const migration1Path = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
-const migration2Path = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+// Full migration chain (see scripts/import.mjs): refresh-slice.sql / normalize-raw.sql write the
+// health-index columns added by 0003, so schema-from-0000-only would miss them.
+const migrationsDir = resolve(root, 'packages/db/migrations');
+const migrationPaths = readdirSync(migrationsDir)
+ .filter((f) => f.endsWith('.sql'))
+ .sort()
+ .map((f) => resolve(migrationsDir, f));
const refreshSlicePath = resolve(root, 'scripts/refresh-slice.sql');
const normalizePath = resolve(root, 'scripts/normalize-raw.sql');
const deriveAmendmentsPath = resolve(root, 'scripts/derive-amendments.sql');
@@ -179,9 +183,7 @@ function seedOcdsOnlySharedNumber(dbPath: string): void {
}
function initWorkDb(dbPath: string): void {
- readScript(dbPath, schemaPath);
- readScript(dbPath, migration1Path);
- readScript(dbPath, migration2Path);
+ for (const migration of migrationPaths) readScript(dbPath, migration);
readScript(dbPath, workStagingSchemaPath);
}
@@ -569,9 +571,7 @@ describe('refresh-slice EOP base derivation', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-'));
const dbPath = resolve(dir, 'test.sqlite');
try {
- readScript(dbPath, schemaPath);
- readScript(dbPath, migration1Path);
- readScript(dbPath, migration2Path);
+ for (const migration of migrationPaths) readScript(dbPath, migration);
readScript(dbPath, workStagingSchemaPath);
seedEopBaseDay(dbPath);
@@ -650,9 +650,7 @@ describe('refresh-slice EOP base derivation', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-'));
const dbPath = resolve(dir, 'test.sqlite');
try {
- readScript(dbPath, schemaPath);
- readScript(dbPath, migration1Path);
- readScript(dbPath, migration2Path);
+ for (const migration of migrationPaths) readScript(dbPath, migration);
readScript(dbPath, workStagingSchemaPath);
seedEopOnlySharedNumber(dbPath);
readScript(dbPath, refreshSlicePath);
@@ -700,9 +698,7 @@ describe('refresh-slice EOP base derivation', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-'));
const dbPath = resolve(dir, 'test.sqlite');
try {
- readScript(dbPath, schemaPath);
- readScript(dbPath, migration1Path);
- readScript(dbPath, migration2Path);
+ for (const migration of migrationPaths) readScript(dbPath, migration);
readScript(dbPath, workStagingSchemaPath);
sqlite(
dbPath,
@@ -750,9 +746,7 @@ describe('refresh-slice EOP base derivation', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-'));
const dbPath = resolve(dir, 'test.sqlite');
try {
- readScript(dbPath, schemaPath);
- readScript(dbPath, migration1Path);
- readScript(dbPath, migration2Path);
+ for (const migration of migrationPaths) readScript(dbPath, migration);
readScript(dbPath, workStagingSchemaPath);
sqlite(
dbPath,
@@ -886,9 +880,7 @@ describe('refresh-slice EOP base derivation', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'sigma-refresh-slice-'));
const dbPath = resolve(dir, 'test.sqlite');
try {
- readScript(dbPath, schemaPath);
- readScript(dbPath, migration1Path);
- readScript(dbPath, migration2Path);
+ for (const migration of migrationPaths) readScript(dbPath, migration);
readScript(dbPath, workStagingSchemaPath);
sqlite(
dbPath,
diff --git a/packages/db/src/ship-domain.test.ts b/packages/db/src/ship-domain.test.ts
index 7101ec83c..331c83140 100644
--- a/packages/db/src/ship-domain.test.ts
+++ b/packages/db/src/ship-domain.test.ts
@@ -1,6 +1,6 @@
///
import { execFileSync } from 'node:child_process';
-import { mkdtempSync, rmSync } from 'node:fs';
+import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -44,14 +44,21 @@ describe('ship-domain', () => {
const workDb = resolve(dir, 'work.sqlite');
const persistTo = resolve(dir, 'served');
try {
- readScript(workDb, resolve(root, 'packages/db/migrations/0000_init.sql'));
+ // Full migration chain, like scripts/import.mjs — ship-domain's precompute/derive steps
+ // reference the 0003 health-index columns.
+ const migrationsDir = resolve(root, 'packages/db/migrations');
+ for (const f of readdirSync(migrationsDir)
+ .filter((n) => n.endsWith('.sql'))
+ .sort()) {
+ readScript(workDb, resolve(migrationsDir, f));
+ }
sqlite(
workDb,
`INSERT INTO authorities (id, name, bulstat, type) VALUES ('auth:1', 'Authority line 1
Authority line 2', '1', 'public');
- INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES ('eik:200000002', 'Bidder', '200000002', '200000002', 1, 'company');
- INSERT INTO tenders (id, source_id, title, authority_id, currency, procedure_type, status) VALUES ('t:1', '1', 'Tender', 'auth:1', 'BGN', 'open', 'awarded');
- INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, contract_number, signing_value, value_flag, amount_eur) VALUES ('c:e:1', 't:1', 'eik:200000002', 10, 'BGN', 'C1', 10, 'ok', 10 / 1.95583);
+ INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES ('eik:200000007', 'Bidder', '200000007', '200000007', 1, 'company');
+ INSERT INTO tenders (id, source_id, title, authority_id, currency, procedure_type, status) VALUES ('t:1', '1', 'Tender', 'auth:1', 'BGN', 'Открита процедура', 'awarded');
+ INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, contract_number, signing_value, value_flag, amount_eur) VALUES ('c:e:1', 't:1', 'eik:200000007', 10, 'BGN', 'C1', 10, 'ok', 10 / 1.95583);
INSERT INTO amendments (id, natural_key, contract_number, unp, description, source) VALUES ('am:1:C1:A1', 'am:1:C1:A1', 'C1', '1', 'Description line 1
Description line 2', 'test');
INSERT INTO nuts_regions (nuts3, nuts3_name, nuts2, nuts2_name, nuts1, nuts1_name)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 150a51d18..bcd023ae5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,11 @@ overrides:
vite@8: ^8.0.16
undici: ^7.28.0
'@babel/core': ^7.29.6
+ sharp: ^0.35.0
+ postcss: ^8.5.18
+ valibot: ^1.4.2
+ react-router: ^7.18.0
+ '@react-router/dev': ^7.18.0
importers:
@@ -36,7 +41,7 @@ importers:
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))
wrangler:
specifier: ^4.93.1
- version: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ version: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@25.9.1)
apps/etl:
dependencies:
@@ -77,15 +82,15 @@ importers:
specifier: ^19.2.6
version: 19.2.6(react@19.2.6)
react-router:
- specifier: 7.18.0
+ specifier: ^7.18.0
version: 7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
devDependencies:
'@cloudflare/vite-plugin':
specifier: ^1.29.1
- version: 1.37.3(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))
+ version: 1.37.3(@types/node@22.19.19)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))
'@react-router/dev':
- specifier: 7.18.0
- version: 7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))
+ specifier: ^7.18.0
+ version: 7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.3.0(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))
@@ -112,7 +117,7 @@ importers:
version: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)
wrangler:
specifier: ^4.75.0
- version: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ version: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)
packages/api-contract:
dependencies:
@@ -412,6 +417,9 @@ packages:
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -584,152 +592,161 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.34.5':
- resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-arm64@0.35.3':
+ resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.34.5':
- resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-x64@0.35.3':
+ resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-darwin-arm64@1.2.4':
- resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
+ resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.2.4':
- resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ '@img/sharp-libvips-darwin-x64@1.3.2':
+ resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.2.4':
- resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ '@img/sharp-libvips-linux-arm64@1.3.2':
+ resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.2.4':
- resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ '@img/sharp-libvips-linux-arm@1.3.2':
+ resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.2.4':
- resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
+ resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.2.4':
- resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
+ resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.2.4':
- resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ '@img/sharp-libvips-linux-s390x@1.3.2':
+ resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.2.4':
- resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ '@img/sharp-libvips-linux-x64@1.3.2':
+ resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
- resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
+ resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
- resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
+ resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-linux-arm64@0.34.5':
- resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm64@0.35.3':
+ resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-arm@0.34.5':
- resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm@0.35.3':
+ resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-ppc64@0.34.5':
- resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-ppc64@0.35.3':
+ resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
+ engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-riscv64@0.34.5':
- resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-riscv64@0.35.3':
+ resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
+ engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-s390x@0.34.5':
- resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-s390x@0.35.3':
+ resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
+ engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-x64@0.34.5':
- resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-x64@0.35.3':
+ resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.34.5':
- resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-arm64@0.35.3':
+ resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-linuxmusl-x64@0.34.5':
- resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-x64@0.35.3':
+ resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-wasm32@0.34.5':
- resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-wasm32@0.35.3':
+ resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
+ engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.34.5':
- resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-arm64@0.35.3':
+ resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.34.5':
- resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-ia32@0.35.3':
+ resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
+ engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.34.5':
- resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-x64@0.35.3':
+ resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -805,7 +822,7 @@ packages:
resolution: {integrity: sha512-pRXJahLrdVfuVbaTpWsZ89mBuGiYH3Z4y+y1UidwxmJFKk6NjMyUvkJl3FjDWdD+nSlgFPSESUZS0hF560MUUQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
- react-router: 7.18.0
+ react-router: ^7.18.0
typescript: ^5.1.0 || ^6.0.0
peerDependenciesMeta:
typescript:
@@ -1555,8 +1572,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.12:
- resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ nanoid@3.3.16:
+ resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1597,8 +1614,8 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
- postcss@8.5.15:
- resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ postcss@8.5.23:
+ resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
prettier@3.8.3:
@@ -1667,12 +1684,22 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
- sharp@0.34.5:
- resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ sharp@0.35.3:
+ resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
+ engines: {node: '>=20.9.0'}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -1771,8 +1798,8 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
- valibot@1.4.0:
- resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==}
+ valibot@1.4.2:
+ resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
@@ -2219,15 +2246,16 @@ snapshots:
optionalDependencies:
workerd: 1.20260520.1
- '@cloudflare/vite-plugin@1.37.3(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))':
+ '@cloudflare/vite-plugin@1.37.3(@types/node@22.19.19)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))':
dependencies:
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1)
- miniflare: 4.20260520.0
+ miniflare: 4.20260520.0(@types/node@22.19.19)
unenv: 2.0.0-rc.24
vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)
- wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)
ws: 8.21.0
transitivePeerDependencies:
+ - '@types/node'
- bufferutil
- utf-8-validate
- workerd
@@ -2288,6 +2316,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
@@ -2375,98 +2408,108 @@ snapshots:
'@img/colour@1.1.0': {}
- '@img/sharp-darwin-arm64@0.34.5':
+ '@img/sharp-darwin-arm64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true
- '@img/sharp-darwin-x64@0.34.5':
+ '@img/sharp-darwin-x64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ optional: true
+
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
optional: true
- '@img/sharp-libvips-darwin-arm64@1.2.4':
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
optional: true
- '@img/sharp-libvips-darwin-x64@1.2.4':
+ '@img/sharp-libvips-darwin-x64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-arm64@1.2.4':
+ '@img/sharp-libvips-linux-arm64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-arm@1.2.4':
+ '@img/sharp-libvips-linux-arm@1.3.2':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.2.4':
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.2.4':
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-s390x@1.2.4':
+ '@img/sharp-libvips-linux-s390x@1.3.2':
optional: true
- '@img/sharp-libvips-linux-x64@1.2.4':
+ '@img/sharp-libvips-linux-x64@1.3.2':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
optional: true
- '@img/sharp-linux-arm64@0.34.5':
+ '@img/sharp-linux-arm64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.3.2
optional: true
- '@img/sharp-linux-arm@0.34.5':
+ '@img/sharp-linux-arm@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.3.2
optional: true
- '@img/sharp-linux-ppc64@0.34.5':
+ '@img/sharp-linux-ppc64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true
- '@img/sharp-linux-riscv64@0.34.5':
+ '@img/sharp-linux-riscv64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true
- '@img/sharp-linux-s390x@0.34.5':
+ '@img/sharp-linux-s390x@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.3.2
optional: true
- '@img/sharp-linux-x64@0.34.5':
+ '@img/sharp-linux-x64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.3.2
optional: true
- '@img/sharp-linuxmusl-arm64@0.34.5':
+ '@img/sharp-linuxmusl-arm64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true
- '@img/sharp-linuxmusl-x64@0.34.5':
+ '@img/sharp-linuxmusl-x64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true
- '@img/sharp-wasm32@0.34.5':
+ '@img/sharp-wasm32@0.35.3':
dependencies:
- '@emnapi/runtime': 1.10.0
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
optional: true
- '@img/sharp-win32-arm64@0.34.5':
+ '@img/sharp-win32-arm64@0.35.3':
optional: true
- '@img/sharp-win32-ia32@0.34.5':
+ '@img/sharp-win32-ia32@0.35.3':
optional: true
- '@img/sharp-win32-x64@0.34.5':
+ '@img/sharp-win32-x64@0.35.3':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -2518,7 +2561,7 @@ snapshots:
'@poppinss/exception@1.2.3': {}
- '@react-router/dev@7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))':
+ '@react-router/dev@7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))':
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.7
@@ -2547,12 +2590,12 @@ snapshots:
react-router: 7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
semver: 7.8.0
tinyglobby: 0.2.17
- valibot: 1.4.0(typescript@5.9.3)
+ valibot: 1.4.2(typescript@5.9.3)
vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)
vite-node: 3.2.4(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)
optionalDependencies:
typescript: 5.9.3
- wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -3131,21 +3174,35 @@ snapshots:
mdn-data@2.27.1: {}
- miniflare@4.20260520.0:
+ miniflare@4.20260520.0(@types/node@22.19.19):
dependencies:
'@cspotcode/source-map-support': 0.8.1
- sharp: 0.34.5
+ sharp: 0.35.3(@types/node@22.19.19)
undici: 7.28.0
workerd: 1.20260520.1
ws: 8.21.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
+ - '@types/node'
+ - bufferutil
+ - utf-8-validate
+
+ miniflare@4.20260520.0(@types/node@25.9.1):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ sharp: 0.35.3(@types/node@25.9.1)
+ undici: 7.28.0
+ workerd: 1.20260520.1
+ ws: 8.21.0
+ youch: 4.1.0-beta.10
+ transitivePeerDependencies:
+ - '@types/node'
- bufferutil
- utf-8-validate
ms@2.1.3: {}
- nanoid@3.3.12: {}
+ nanoid@3.3.16: {}
node-releases@2.0.45: {}
@@ -3178,9 +3235,9 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
- postcss@8.5.15:
+ postcss@8.5.23:
dependencies:
- nanoid: 3.3.12
+ nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -3271,38 +3328,75 @@ snapshots:
semver@7.8.0: {}
+ semver@7.8.5: {}
+
set-cookie-parser@2.7.2: {}
- sharp@0.34.5:
+ sharp@0.35.3(@types/node@22.19.19):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.8.0
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.35.3
+ '@img/sharp-darwin-x64': 0.35.3
+ '@img/sharp-freebsd-wasm32': 0.35.3
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ '@img/sharp-linux-arm': 0.35.3
+ '@img/sharp-linux-arm64': 0.35.3
+ '@img/sharp-linux-ppc64': 0.35.3
+ '@img/sharp-linux-riscv64': 0.35.3
+ '@img/sharp-linux-s390x': 0.35.3
+ '@img/sharp-linux-x64': 0.35.3
+ '@img/sharp-linuxmusl-arm64': 0.35.3
+ '@img/sharp-linuxmusl-x64': 0.35.3
+ '@img/sharp-webcontainers-wasm32': 0.35.3
+ '@img/sharp-win32-arm64': 0.35.3
+ '@img/sharp-win32-ia32': 0.35.3
+ '@img/sharp-win32-x64': 0.35.3
+ '@types/node': 22.19.19
+
+ sharp@0.35.3(@types/node@25.9.1):
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.5
- '@img/sharp-darwin-x64': 0.34.5
- '@img/sharp-libvips-darwin-arm64': 1.2.4
- '@img/sharp-libvips-darwin-x64': 1.2.4
- '@img/sharp-libvips-linux-arm': 1.2.4
- '@img/sharp-libvips-linux-arm64': 1.2.4
- '@img/sharp-libvips-linux-ppc64': 1.2.4
- '@img/sharp-libvips-linux-riscv64': 1.2.4
- '@img/sharp-libvips-linux-s390x': 1.2.4
- '@img/sharp-libvips-linux-x64': 1.2.4
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
- '@img/sharp-linux-arm': 0.34.5
- '@img/sharp-linux-arm64': 0.34.5
- '@img/sharp-linux-ppc64': 0.34.5
- '@img/sharp-linux-riscv64': 0.34.5
- '@img/sharp-linux-s390x': 0.34.5
- '@img/sharp-linux-x64': 0.34.5
- '@img/sharp-linuxmusl-arm64': 0.34.5
- '@img/sharp-linuxmusl-x64': 0.34.5
- '@img/sharp-wasm32': 0.34.5
- '@img/sharp-win32-arm64': 0.34.5
- '@img/sharp-win32-ia32': 0.34.5
- '@img/sharp-win32-x64': 0.34.5
+ '@img/sharp-darwin-arm64': 0.35.3
+ '@img/sharp-darwin-x64': 0.35.3
+ '@img/sharp-freebsd-wasm32': 0.35.3
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ '@img/sharp-linux-arm': 0.35.3
+ '@img/sharp-linux-arm64': 0.35.3
+ '@img/sharp-linux-ppc64': 0.35.3
+ '@img/sharp-linux-riscv64': 0.35.3
+ '@img/sharp-linux-s390x': 0.35.3
+ '@img/sharp-linux-x64': 0.35.3
+ '@img/sharp-linuxmusl-arm64': 0.35.3
+ '@img/sharp-linuxmusl-x64': 0.35.3
+ '@img/sharp-webcontainers-wasm32': 0.35.3
+ '@img/sharp-win32-arm64': 0.35.3
+ '@img/sharp-win32-ia32': 0.35.3
+ '@img/sharp-win32-x64': 0.35.3
+ '@types/node': 25.9.1
siginfo@2.0.0: {}
@@ -3382,7 +3476,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- valibot@1.4.0(typescript@5.9.3):
+ valibot@1.4.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -3412,7 +3506,7 @@ snapshots:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
- postcss: 8.5.15
+ postcss: 8.5.23
rollup: 4.60.4
tinyglobby: 0.2.17
optionalDependencies:
@@ -3425,7 +3519,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
- postcss: 8.5.15
+ postcss: 8.5.23
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -3438,7 +3532,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
- postcss: 8.5.15
+ postcss: 8.5.23
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -3505,13 +3599,13 @@ snapshots:
'@cloudflare/workerd-linux-arm64': 1.20260520.1
'@cloudflare/workerd-windows-64': 1.20260520.1
- wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1):
+ wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1)
blake3-wasm: 2.1.5
esbuild: 0.28.1
- miniflare: 4.20260520.0
+ miniflare: 4.20260520.0(@types/node@22.19.19)
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260520.1
@@ -3519,6 +3613,25 @@ snapshots:
'@cloudflare/workers-types': 4.20260521.1
fsevents: 2.3.3
transitivePeerDependencies:
+ - '@types/node'
+ - bufferutil
+ - utf-8-validate
+
+ wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@25.9.1):
+ dependencies:
+ '@cloudflare/kv-asset-handler': 0.5.0
+ '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1)
+ blake3-wasm: 2.1.5
+ esbuild: 0.28.1
+ miniflare: 4.20260520.0(@types/node@25.9.1)
+ path-to-regexp: 6.3.0
+ unenv: 2.0.0-rc.24
+ workerd: 1.20260520.1
+ optionalDependencies:
+ '@cloudflare/workers-types': 4.20260521.1
+ fsevents: 2.3.3
+ transitivePeerDependencies:
+ - '@types/node'
- bufferutil
- utf-8-validate
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 96815ceab..08f1cfce9 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -24,6 +24,24 @@ overrides:
# @babel/core <7.29.6 — arbitrary file read via sourceMappingURL (GHSA-4x5r-pxfx-6jf8);
# dev/build-time only (via @react-router/dev), never ships to the Worker.
'@babel/core': '^7.29.6'
+ # sharp <0.35.0 — HIGH severity (CVSS 7.0) advisory GHSA-f88m-g3jw-g9cj, via
+ # wrangler→miniflare. Dev/build-time only; never ships to the Worker.
+ sharp: '^0.35.0'
+ # postcss <8.5.18 — path traversal via sourceMappingURL auto-load
+ # (GHSA-r28c-9q8g-f849); patch-level fix.
+ # valibot <1.4.2 — flatten() crashes on inherited-property keys
+ # (GHSA-5qjj-4xww-7phc); patch-level fix.
+ postcss: '^8.5.18'
+ valibot: '^1.4.2'
+ # react-router <7.18.0 — 4 advisories fixed within the 7.x line (no major bump needed):
+ # SSR hydration constructor injection (GHSA-337j-9hxr-rhxg), unauthenticated
+ # DoS via inefficient route matching (GHSA-chx6-hx7r-mcp5), RSCErrorHandler
+ # missing protocol validation / XSS (GHSA-h8fp-f39c-q6mh), open redirect via
+ # backslash in Link/useNavigate (GHSA-wrjc-x8rr-h8h6). The separate
+ # GHSA-qwww-vcr4-c8h2 (RSC-only CSRF, needs 8.x) is NOT fixed by this pin —
+ # see osv-scanner.toml for why that one is suppressed instead of bumped.
+ 'react-router': '^7.18.0'
+ '@react-router/dev': '^7.18.0'
onlyBuiltDependencies:
- esbuild
diff --git a/scripts/derive-contract-features.sql b/scripts/derive-contract-features.sql
new file mode 100644
index 000000000..f59e8df17
--- /dev/null
+++ b/scripts/derive-contract-features.sql
@@ -0,0 +1,831 @@
+-- Sigma — Contract Quality / Health Index, Phase 5a+5b: per-contract feature store.
+-- Run AFTER scripts/derive-health.sql (Phase 4 — authority_health_rollup, bidder_health_rollup,
+-- sector_concentration) has (re)built its rollups on the served D1:
+-- (cd apps/web && wrangler d1 execute sigma --local --file ../../scripts/derive-contract-features.sql)
+--
+-- Spec: Contract Quality / Health Index design spec §4 (leaf defs), §5 (peer key), §5.6 (fallback), §6
+-- (coverage), §7.3 (DDL), §8 (build order). §12 corrections OVERRIDE earlier sections — this file
+-- follows §12.2 (21-value procedure map), §12.3 (framework/DPS regime, contracts.framework is
+-- 100% NULL), §12.5 (year-band 'NA' for the 37 NULL/out-of-range signing years).
+--
+-- SCOPE: leaves + effective_peer_key/peer_n + score_coverage, then the pillar scoring UPDATEs
+-- (score_a..score_e, score_overall in [0,1]) and the six *_quality_totals rollups — all in this
+-- file, executed as one batch after scripts/derive-health.sql.
+--
+-- KNOWN LIMITATION (spec §5.6, same in its own sample SQL): rows that fall back to a mid/coarse
+-- peer key are PERCENT_RANKed only against the other fallback rows assigned that key, not against
+-- every row matching it — so the effective ranking cohort can be smaller than the stored peer_n.
+-- Recorded as an open §11 question; fixing it requires ranking against the full key population.
+--
+-- IDEMPOTENT: CREATE TABLE IF NOT EXISTS + DELETE + INSERT, same idiom as scripts/derive-health.sql.
+-- Temp staging tables are dropped up front so a re-run in the same connection is safe.
+--
+-- PORTABLE SQLite ONLY: no POWER/LN/EXP/SQRT; UPDATE...FROM (SQLite 3.33+, well below the D1/
+-- wrangler-bundled version) is used for the peer-key assignment join — a real JOIN, not a
+-- correlated COUNT(*)-per-row subquery.
+--
+-- PERFORMANCE: two single passes over `contracts` (194,484 rows): (1) one INSERT...SELECT with
+-- LEFT JOINs to the Phase-4 rollups (O(n) — every join target is PK/indexed-unique), materializing
+-- `contract_regime` (family/division/band/year) once as a TEMP TABLE so both the leaf INSERT and the
+-- peer-key UPDATE reuse it instead of recomputing the 21-value CASE map twice; (2) the peer-group
+-- counts are three GROUP BY passes over `contract_regime` (fine/mid/coarse), then ONE indexed
+-- UPDATE...FROM join to assign effective_peer_key/peer_n — NOT a per-row correlated COUNT(*), which
+-- would be O(n) work repeated n times.
+
+-- contract_features_next: the staging build target, DROP+CREATE fresh every run (it's disposable
+-- scratch, never the served name — see the atomic staging-swap note before the summary SELECT
+-- below). Built with score_a_bids/peer_has_multi from day one (group 338, §12.0 [0,1] scale), so
+-- there is no "ADD COLUMN IF NOT EXISTS" concern here the way there would be for an in-place ALTER.
+DROP TABLE IF EXISTS contract_features_next;
+CREATE TABLE contract_features_next (
+ contract_id TEXT PRIMARY KEY REFERENCES contracts(id),
+ -- peer + coverage
+ effective_peer_key TEXT, peer_n INTEGER,
+ coverage_bids INTEGER, coverage_sme INTEGER, coverage_estimate INTEGER,
+ coverage_overrun INTEGER, coverage_ocds INTEGER, score_coverage REAL,
+ -- A
+ bids_received INTEGER, single_offer INTEGER, sme_rate REAL, disq_rate REAL,
+ -- B
+ is_open_procedure INTEGER, is_direct_award INTEGER, has_exemption INTEGER,
+ is_outside_zop INTEGER, is_dps INTEGER, is_meat INTEGER, is_accelerated INTEGER,
+ is_framework INTEGER, is_eauction INTEGER, bid_window_days REAL, scoring_regime TEXT,
+ -- C
+ annex_count INTEGER, cost_overrun_ratio REAL, estimate_dev_ratio REAL,
+ value_flag TEXT, has_reason_text INTEGER, first_amend_shock INTEGER,
+ -- D
+ authority_hhi REAL, bidder_buyer_hhi REAL, repeat_win_intensity REAL,
+ sector_win_share REAL, pair_first_date TEXT, edge_age_years REAL, authority_suppliers INTEGER,
+ -- E
+ date_flag TEXT, eu_funded INTEGER, subcontract_passthrough REAL, corrections_count INTEGER,
+ duration_days INTEGER, winner_size TEXT, bidder_nuts TEXT, awarded_to_group INTEGER,
+ -- sub-scores [0,1], NULL when unknown
+ score_a REAL, score_b REAL, score_c REAL, score_d REAL, score_e REAL,
+ score_overall REAL, computed_at TEXT,
+ -- A1 leaf, auditable (§5.5/§5.6 PERCENT_RANK floor)
+ score_a_bids REAL, peer_has_multi INTEGER
+);
+-- Indexes are (re)created AFTER the staging swap below, on the live `contract_features` name —
+-- not here — so their names never collide with the still-live old table's same-named indexes
+-- while contract_features_next is being built.
+
+DROP TABLE IF EXISTS contract_regime;
+DROP TABLE IF EXISTS peer_fine_counts;
+DROP TABLE IF EXISTS peer_mid_counts;
+DROP TABLE IF EXISTS peer_coarse_counts;
+-- Scoring temp tables too: a run that dies mid-file (e.g. transient D1 lock, retried by
+-- import.mjs execWranglerD1File) must be able to re-execute the whole file cleanly.
+DROP TABLE IF EXISTS tmp_score_ctx;
+DROP TABLE IF EXISTS tmp_a1;
+DROP TABLE IF EXISTS tmp_peer_multi;
+DROP TABLE IF EXISTS tmp_b1;
+DROP TABLE IF EXISTS tmp_c;
+DROP TABLE IF EXISTS tmp_d;
+DROP TABLE IF EXISTS tmp_diag;
+
+DELETE FROM contract_features_next;
+
+-- ── contract_regime: family (§5.4/§12.2/§12.3) + peer-key components (§5.2-5.3), computed ONCE ──
+-- is_framework_regime: procedure_type ∈ the 5-value ДСП/КС regime set OR dps_contract=1 (§12.3 —
+-- contracts.framework is 100% NULL locally, so the OR-on-framework from the original §4.B6 text is
+-- dropped; dps_contract is also 100% NULL today but the OR stays so a future re-derive picks it up).
+-- LEFT JOIN tenders (§ orphan-row robustness): contracts.tender_id/bidder_id are NOT NULL columns
+-- (schema-enforced), but SQLite never enforces the REFERENCES itself without
+-- `PRAGMA foreign_keys=ON` — so a dangling tender_id is possible in principle. An INNER JOIN here
+-- would silently drop that contract from contract_regime (and, transitively, from
+-- contract_features_next below), tripping the summary's contract_features_rows == contracts_rows
+-- parity check with no diagnosis. LEFT JOIN + explicit 'unknown'/0 defaults for the t.id IS NULL
+-- case keeps every contract represented and scored as "unknown", never silently dropped.
+CREATE TABLE contract_regime AS
+SELECT
+ c.id AS contract_id,
+ CASE
+ WHEN t.id IS NULL THEN 0
+ WHEN t.procedure_type IN (
+ 'Динамична система за покупки', 'Квалификационна система',
+ 'Ограничена процедура по ДСП', 'Ограничена процедура по КС',
+ 'Договаряне с предварителна покана за участие по КС'
+ ) OR c.dps_contract = 1 THEN 1 ELSE 0
+ END AS is_framework_regime,
+ CASE
+ WHEN t.id IS NULL THEN 'unknown' -- orphan tender_id: no procedure data to classify on
+ WHEN t.procedure_type IN (
+ 'Динамична система за покупки', 'Квалификационна система',
+ 'Ограничена процедура по ДСП', 'Ограничена процедура по КС',
+ 'Договаряне с предварителна покана за участие по КС'
+ ) OR c.dps_contract = 1 THEN 'framework'
+ WHEN t.procedure_type IN ('Открита процедура', 'Публично състезание', 'Събиране на оферти с обява') THEN 'open'
+ WHEN t.procedure_type IN ('Ограничена процедура', 'Конкурс за проект - открит', 'Състезателна процедура с договаряне', 'Партньорство за иновации') THEN 'restricted'
+ WHEN t.procedure_type IN ('Договаряне с предварителна покана за участие', 'Договаряне с публикуване на обявление за поръчка', 'Договаряне без предварително обявление', 'Договаряне без предварителна покана за участие', 'Договаряне без публикуване на обявление за поръчка') THEN 'negotiated'
+ WHEN t.procedure_type IN ('Пряко договаряне', 'Покана до определени лица', 'Конкурс за проект - ограничен') THEN 'direct'
+ WHEN t.procedure_type = 'неизвестна' THEN 'unknown'
+ ELSE NULL -- completeness guard: verification asserts COUNT(*) WHERE family IS NULL = 0 (§12.2)
+ END AS family,
+ CASE WHEN t.id IS NULL OR t.cpv_code IS NULL OR LENGTH(TRIM(t.cpv_code)) < 2 THEN 'NA' ELSE substr(t.cpv_code, 1, 2) END AS division,
+ CASE
+ WHEN c.amount_eur IS NULL THEN 'NA'
+ WHEN c.amount_eur < 30000 THEN 'XS'
+ WHEN c.amount_eur < 200000 THEN 'S'
+ WHEN c.amount_eur < 1000000 THEN 'M'
+ WHEN c.amount_eur < 10000000 THEN 'L'
+ ELSE 'XL'
+ END AS band,
+ CASE
+ WHEN c.signed_at IS NULL OR strftime('%Y', c.signed_at) NOT BETWEEN '2020' AND '2026' THEN 'NA'
+ ELSE strftime('%Y', c.signed_at)
+ END AS yr,
+ c.bids_received AS bids_received_raw
+FROM contracts c LEFT JOIN tenders t ON t.id = c.tender_id;
+CREATE UNIQUE INDEX idx_contract_regime_id ON contract_regime(contract_id);
+
+-- ── 5a: raw leaf values + coverage flags ─────────────────────────────────────────────────────────
+WITH
+amendment_agg AS (
+ -- reason/circumstances are 100% NULL locally (§12.1) → MAX(LENGTH(...)) over all-NULL input is
+ -- NULL (SQLite MAX ignores NULLs, returns NULL if every input is NULL) → has_reason_text stays
+ -- NULL rather than fabricating 0, per "never fabricate defaults".
+ SELECT unp, contract_number, MAX(LENGTH(circumstances)) AS max_circ_len, COUNT(*) AS n
+ FROM amendments
+ WHERE unp IS NOT NULL AND contract_number IS NOT NULL
+ GROUP BY unp, contract_number
+),
+first_amend AS (
+ -- Earliest amendment per (unp, contract_number) — the join key used across the amendments table
+ -- (idx_amendments_contract), matched to tenders.source_id / contracts.contract_number (§4.C NEW-C6).
+ -- `currency` is carried through so the shock ratio below only compares like-denominated amounts —
+ -- amendments.currency is independent of contracts.currency (0000_init.sql:169 vs :118).
+ SELECT unp, contract_number, value_delta AS first_delta, published_at AS first_published_at, currency AS first_currency
+ FROM (
+ SELECT unp, contract_number, value_delta, published_at, currency,
+ ROW_NUMBER() OVER (PARTITION BY unp, contract_number ORDER BY published_at ASC, id ASC) AS rn
+ FROM amendments
+ WHERE unp IS NOT NULL AND contract_number IS NOT NULL
+ )
+ WHERE rn = 1
+)
+INSERT INTO contract_features_next (
+ contract_id,
+ coverage_bids, coverage_sme, coverage_estimate, coverage_overrun, coverage_ocds, score_coverage,
+ bids_received, single_offer, sme_rate, disq_rate,
+ is_open_procedure, is_direct_award, has_exemption, is_outside_zop, is_dps, is_meat,
+ is_accelerated, is_framework, is_eauction, bid_window_days, scoring_regime,
+ annex_count, cost_overrun_ratio, estimate_dev_ratio, value_flag, has_reason_text, first_amend_shock,
+ authority_hhi, bidder_buyer_hhi, repeat_win_intensity, sector_win_share, pair_first_date, edge_age_years, authority_suppliers,
+ date_flag, eu_funded, subcontract_passthrough, corrections_count, duration_days, winner_size, bidder_nuts, awarded_to_group,
+ computed_at
+)
+SELECT
+ c.id,
+ -- coverage_bids: bids_received=0 (2,845 rows) is treated as NULL for A-leaves, so it's uncovered too.
+ CASE WHEN c.bids_received IS NOT NULL AND c.bids_received <> 0 THEN 1 ELSE 0 END,
+ CASE WHEN c.bids_received > 0 AND c.bids_sme IS NOT NULL THEN 1 ELSE 0 END,
+ CASE WHEN t.estimated_value_eur IS NOT NULL THEN 1 ELSE 0 END,
+ CASE WHEN c.current_value_eur IS NOT NULL OR c.annex_count = 0 THEN 1 ELSE 0 END,
+ -- coverage_ocds: OCDS-era enrichment presence (winner_size / bidder NUTS) — a coverage FACET, not
+ -- one of the §6.1 score_coverage terms; used for the §10.10 era comparison, not the formula below.
+ CASE WHEN c.winner_size IS NOT NULL OR b.nuts IS NOT NULL THEN 1 ELSE 0 END,
+ ROUND((
+ (CASE WHEN c.bids_received IS NOT NULL AND c.bids_received <> 0 THEN 1.0 ELSE 0 END)
+ + (CASE WHEN c.bids_received > 0 AND c.bids_sme IS NOT NULL THEN 0.5 ELSE 0 END)
+ + (CASE WHEN c.signing_value_eur IS NOT NULL THEN 1.0 ELSE 0 END)
+ + (CASE WHEN c.current_value_eur IS NOT NULL OR c.annex_count = 0 THEN 1.0 ELSE 0 END)
+ + (CASE WHEN t.estimated_value_eur IS NOT NULL THEN 0.5 ELSE 0 END)
+ + (CASE WHEN t.procedure_type <> 'неизвестна' THEN 1.0 ELSE 0 END)
+ + (CASE WHEN ahr.hhi IS NOT NULL THEN 0.5 ELSE 0 END)
+ ) / 5.5, 3),
+ -- A
+ CASE WHEN c.bids_received = 0 THEN NULL ELSE c.bids_received END,
+ CASE WHEN c.bids_received = 1 THEN 1 WHEN c.bids_received IS NULL OR c.bids_received = 0 THEN NULL ELSE 0 END,
+ CASE WHEN COALESCE(c.bids_received, 0) > 0 AND c.bids_sme IS NOT NULL THEN CAST(c.bids_sme AS REAL) / c.bids_received END,
+ CASE WHEN c.bids_received IS NOT NULL AND c.bids_rejected IS NOT NULL
+ THEN CAST(c.bids_rejected AS REAL) / NULLIF(c.bids_received + c.bids_rejected, 0) END,
+ -- B
+ CASE WHEN r.family = 'unknown' THEN NULL WHEN r.family = 'open' THEN 1 ELSE 0 END,
+ CASE WHEN r.family = 'unknown' THEN NULL WHEN r.family = 'direct' THEN 1 ELSE 0 END,
+ -- has_exemption only meaningful once outside_zop=1; outside_zop is 100% NULL locally (§12.1) so
+ -- this stays NULL corpus-wide until a future re-derive populates it.
+ CASE WHEN c.outside_zop IS NULL THEN NULL
+ WHEN c.outside_zop = 1 THEN CASE WHEN c.exemption_legal_basis IS NOT NULL AND LENGTH(TRIM(c.exemption_legal_basis)) >= 20 THEN 1 ELSE 0 END
+ ELSE NULL END,
+ c.outside_zop,
+ c.dps_contract,
+ -- is_meat (§12.6): exact price-only match only; any combo (incl. `Разходи`) counts non-price-only.
+ CASE WHEN t.award_criteria IS NULL THEN NULL WHEN t.award_criteria = 'Най-ниска цена' THEN 0 ELSE 1 END,
+ c.accelerated,
+ c.framework,
+ c.eauction,
+ CASE WHEN t.deadline_at IS NOT NULL AND t.published_at IS NOT NULL THEN JULIANDAY(t.deadline_at) - JULIANDAY(t.published_at) END,
+ CASE WHEN r.is_framework_regime = 1 THEN 'framework' ELSE 'normal' END,
+ -- C
+ c.annex_count,
+ CASE WHEN c.value_flag IN ('annex_suspect', 'value_suspect') THEN NULL
+ WHEN c.annex_count = 0 THEN 1.0
+ WHEN c.signing_value_eur > 0 AND c.current_value_eur IS NOT NULL THEN c.current_value_eur / c.signing_value_eur
+ ELSE NULL END,
+ CASE WHEN r.is_framework_regime = 1 THEN NULL
+ WHEN t.procedure_type = 'неизвестна' THEN NULL
+ WHEN c.value_flag IN ('value_low', 'value_suspect') THEN NULL
+ WHEN t.estimated_value_eur IS NULL OR c.signing_value_eur IS NULL THEN NULL
+ ELSE ABS(c.signing_value_eur - t.estimated_value_eur) / NULLIF(t.estimated_value_eur, 0) END,
+ c.value_flag,
+ CASE WHEN aa.n IS NULL OR aa.max_circ_len IS NULL THEN NULL WHEN aa.max_circ_len >= 50 THEN 1 ELSE 0 END,
+ -- NULL (not 0) whenever the ratio isn't computable — an unscorable row must stay unknown, not
+ -- silently read as "no shock" (mirrors the has_reason_text NULL-propagation above). Requires the
+ -- amendment's currency to match the contract's booking currency (`c.currency`) since value_delta
+ -- is denominated in amendments.currency, independent of the contract's.
+ CASE WHEN fa.first_delta IS NULL THEN NULL
+ WHEN c.signing_value IS NULL OR c.signing_value <= 0 THEN NULL
+ WHEN fa.first_currency IS NOT NULL AND (c.currency IS NULL OR fa.first_currency <> c.currency) THEN NULL
+ WHEN fa.first_delta > 0 AND fa.first_delta > 0.30 * c.signing_value
+ AND (JULIANDAY(fa.first_published_at) - JULIANDAY(c.signed_at)) < 90 THEN 1
+ ELSE 0 END,
+ -- D
+ ahr.hhi,
+ bhr.buyer_hhi,
+ CASE WHEN fp.contracts IS NOT NULL AND at.contracts IS NOT NULL THEN fp.contracts * 1.0 / NULLIF(at.contracts, 0) END,
+ sc.win_share,
+ fp.first_date,
+ CASE WHEN c.signed_at IS NOT NULL AND fp.first_date IS NOT NULL THEN (JULIANDAY(c.signed_at) - JULIANDAY(fp.first_date)) / 365.25 END,
+ at.suppliers,
+ -- E
+ c.date_flag,
+ c.eu_funded,
+ CASE WHEN c.subcontract_value IS NOT NULL AND c.signing_value IS NOT NULL THEN c.subcontract_value * 1.0 / NULLIF(c.signing_value, 0) END,
+ t.corrections_count,
+ c.duration_days,
+ c.winner_size,
+ b.nuts,
+ c.awarded_to_group,
+ datetime('now')
+FROM contracts c
+LEFT JOIN tenders t ON t.id = c.tender_id
+LEFT JOIN bidders b ON b.id = c.bidder_id
+JOIN contract_regime r ON r.contract_id = c.id
+LEFT JOIN authority_health_rollup ahr ON ahr.authority_id = t.authority_id
+LEFT JOIN bidder_health_rollup bhr ON bhr.bidder_id = c.bidder_id
+LEFT JOIN authority_totals at ON at.authority_id = t.authority_id
+LEFT JOIN flow_pairs fp ON fp.authority_id = t.authority_id AND fp.bidder_id = c.bidder_id
+LEFT JOIN sector_concentration sc
+ ON sc.cpv_division = substr(t.cpv_code, 1, 2) AND sc.bidder_id = c.bidder_id
+ AND t.cpv_code IS NOT NULL AND LENGTH(t.cpv_code) >= 2
+LEFT JOIN amendment_agg aa ON aa.unp = t.source_id AND aa.contract_number = c.contract_number
+LEFT JOIN first_amend fa ON fa.unp = t.source_id AND fa.contract_number = c.contract_number;
+
+-- ── 5b: effective_peer_key selection (§5.6) ─────────────────────────────────────────────────────
+-- Three grouped count tables, built ONCE via GROUP BY (not a correlated COUNT(*) per contract row),
+-- restricted to bids_received >= 1 per spec. Finest key with peer_n >= 30 wins; else 'GLOBAL'.
+CREATE TABLE peer_fine_counts AS
+ SELECT division || ':' || band || ':' || family || ':' || yr AS peer_key, COUNT(*) AS n
+ FROM contract_regime WHERE bids_received_raw >= 1 GROUP BY peer_key;
+CREATE UNIQUE INDEX idx_peer_fine_key ON peer_fine_counts(peer_key);
+
+CREATE TABLE peer_mid_counts AS
+ SELECT division || ':' || band || ':' || family AS peer_key, COUNT(*) AS n
+ FROM contract_regime WHERE bids_received_raw >= 1 GROUP BY peer_key;
+CREATE UNIQUE INDEX idx_peer_mid_key ON peer_mid_counts(peer_key);
+
+CREATE TABLE peer_coarse_counts AS
+ SELECT division AS peer_key, COUNT(*) AS n
+ FROM contract_regime WHERE bids_received_raw >= 1 GROUP BY peer_key;
+CREATE UNIQUE INDEX idx_peer_coarse_key ON peer_coarse_counts(peer_key);
+
+UPDATE contract_features_next
+SET effective_peer_key = x.eff_key, peer_n = x.eff_n
+FROM (
+ SELECT
+ r.contract_id,
+ CASE
+ WHEN fc.n >= 30 THEN r.division || ':' || r.band || ':' || r.family || ':' || r.yr
+ WHEN mc.n >= 30 THEN r.division || ':' || r.band || ':' || r.family
+ WHEN cc.n >= 30 THEN r.division
+ ELSE 'GLOBAL'
+ END AS eff_key,
+ CASE
+ WHEN fc.n >= 30 THEN fc.n
+ WHEN mc.n >= 30 THEN mc.n
+ WHEN cc.n >= 30 THEN cc.n
+ ELSE (SELECT COUNT(*) FROM contract_regime WHERE bids_received_raw >= 1)
+ END AS eff_n
+ FROM contract_regime r
+ LEFT JOIN peer_fine_counts fc ON fc.peer_key = r.division || ':' || r.band || ':' || r.family || ':' || r.yr
+ LEFT JOIN peer_mid_counts mc ON mc.peer_key = r.division || ':' || r.band || ':' || r.family
+ LEFT JOIN peer_coarse_counts cc ON cc.peer_key = r.division
+) AS x
+WHERE x.contract_id = contract_features_next.contract_id;
+
+DROP TABLE contract_regime;
+DROP TABLE peer_fine_counts;
+DROP TABLE peer_mid_counts;
+DROP TABLE peer_coarse_counts;
+
+-- ── 5c: per-pillar score UPDATEs (§3, §4, §12 — [0,1] scale, §12.0) ─────────────────────────────
+-- tmp_score_ctx: raw contract/tender columns needed for scoring but not already carried on
+-- contract_features_next (procedure_type for B1 §12.2, cpv_division for B3, signed_at for the C1
+-- maturity gate, subcontractor_eik/subcontract_value for E1, exemption_legal_basis for B2).
+-- One O(n) join pass, reused by both the B- and E-pillar UPDATEs below (dropped at the very end).
+-- LEFT JOIN (orphan-row robustness, matches contract_regime above): an orphan tender_id must not
+-- drop the contract from this context table, or pillars C/E (which don't actually need tender
+-- data — only ctx.procedure_type/cpv_division do) would silently go unscored for it too.
+CREATE TABLE tmp_score_ctx AS
+SELECT c.id AS contract_id, t.procedure_type,
+ CASE WHEN t.cpv_code IS NULL OR LENGTH(TRIM(t.cpv_code)) < 2 THEN NULL ELSE substr(t.cpv_code, 1, 2) END AS cpv_division,
+ c.signed_at, c.subcontractor_eik, c.subcontract_value, c.exemption_legal_basis
+FROM contracts c LEFT JOIN tenders t ON t.id = c.tender_id;
+CREATE UNIQUE INDEX idx_tmp_score_ctx ON tmp_score_ctx(contract_id);
+
+-- A1 leaf (score_a_bids, stored — auditable §5.5/§5.6) + peer_has_multi (drives the AC's PERCENT_RANK
+-- floor proof). PERCENT_RANK is natively [0,1]; the GLOBAL fallback band (§4.A) is written pre-divided.
+CREATE TABLE tmp_a1 AS
+SELECT contract_id,
+ CASE
+ WHEN effective_peer_key <> 'GLOBAL'
+ THEN PERCENT_RANK() OVER (PARTITION BY effective_peer_key ORDER BY bids_received)
+ WHEN bids_received = 1 THEN 0.0
+ WHEN bids_received = 2 THEN 0.40
+ WHEN bids_received = 3 THEN 0.60
+ WHEN bids_received = 4 THEN 0.70
+ WHEN bids_received = 5 THEN 0.80
+ WHEN bids_received IN (6, 7) THEN 0.90
+ ELSE 1.0
+ END AS a1
+FROM contract_features_next
+WHERE bids_received >= 1;
+CREATE UNIQUE INDEX idx_tmp_a1 ON tmp_a1(contract_id);
+
+UPDATE contract_features_next SET score_a_bids = tmp_a1.a1
+FROM tmp_a1 WHERE tmp_a1.contract_id = contract_features_next.contract_id;
+
+CREATE TABLE tmp_peer_multi AS
+SELECT effective_peer_key, MAX(CASE WHEN bids_received >= 2 THEN 1 ELSE 0 END) AS has_multi
+FROM contract_features_next WHERE bids_received IS NOT NULL GROUP BY effective_peer_key;
+CREATE UNIQUE INDEX idx_tmp_peer_multi ON tmp_peer_multi(effective_peer_key);
+
+UPDATE contract_features_next SET peer_has_multi = tmp_peer_multi.has_multi
+FROM tmp_peer_multi WHERE tmp_peer_multi.effective_peer_key = contract_features_next.effective_peer_key;
+
+DROP TABLE tmp_a1;
+DROP TABLE tmp_peer_multi;
+
+-- ── Pillar A (Contestability, w=.30): weighted mean of A1(w3)/A3 sme-rate(w1) over non-NULL leaves;
+-- A4 disqualification modifier -0.10 when disq>0.5 & bids=1; A5 e-auction bonus +0.10; clamp [0,1].
+UPDATE contract_features_next
+SET score_a = CASE
+ WHEN score_a_bids IS NULL AND sme_rate IS NULL THEN NULL
+ ELSE ROUND(MAX(0.0, MIN(1.0,
+ ( COALESCE(score_a_bids, 0) * (CASE WHEN score_a_bids IS NOT NULL THEN 3 ELSE 0 END)
+ + COALESCE(sme_rate, 0) * (CASE WHEN sme_rate IS NOT NULL THEN 1 ELSE 0 END)
+ ) / ( (CASE WHEN score_a_bids IS NOT NULL THEN 3 ELSE 0 END)
+ + (CASE WHEN sme_rate IS NOT NULL THEN 1 ELSE 0 END) )
+ + CASE WHEN disq_rate > 0.5 AND bids_received = 1 THEN -0.10 ELSE 0 END
+ + CASE WHEN is_eauction = 1 THEN 0.10 ELSE 0 END
+ )), 3)
+END;
+
+-- ── Pillar B (Procedure openness, w=.15): B1 is the §12.2 frozen 21-value map (NULL for 'неизвестна'
+-- and the 2 pure framework-establishment procedures — Динамична система за покупки / Квалификационна
+-- система — which are a regime tag, not an award-openness route, §12.2 last row); B2 outside-ZOP
+-- penalty (all-NULL locally, §12.1, expression kept); B3 complex-service price-only -0.05 (§12.6
+-- exact-match test); B4 accelerated -0.15; B5 short bid-window on open & non-accelerated -0.10.
+-- `unmapped` proves the §12.2 completeness guard (surfaced in the summary SELECT below).
+CREATE TABLE tmp_b1 AS
+SELECT cf.contract_id,
+ CASE ctx.procedure_type
+ WHEN 'Открита процедура' THEN 1.00
+ WHEN 'Публично състезание' THEN 0.80
+ WHEN 'Събиране на оферти с обява' THEN 0.70
+ WHEN 'Ограничена процедура' THEN 0.60
+ WHEN 'Ограничена процедура по ДСП' THEN 0.60
+ WHEN 'Ограничена процедура по КС' THEN 0.60
+ WHEN 'Конкурс за проект - открит' THEN 0.60
+ WHEN 'Състезателна процедура с договаряне' THEN 0.60
+ WHEN 'Партньорство за иновации' THEN 0.60
+ WHEN 'Договаряне с предварителна покана за участие' THEN 0.40
+ WHEN 'Договаряне с предварителна покана за участие по КС' THEN 0.40
+ WHEN 'Договаряне с публикуване на обявление за поръчка' THEN 0.40
+ WHEN 'Договаряне без предварително обявление' THEN 0.20
+ WHEN 'Договаряне без предварителна покана за участие' THEN 0.20
+ WHEN 'Договаряне без публикуване на обявление за поръчка' THEN 0.20
+ WHEN 'Покана до определени лица' THEN 0.20
+ WHEN 'Конкурс за проект - ограничен' THEN 0.20
+ WHEN 'Пряко договаряне' THEN 0.00
+ WHEN 'неизвестна' THEN NULL
+ WHEN 'Динамична система за покупки' THEN NULL
+ WHEN 'Квалификационна система' THEN NULL
+ ELSE NULL
+ END AS b1,
+ CASE WHEN ctx.procedure_type IS NOT NULL AND ctx.procedure_type NOT IN (
+ 'Открита процедура', 'Публично състезание', 'Събиране на оферти с обява', 'Ограничена процедура',
+ 'Ограничена процедура по ДСП', 'Ограничена процедура по КС', 'Конкурс за проект - открит',
+ 'Състезателна процедура с договаряне', 'Партньорство за иновации',
+ 'Договаряне с предварителна покана за участие', 'Договаряне с предварителна покана за участие по КС',
+ 'Договаряне с публикуване на обявление за поръчка', 'Договаряне без предварително обявление',
+ 'Договаряне без предварителна покана за участие', 'Договаряне без публикуване на обявление за поръчка',
+ 'Покана до определени лица', 'Конкурс за проект - ограничен', 'Пряко договаряне', 'неизвестна',
+ 'Динамична система за покупки', 'Квалификационна система'
+ ) THEN 1 ELSE 0 END AS unmapped
+FROM contract_features_next cf JOIN tmp_score_ctx ctx ON ctx.contract_id = cf.contract_id;
+CREATE UNIQUE INDEX idx_tmp_b1 ON tmp_b1(contract_id);
+
+-- Stashed (not SELECTed) here: local D1 runs the whole file as one batch, and a bare SELECT on
+-- tmp_b1 would leave a cursor that makes the DROP TABLE below fail with SQLITE_LOCKED. The final
+-- summary SELECT surfaces it as unmapped_procedure_rows.
+CREATE TABLE tmp_diag AS
+SELECT COUNT(*) AS unmapped_procedure_rows FROM tmp_b1 WHERE unmapped = 1;
+
+UPDATE contract_features_next
+SET score_b = CASE
+ WHEN w.b1 IS NULL THEN NULL
+ ELSE ROUND(MAX(0.0, MIN(1.0,
+ w.b1
+ + CASE WHEN contract_features_next.is_outside_zop = 1 AND w.exemption_legal_basis IS NULL THEN -0.20
+ WHEN contract_features_next.is_outside_zop = 1 AND LENGTH(TRIM(COALESCE(w.exemption_legal_basis, ''))) < 20 THEN -0.10
+ ELSE 0 END
+ + CASE WHEN w.cpv_division IN ('71', '72', '73', '79', '80', '85') AND contract_features_next.is_meat = 0 THEN -0.05 ELSE 0 END
+ + CASE WHEN contract_features_next.is_accelerated = 1 THEN -0.15 ELSE 0 END
+ -- >= 0 floor: negative windows are date errors (deadline before publication), not short windows
+ + CASE WHEN contract_features_next.bid_window_days >= 0 AND contract_features_next.bid_window_days < 15
+ AND contract_features_next.is_open_procedure = 1
+ AND contract_features_next.is_accelerated = 0 THEN -0.10 ELSE 0 END
+ )), 3)
+END
+FROM (SELECT b1.contract_id, b1.b1, ctx.exemption_legal_basis, ctx.cpv_division
+ FROM tmp_b1 b1 JOIN tmp_score_ctx ctx ON ctx.contract_id = b1.contract_id) AS w
+WHERE w.contract_id = contract_features_next.contract_id;
+
+DROP TABLE tmp_b1;
+
+-- ── Pillar C (Value integrity, w=.25): C1 annex band + maturity gate; C2 overrun band (leaf already
+-- NULL-gated for annex_suspect/value_suspect, §3.4); C3 estimate-accuracy band (leaf already NULL-
+-- gated for framework/synthetic/value_low/value_suspect, §3.4/§4.C); equal-weighted mean of the
+-- non-NULL leaves; C4 boilerplate-reason penalty -0.15 (all-NULL locally, §12.1, expression kept);
+-- C6 first-amendment-shock penalty -0.10; `review` -> whole pillar x0.90; `value_suspect` -> pillar
+-- NULL outright (C1/C3 would otherwise still compute — the gate table requires suppressing all of C).
+CREATE TABLE tmp_c AS
+SELECT cf.contract_id,
+ CASE
+ WHEN (JULIANDAY('now') - JULIANDAY(ctx.signed_at)) < 90 AND cf.annex_count = 0 THEN NULL
+ WHEN cf.annex_count IS NULL THEN NULL
+ WHEN cf.annex_count = 0 THEN 1.0
+ WHEN cf.annex_count = 1 THEN 0.85
+ WHEN cf.annex_count = 2 THEN 0.70
+ WHEN cf.annex_count = 3 THEN 0.50
+ WHEN cf.annex_count = 4 THEN 0.30
+ ELSE 0.0
+ END AS c1,
+ -- C2 uses the spec's "compact" linear variant (1.2x -> 0.80, 1.5x -> 0.50), not the §4.C
+ -- piecewise band (1.2x -> 0.60) — the two are inconsistent in the spec and §12 doesn't
+ -- resolve it; linear is chosen as the smoother, easier-to-explain mapping.
+ CASE
+ WHEN cf.cost_overrun_ratio IS NULL THEN NULL
+ WHEN cf.cost_overrun_ratio <= 1.0 THEN 1.0
+ WHEN cf.cost_overrun_ratio >= 2.0 THEN 0.0
+ ELSE MAX(0.0, MIN(1.0, 1.0 - (cf.cost_overrun_ratio - 1.0)))
+ END AS c2,
+ CASE
+ WHEN cf.estimate_dev_ratio IS NULL THEN NULL
+ WHEN cf.estimate_dev_ratio <= 0.05 THEN 1.0
+ WHEN cf.estimate_dev_ratio <= 0.30 THEN 1.0 - 0.30 * (cf.estimate_dev_ratio - 0.05) / 0.25
+ WHEN cf.estimate_dev_ratio <= 1.00 THEN 0.70 - 0.40 * (cf.estimate_dev_ratio - 0.30) / 0.70
+ WHEN cf.estimate_dev_ratio <= 2.00 THEN 0.30 - 0.30 * (cf.estimate_dev_ratio - 1.00) / 1.00
+ ELSE 0.0
+ END AS c3
+FROM contract_features_next cf JOIN tmp_score_ctx ctx ON ctx.contract_id = cf.contract_id;
+CREATE UNIQUE INDEX idx_tmp_c ON tmp_c(contract_id);
+
+UPDATE contract_features_next
+SET score_c = CASE
+ WHEN contract_features_next.value_flag = 'value_suspect' THEN NULL
+ WHEN w.n_leaves = 0 THEN NULL
+ ELSE ROUND(
+ MAX(0.0, MIN(1.0,
+ w.leaf_sum / w.n_leaves
+ + CASE WHEN contract_features_next.has_reason_text = 0 THEN -0.15 ELSE 0 END
+ + CASE WHEN contract_features_next.first_amend_shock = 1 THEN -0.10 ELSE 0 END
+ )) * (CASE WHEN contract_features_next.value_flag = 'review' THEN 0.90 ELSE 1.0 END)
+ , 3)
+END
+FROM (
+ SELECT contract_id,
+ COALESCE(c1, 0) + COALESCE(c2, 0) + COALESCE(c3, 0) AS leaf_sum,
+ (CASE WHEN c1 IS NOT NULL THEN 1 ELSE 0 END)
+ + (CASE WHEN c2 IS NOT NULL THEN 1 ELSE 0 END)
+ + (CASE WHEN c3 IS NOT NULL THEN 1 ELSE 0 END) AS n_leaves
+ FROM tmp_c
+) AS w
+WHERE w.contract_id = contract_features_next.contract_id;
+
+DROP TABLE tmp_c;
+
+-- ── Pillar D (Relationship health, w=.20): D1/D2 buyer/supplier HHI-inverse averaged into a single
+-- 0.1-weight context term (§4.D grain caveat); D3 repeat-win intensity w=0.5 (primary contract-
+-- discriminating leaf); D4 edge-novelty band w=0.3; D5 sector win-share w=0.1. Weighted mean over
+-- non-NULL components, renormalized; clamp [0,1].
+CREATE TABLE tmp_d AS
+SELECT cf.contract_id,
+ CASE
+ WHEN cf.authority_hhi IS NULL AND cf.bidder_buyer_hhi IS NULL THEN NULL
+ ELSE (
+ COALESCE(MAX(0.0, MIN(1.0, 1.0 - cf.authority_hhi)), MAX(0.0, MIN(1.0, 1.0 - cf.bidder_buyer_hhi)))
+ + COALESCE(MAX(0.0, MIN(1.0, 1.0 - cf.bidder_buyer_hhi)), MAX(0.0, MIN(1.0, 1.0 - cf.authority_hhi)))
+ ) / 2.0
+ END AS d12,
+ CASE WHEN cf.repeat_win_intensity IS NOT NULL THEN MAX(0.0, MIN(1.0, 1.0 - cf.repeat_win_intensity)) END AS d3,
+ CASE
+ WHEN cf.edge_age_years IS NULL THEN NULL
+ WHEN cf.edge_age_years < 1 THEN 1.00
+ WHEN cf.edge_age_years < 2 THEN 0.80
+ WHEN cf.edge_age_years < 4 THEN 0.55
+ WHEN cf.edge_age_years < 7 THEN 0.30
+ ELSE 0.10
+ END AS d4,
+ CASE WHEN cf.sector_win_share IS NOT NULL THEN MAX(0.0, MIN(1.0, 1.0 - cf.sector_win_share)) END AS d5
+FROM contract_features_next cf;
+CREATE UNIQUE INDEX idx_tmp_d ON tmp_d(contract_id);
+
+UPDATE contract_features_next
+SET score_d = CASE WHEN w.wsum = 0 THEN NULL ELSE ROUND(MAX(0.0, MIN(1.0, w.wnum / w.wsum)), 3) END
+FROM (
+ SELECT contract_id,
+ (CASE WHEN d12 IS NOT NULL THEN 0.1 ELSE 0 END) + (CASE WHEN d3 IS NOT NULL THEN 0.5 ELSE 0 END)
+ + (CASE WHEN d4 IS NOT NULL THEN 0.3 ELSE 0 END) + (CASE WHEN d5 IS NOT NULL THEN 0.1 ELSE 0 END) AS wsum,
+ COALESCE(d12, 0) * (CASE WHEN d12 IS NOT NULL THEN 0.1 ELSE 0 END)
+ + COALESCE(d3, 0) * (CASE WHEN d3 IS NOT NULL THEN 0.5 ELSE 0 END)
+ + COALESCE(d4, 0) * (CASE WHEN d4 IS NOT NULL THEN 0.3 ELSE 0 END)
+ + COALESCE(d5, 0) * (CASE WHEN d5 IS NOT NULL THEN 0.1 ELSE 0 END) AS wnum
+ FROM tmp_d
+) AS w
+WHERE w.contract_id = contract_features_next.contract_id;
+
+DROP TABLE tmp_d;
+
+-- ── Pillar E (Transparency/data quality, w=.10): base 1.0 minus penalties, always computable (no
+-- NULL-propagating leaf — missing inputs simply contribute no penalty, per §4.E). E1 undisclosed
+-- subcontract -0.05; E2 date_flag -0.10; E3 pass-through -0.10/-0.15; E4 corrigenda (all-NULL
+-- locally, §12.1, expression kept); E5 lock-in -0.10/-0.15 keyed on scoring_regime (§12.3, NOT
+-- framework=0 — contracts.framework is 100% NULL locally). Floored at 0.
+UPDATE contract_features_next
+SET score_e = ROUND(MAX(0.0,
+ 1.0
+ - CASE WHEN ctx.subcontractor_eik IS NOT NULL AND ctx.subcontract_value IS NULL THEN 0.05 ELSE 0 END
+ - CASE WHEN contract_features_next.date_flag = 'signed_after_publication' THEN 0.10 ELSE 0 END
+ - CASE WHEN contract_features_next.subcontract_passthrough >= 1.0 THEN 0.15
+ WHEN contract_features_next.subcontract_passthrough > 0.70 THEN 0.10 ELSE 0 END
+ - CASE WHEN contract_features_next.corrections_count >= 3 THEN 0.10 ELSE 0 END
+ - CASE WHEN contract_features_next.duration_days > 1825 AND contract_features_next.scoring_regime <> 'framework' THEN 0.15
+ WHEN contract_features_next.duration_days > 1095 AND contract_features_next.scoring_regime <> 'framework' THEN 0.10 ELSE 0 END
+ ), 3)
+FROM tmp_score_ctx ctx
+WHERE ctx.contract_id = contract_features_next.contract_id;
+
+DROP TABLE tmp_score_ctx;
+
+-- ── score_overall = ROUND(0.6*wmean + 0.4*worst, 3) over non-NULL pillars, renormalized (§3.3/§12.0).
+-- Withheld (NULL) for value_suspect (§3.4) and score_coverage < 0.40 (§6.2 withhold rule) — the only
+-- two NULL paths, matching the AC's >=90%-scored expectation.
+UPDATE contract_features_next
+SET score_overall = CASE
+ WHEN contract_features_next.value_flag = 'value_suspect' THEN NULL
+ WHEN contract_features_next.score_coverage < 0.40 THEN NULL
+ WHEN w.wsum = 0 THEN NULL
+ ELSE ROUND(0.6 * w.wmean + 0.4 * w.worst, 3)
+END
+FROM (
+ SELECT contract_id,
+ (CASE WHEN score_a IS NOT NULL THEN 0.30 ELSE 0 END) + (CASE WHEN score_b IS NOT NULL THEN 0.15 ELSE 0 END)
+ + (CASE WHEN score_c IS NOT NULL THEN 0.25 ELSE 0 END) + (CASE WHEN score_d IS NOT NULL THEN 0.20 ELSE 0 END)
+ + (CASE WHEN score_e IS NOT NULL THEN 0.10 ELSE 0 END) AS wsum,
+ ( COALESCE(score_a, 0) * 0.30 + COALESCE(score_b, 0) * 0.15 + COALESCE(score_c, 0) * 0.25
+ + COALESCE(score_d, 0) * 0.20 + COALESCE(score_e, 0) * 0.10 )
+ / NULLIF(
+ (CASE WHEN score_a IS NOT NULL THEN 0.30 ELSE 0 END) + (CASE WHEN score_b IS NOT NULL THEN 0.15 ELSE 0 END)
+ + (CASE WHEN score_c IS NOT NULL THEN 0.25 ELSE 0 END) + (CASE WHEN score_d IS NOT NULL THEN 0.20 ELSE 0 END)
+ + (CASE WHEN score_e IS NOT NULL THEN 0.10 ELSE 0 END), 0) AS wmean,
+ MIN(COALESCE(score_a, 1.0), COALESCE(score_b, 1.0), COALESCE(score_c, 1.0), COALESCE(score_d, 1.0), COALESCE(score_e, 1.0)) AS worst
+ FROM contract_features_next
+) AS w
+WHERE w.contract_id = contract_features_next.contract_id;
+
+-- ── Atomic staging swap: contract_features_next is fully built and scored above, off the live
+-- `contract_features` name — this DROP+RENAME pair is the only moment the served name changes,
+-- and it lands back-to-back in the same `wrangler d1 execute --file` batch as everything below,
+-- so a request hitting served D1 mid-rebuild never sees `contract_features` missing/empty; it
+-- sees either the complete prior day's table or the complete new one, never a gap.
+DROP TABLE IF EXISTS contract_features;
+ALTER TABLE contract_features_next RENAME TO contract_features;
+CREATE INDEX IF NOT EXISTS idx_contract_features_overall ON contract_features(score_overall);
+CREATE INDEX IF NOT EXISTS idx_contract_features_peer ON contract_features(effective_peer_key);
+
+-- Summary (last result set printed by `wrangler d1 execute`). unmapped_family_rows must be 0 — the
+-- §12.2 completeness guard for the 21-value procedure_type vocabulary. contract_features_rows must
+-- equal contracts_rows — contract_regime/the leaf INSERT LEFT JOIN tenders/bidders (§ orphan-row
+-- robustness above), so every contract gets a row even if contracts.bidder_id/tender_id ever points
+-- at a missing row (SQLite doesn't enforce FKs unless PRAGMA foreign_keys=ON).
+SELECT
+ (SELECT COUNT(*) FROM contracts) AS contracts_rows,
+ (SELECT COUNT(*) FROM contract_features) AS contract_features_rows,
+ (SELECT unmapped_procedure_rows FROM tmp_diag) AS unmapped_procedure_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE score_coverage IS NULL) AS null_coverage_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE effective_peer_key IS NULL) AS null_peer_key_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE scoring_regime = 'framework') AS framework_regime_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE single_offer = 1) AS single_offer_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE score_overall IS NOT NULL) AS scored_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE value_flag = 'value_suspect' AND (score_overall IS NOT NULL OR score_c IS NOT NULL)) AS value_suspect_leak_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE value_flag = 'annex_suspect' AND (cost_overrun_ratio IS NOT NULL OR score_c IS NULL)) AS annex_suspect_bad_rows,
+ (SELECT COUNT(*) FROM contract_features WHERE single_offer = 1 AND score_a_bids > 0 AND peer_has_multi = 1) AS a1_floor_violations,
+ (SELECT COUNT(*) FROM contract_features cf JOIN contracts c ON c.id = cf.contract_id JOIN tenders t ON t.id = c.tender_id
+ WHERE t.procedure_type = 'Пряко договаряне' AND cf.score_b <> 0) AS direct_award_b1_nonzero,
+ -- Nested 3+3 (not one 6-term UNION ALL chain): local D1 enforces a low
+ -- SQLITE_MAX_COMPOUND_SELECT, so a flat 6-term compound fails with
+ -- "too many terms in compound SELECT". Each inner compound stays ≤ 3 terms.
+ (SELECT MIN(x) FROM (
+ SELECT x FROM (SELECT score_a AS x FROM contract_features WHERE score_a IS NOT NULL
+ UNION ALL SELECT score_b FROM contract_features WHERE score_b IS NOT NULL
+ UNION ALL SELECT score_c FROM contract_features WHERE score_c IS NOT NULL)
+ UNION ALL
+ SELECT x FROM (SELECT score_d AS x FROM contract_features WHERE score_d IS NOT NULL
+ UNION ALL SELECT score_e FROM contract_features WHERE score_e IS NOT NULL
+ UNION ALL SELECT score_overall FROM contract_features WHERE score_overall IS NOT NULL))) AS min_any_score,
+ (SELECT MAX(x) FROM (
+ SELECT x FROM (SELECT score_a AS x FROM contract_features WHERE score_a IS NOT NULL
+ UNION ALL SELECT score_b FROM contract_features WHERE score_b IS NOT NULL
+ UNION ALL SELECT score_c FROM contract_features WHERE score_c IS NOT NULL)
+ UNION ALL
+ SELECT x FROM (SELECT score_d AS x FROM contract_features WHERE score_d IS NOT NULL
+ UNION ALL SELECT score_e FROM contract_features WHERE score_e IS NOT NULL
+ UNION ALL SELECT score_overall FROM contract_features WHERE score_overall IS NOT NULL))) AS max_any_score;
+
+-- ── 5e: aggregate UI rollups — six *_quality_totals grains (§7.4/§9/§12.7) ──────────────────────
+-- Universal rule (§9): the `score_overall`/`score_X IS NOT NULL` mask excludes unknown/value_suspect
+-- rows from BOTH the numerator and the denominator of every weighted average (CASE inside SUM on
+-- both sides) — `total_contracts` still counts them via COUNT(*). Authority/bidder are value-weighted
+-- with the 15% single-contract cap (§7.4 literal `MIN(amount_eur, 0.15*SUM(...) OVER (PARTITION BY
+-- ...))`); sector/region/funding are value-weighted uncapped; year is count-weighted (`AVG`, §9) for
+-- year-over-year comparability. CREATE TABLE IF NOT EXISTS + DELETE + INSERT, same idiom as
+-- derive-health.sql (§12.7) — these tables never change shape across re-derives, unlike
+-- contract_features above.
+
+CREATE TABLE IF NOT EXISTS authority_quality_totals (
+ authority_id TEXT PRIMARY KEY REFERENCES authorities(id), name TEXT NOT NULL, type_group TEXT,
+ avg_overall REAL, avg_a REAL, avg_b REAL, avg_c REAL, avg_d REAL, avg_e REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER NOT NULL, unknown_contracts INTEGER,
+ single_offer_count INTEGER, direct_award_count INTEGER, amended_count INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+DELETE FROM authority_quality_totals;
+WITH w AS (
+ SELECT t.authority_id AS aid, cf.*, c.amount_eur,
+ MIN(c.amount_eur, 0.15 * SUM(c.amount_eur) OVER (PARTITION BY t.authority_id)) AS wt
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ WHERE c.amount_eur IS NOT NULL
+)
+INSERT INTO authority_quality_totals
+SELECT w.aid, a.name, a.type_group,
+ ROUND(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.score_overall * w.wt END) / NULLIF(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.wt END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_a IS NOT NULL THEN w.score_a * w.wt END) / NULLIF(SUM(CASE WHEN w.score_a IS NOT NULL THEN w.wt END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_b IS NOT NULL THEN w.score_b * w.wt END) / NULLIF(SUM(CASE WHEN w.score_b IS NOT NULL THEN w.wt END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_c IS NOT NULL THEN w.score_c * w.wt END) / NULLIF(SUM(CASE WHEN w.score_c IS NOT NULL THEN w.wt END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_d IS NOT NULL THEN w.score_d * w.wt END) / NULLIF(SUM(CASE WHEN w.score_d IS NOT NULL THEN w.wt END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_e IS NOT NULL THEN w.score_e * w.wt END) / NULLIF(SUM(CASE WHEN w.score_e IS NOT NULL THEN w.wt END), 0), 3),
+ COUNT(*),
+ SUM(CASE WHEN w.score_overall IS NOT NULL THEN 1 ELSE 0 END),
+ SUM(CASE WHEN w.score_overall IS NULL THEN 1 ELSE 0 END),
+ SUM(CASE WHEN w.single_offer = 1 THEN 1 ELSE 0 END),
+ SUM(CASE WHEN w.is_direct_award = 1 THEN 1 ELSE 0 END),
+ SUM(CASE WHEN w.annex_count > 0 THEN 1 ELSE 0 END),
+ ROUND(SUM(w.score_coverage * w.amount_eur) / NULLIF(SUM(w.amount_eur), 0), 3),
+ datetime('now')
+FROM w JOIN authorities a ON a.id = w.aid
+GROUP BY w.aid;
+
+CREATE TABLE IF NOT EXISTS bidder_quality_totals (
+ bidder_id TEXT PRIMARY KEY REFERENCES bidders(id), name TEXT NOT NULL,
+ avg_overall REAL, avg_c REAL, avg_d REAL, buyer_hhi REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER NOT NULL, amended_count INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+DELETE FROM bidder_quality_totals;
+WITH w AS (
+ SELECT c.bidder_id AS bid, cf.*, c.amount_eur,
+ MIN(c.amount_eur, 0.15 * SUM(c.amount_eur) OVER (PARTITION BY c.bidder_id)) AS wt
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ WHERE c.amount_eur IS NOT NULL
+)
+INSERT INTO bidder_quality_totals
+SELECT w.bid, b.name,
+ ROUND(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.score_overall * w.wt END) / NULLIF(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.wt END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_c IS NOT NULL THEN w.score_c * w.wt END) / NULLIF(SUM(CASE WHEN w.score_c IS NOT NULL THEN w.wt END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_d IS NOT NULL THEN w.score_d * w.wt END) / NULLIF(SUM(CASE WHEN w.score_d IS NOT NULL THEN w.wt END), 0), 3),
+ MAX(w.bidder_buyer_hhi),
+ COUNT(*),
+ SUM(CASE WHEN w.score_overall IS NOT NULL THEN 1 ELSE 0 END),
+ SUM(CASE WHEN w.annex_count > 0 THEN 1 ELSE 0 END),
+ ROUND(SUM(w.score_coverage * w.amount_eur) / NULLIF(SUM(w.amount_eur), 0), 3),
+ datetime('now')
+FROM w JOIN bidders b ON b.id = w.bid
+GROUP BY w.bid;
+
+CREATE TABLE IF NOT EXISTS sector_quality_totals ( -- CPV division
+ division TEXT PRIMARY KEY, avg_overall REAL, avg_a REAL, avg_c REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, single_offer_pct REAL,
+ direct_award_pct REAL, mean_coverage REAL, computed_at TEXT
+);
+DELETE FROM sector_quality_totals;
+WITH w AS (
+ SELECT CASE WHEN t.cpv_code IS NULL OR LENGTH(TRIM(t.cpv_code)) < 2 THEN 'NA' ELSE substr(t.cpv_code, 1, 2) END AS division,
+ cf.*, c.amount_eur
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ WHERE c.amount_eur IS NOT NULL
+)
+INSERT INTO sector_quality_totals
+SELECT w.division,
+ ROUND(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.score_overall * w.amount_eur END) / NULLIF(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.amount_eur END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_a IS NOT NULL THEN w.score_a * w.amount_eur END) / NULLIF(SUM(CASE WHEN w.score_a IS NOT NULL THEN w.amount_eur END), 0), 3),
+ ROUND(SUM(CASE WHEN w.score_c IS NOT NULL THEN w.score_c * w.amount_eur END) / NULLIF(SUM(CASE WHEN w.score_c IS NOT NULL THEN w.amount_eur END), 0), 3),
+ COUNT(*),
+ SUM(CASE WHEN w.score_overall IS NOT NULL THEN 1 ELSE 0 END),
+ ROUND(100.0 * SUM(CASE WHEN w.single_offer = 1 THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2),
+ ROUND(100.0 * SUM(CASE WHEN w.is_direct_award = 1 THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 2),
+ ROUND(SUM(w.score_coverage * w.amount_eur) / NULLIF(SUM(w.amount_eur), 0), 3),
+ datetime('now')
+FROM w
+GROUP BY w.division;
+
+CREATE TABLE IF NOT EXISTS region_quality_totals ( -- NUTS of performance (tenders.place_of_performance)
+ nuts TEXT PRIMARY KEY, nuts_label TEXT, avg_overall REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, mean_coverage REAL, computed_at TEXT
+);
+DELETE FROM region_quality_totals;
+WITH w AS (
+ SELECT COALESCE(t.place_of_performance, 'NA') AS nuts, cf.*, c.amount_eur
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ WHERE c.amount_eur IS NOT NULL
+)
+INSERT INTO region_quality_totals
+SELECT w.nuts, n.nuts3_name,
+ ROUND(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.score_overall * w.amount_eur END) / NULLIF(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.amount_eur END), 0), 3),
+ COUNT(*),
+ SUM(CASE WHEN w.score_overall IS NOT NULL THEN 1 ELSE 0 END),
+ ROUND(SUM(w.score_coverage * w.amount_eur) / NULLIF(SUM(w.amount_eur), 0), 3),
+ datetime('now')
+FROM w LEFT JOIN nuts_regions n ON n.nuts3 = w.nuts
+GROUP BY w.nuts;
+
+CREATE TABLE IF NOT EXISTS year_quality_totals ( -- count-weighted (trend comparability)
+ year TEXT PRIMARY KEY, avg_overall REAL, avg_a REAL, avg_b REAL, avg_c REAL, avg_d REAL, avg_e REAL,
+ total_contracts INTEGER NOT NULL, scored_contracts INTEGER, mean_coverage REAL, computed_at TEXT
+);
+DELETE FROM year_quality_totals;
+WITH w AS (
+ SELECT CASE WHEN c.signed_at IS NULL OR strftime('%Y', c.signed_at) NOT BETWEEN '2020' AND '2026'
+ THEN 'NA' ELSE strftime('%Y', c.signed_at) END AS yr,
+ cf.*
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+)
+INSERT INTO year_quality_totals
+SELECT w.yr,
+ ROUND(AVG(w.score_overall), 3), ROUND(AVG(w.score_a), 3), ROUND(AVG(w.score_b), 3),
+ ROUND(AVG(w.score_c), 3), ROUND(AVG(w.score_d), 3), ROUND(AVG(w.score_e), 3),
+ COUNT(*),
+ SUM(CASE WHEN w.score_overall IS NOT NULL THEN 1 ELSE 0 END),
+ ROUND(AVG(w.score_coverage), 3),
+ datetime('now')
+FROM w
+GROUP BY w.yr;
+
+CREATE TABLE IF NOT EXISTS funding_quality_totals ( -- eu_funded 0/1
+ funding_key TEXT PRIMARY KEY, -- 'eu' | 'national'
+ avg_overall REAL, total_contracts INTEGER NOT NULL, scored_contracts INTEGER,
+ mean_coverage REAL, computed_at TEXT
+);
+DELETE FROM funding_quality_totals;
+WITH w AS (
+ SELECT CASE WHEN c.eu_funded = 1 THEN 'eu' ELSE 'national' END AS funding_key, cf.*, c.amount_eur
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ WHERE c.amount_eur IS NOT NULL
+)
+INSERT INTO funding_quality_totals
+SELECT w.funding_key,
+ ROUND(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.score_overall * w.amount_eur END) / NULLIF(SUM(CASE WHEN w.score_overall IS NOT NULL THEN w.amount_eur END), 0), 3),
+ COUNT(*),
+ SUM(CASE WHEN w.score_overall IS NOT NULL THEN 1 ELSE 0 END),
+ ROUND(SUM(w.score_coverage * w.amount_eur) / NULLIF(SUM(w.amount_eur), 0), 3),
+ datetime('now')
+FROM w
+GROUP BY w.funding_key;
+
+-- Rollup summary (second result set) — six tables non-empty, avg_overall in [0,1].
+SELECT
+ (SELECT COUNT(*) FROM authority_quality_totals) AS authority_rows,
+ (SELECT COUNT(*) FROM bidder_quality_totals) AS bidder_rows,
+ (SELECT COUNT(*) FROM sector_quality_totals) AS sector_rows,
+ (SELECT COUNT(*) FROM region_quality_totals) AS region_rows,
+ (SELECT COUNT(*) FROM year_quality_totals) AS year_rows,
+ (SELECT COUNT(*) FROM funding_quality_totals) AS funding_rows;
diff --git a/scripts/derive-health.sql b/scripts/derive-health.sql
new file mode 100644
index 000000000..8aca24e78
--- /dev/null
+++ b/scripts/derive-health.sql
@@ -0,0 +1,186 @@
+-- Sigma — Contract Quality / Health Index, Phase 4: entity-grain rollups the per-contract scoring
+-- (Phase 5, next PRD group) joins against. Run AFTER scripts/precompute.sql has (re)built
+-- flow_pairs/authority_totals/tenders.estimated_value_eur on the served D1:
+-- (cd apps/web && wrangler d1 execute sigma --local --file ../../scripts/derive-health.sql)
+--
+-- Spec: Contract Quality / Health Index design spec §7.2 (table DDL + INSERT bodies) + §8 (build order).
+-- §12 corrections override earlier sections on conflict (score scale, procedure-type vocabulary —
+-- neither concerns these four tables, which are raw fractions/counts, not [0,1] pillar scores).
+--
+-- IDEMPOTENT: CREATE TABLE IF NOT EXISTS + DELETE + INSERT, same idiom as scripts/precompute.sql.
+-- PORTABLE SQLite ONLY: no POWER/LN/EXP/SQRT — HHI as (x)*(x); percentiles via LIMIT 1 OFFSET.
+--
+-- PERFORMANCE: authority_health_rollup's HHI is computed via a two-step aggregate-then-join
+-- (authority_won -> authority_won_totals -> grouped join), NOT the spec's literal correlated
+-- subquery-per-authority-row — same numbers, one pass over flow_pairs instead of one subquery
+-- execution per authority. Every other INSERT below is a single-pass GROUP BY over contracts/
+-- flow_pairs (O(n log n) in the sort/hash the query planner picks, n = contracts_with_bids or
+-- flow_pairs rows, both well under 200k).
+
+-- ── authority_health_rollup ───────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS authority_health_rollup (
+ authority_id TEXT PRIMARY KEY REFERENCES authorities(id),
+ hhi REAL, -- SUM((won/total)*(won/total)) over the authority's bidders
+ single_offer_share REAL, -- bids_received=1 / known-bids contracts
+ direct_award_share REAL, -- procedure_type='Пряко договаряне' / total
+ avg_annex_count REAL,
+ avg_cost_overrun REAL, -- mean current/signing where it grew
+ cancelled_share REAL, -- tenders.cancelled=1 / tenders (authority)
+ contracts_with_bids INTEGER,
+ total_contracts INTEGER
+);
+DELETE FROM authority_health_rollup;
+WITH authority_won AS (
+ SELECT authority_id, bidder_id, won_eur FROM flow_pairs
+),
+authority_won_totals AS (
+ SELECT authority_id, SUM(won_eur) AS total_won_eur FROM authority_won GROUP BY authority_id
+),
+authority_hhi AS (
+ SELECT w.authority_id,
+ SUM((w.won_eur / NULLIF(t.total_won_eur,0)) * (w.won_eur / NULLIF(t.total_won_eur,0))) AS hhi
+ FROM authority_won w JOIN authority_won_totals t ON t.authority_id = w.authority_id
+ GROUP BY w.authority_id
+),
+authority_stats AS (
+ SELECT t.authority_id AS authority_id,
+ SUM(CASE WHEN c.bids_received = 1 THEN 1.0 ELSE 0 END)
+ / NULLIF(SUM(CASE WHEN c.bids_received IS NOT NULL THEN 1 ELSE 0 END),0) AS single_offer_share,
+ SUM(CASE WHEN t.procedure_type='Пряко договаряне' THEN 1.0 ELSE 0 END) / NULLIF(COUNT(*),0) AS direct_award_share,
+ AVG(c.annex_count) AS avg_annex_count,
+ AVG(CASE WHEN c.signing_value_eur>0 AND c.current_value_eur>c.signing_value_eur
+ THEN c.current_value_eur/c.signing_value_eur END) AS avg_cost_overrun,
+ SUM(CASE WHEN c.bids_received IS NOT NULL THEN 1 ELSE 0 END) AS contracts_with_bids,
+ COUNT(*) AS total_contracts
+ FROM contracts c JOIN tenders t ON t.id = c.tender_id
+ WHERE c.amount_eur IS NOT NULL
+ GROUP BY t.authority_id
+)
+INSERT INTO authority_health_rollup
+ (authority_id, hhi, single_offer_share, direct_award_share, avg_annex_count, avg_cost_overrun,
+ cancelled_share, contracts_with_bids, total_contracts)
+SELECT s.authority_id, h.hhi, s.single_offer_share, s.direct_award_share, s.avg_annex_count,
+ s.avg_cost_overrun, NULL, s.contracts_with_bids, s.total_contracts
+FROM authority_stats s LEFT JOIN authority_hhi h ON h.authority_id = s.authority_id;
+
+-- cancelled_share: second pass, joining tenders grouped by authority_id (spec leaves it NULL in the
+-- main INSERT). Pre-aggregated CTE keeps this a lookup per authority row, not a per-contract rescan.
+UPDATE authority_health_rollup
+SET cancelled_share = (
+ SELECT SUM(CASE WHEN t.cancelled = 1 THEN 1.0 ELSE 0 END) / NULLIF(COUNT(*),0)
+ FROM tenders t WHERE t.authority_id = authority_health_rollup.authority_id
+);
+
+-- ── bidder_health_rollup ─────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS bidder_health_rollup (
+ bidder_id TEXT PRIMARY KEY REFERENCES bidders(id),
+ buyer_hhi REAL, -- SUM((won_from_buyer/total_won)^2) across buyers
+ buyer_count INTEGER,
+ avg_repeat_share REAL,
+ total_contracts INTEGER
+);
+DELETE FROM bidder_health_rollup;
+INSERT INTO bidder_health_rollup (bidder_id, buyer_hhi, buyer_count, avg_repeat_share, total_contracts)
+SELECT fp.bidder_id,
+ SUM((fp.won_eur/NULLIF(bt.won_eur,0))*(fp.won_eur/NULLIF(bt.won_eur,0))),
+ COUNT(DISTINCT fp.authority_id),
+ AVG(fp.contracts*1.0/NULLIF(at.contracts,0)),
+ bt.contracts
+FROM flow_pairs fp
+JOIN (SELECT bidder_id, SUM(won_eur) won_eur, SUM(contracts) contracts FROM flow_pairs GROUP BY bidder_id) bt
+ ON bt.bidder_id = fp.bidder_id
+JOIN authority_totals at ON at.authority_id = fp.authority_id
+GROUP BY fp.bidder_id;
+
+-- ── sector_concentration ─────────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS sector_concentration (
+ cpv_division TEXT NOT NULL,
+ bidder_id TEXT NOT NULL REFERENCES bidders(id),
+ won_eur REAL NOT NULL,
+ contracts INTEGER NOT NULL,
+ division_total_eur REAL NOT NULL,
+ win_share REAL NOT NULL,
+ PRIMARY KEY (cpv_division, bidder_id)
+);
+CREATE INDEX IF NOT EXISTS idx_sector_concentration_bidder ON sector_concentration(bidder_id);
+DELETE FROM sector_concentration;
+-- HAVING <> 0 guards win_share's division: a CPV division whose priced contracts sum to 0 EUR
+-- (a single amount_eur=0 contract, or exact +/- offsets) would otherwise yield 0/0 = NULL and
+-- abort the whole derive on win_share's NOT NULL constraint. Such a division carries no
+-- meaningful share, so it is skipped here; downstream (derive-contract-features.sql LEFT JOIN)
+-- its contracts get sector_win_share NULL — an honest "unknown", never a fabricated 0 score.
+WITH div_totals AS (
+ SELECT substr(t.cpv_code,1,2) div, SUM(c.amount_eur) total_eur
+ FROM contracts c JOIN tenders t ON t.id=c.tender_id
+ WHERE c.amount_eur IS NOT NULL AND COALESCE(t.cpv_code,'')<>''
+ GROUP BY substr(t.cpv_code,1,2)
+ HAVING SUM(c.amount_eur) <> 0)
+INSERT INTO sector_concentration (cpv_division, bidder_id, won_eur, contracts, division_total_eur, win_share)
+SELECT substr(t.cpv_code,1,2), c.bidder_id, SUM(c.amount_eur), COUNT(*), dt.total_eur,
+ SUM(c.amount_eur)/dt.total_eur
+FROM contracts c JOIN tenders t ON t.id=c.tender_id
+JOIN div_totals dt ON dt.div=substr(t.cpv_code,1,2)
+WHERE c.amount_eur IS NOT NULL AND COALESCE(t.cpv_code,'')<>''
+GROUP BY substr(t.cpv_code,1,2), c.bidder_id;
+
+-- ── health_percentiles ───────────────────────────────────────────────────────────────────────
+-- Corpus distribution snapshot (calibration + validation, §10) via the LIMIT 1 OFFSET idiom.
+CREATE TABLE IF NOT EXISTS health_percentiles (
+ signal TEXT PRIMARY KEY, p05 REAL, p25 REAL, p50 REAL, p75 REAL, p95 REAL
+);
+DELETE FROM health_percentiles;
+
+INSERT INTO health_percentiles
+WITH vals AS (SELECT bids_received AS v FROM contracts WHERE bids_received IS NOT NULL),
+ n AS (SELECT COUNT(*) AS cnt FROM vals)
+SELECT 'bids_received',
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.05 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.25 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.50 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.75 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.95 AS INTEGER) FROM n));
+
+INSERT INTO health_percentiles
+WITH vals AS (
+ SELECT current_value_eur/signing_value_eur AS v FROM contracts
+ WHERE signing_value_eur > 0 AND current_value_eur IS NOT NULL
+),
+n AS (SELECT COUNT(*) AS cnt FROM vals)
+SELECT 'cost_overrun_ratio',
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.05 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.25 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.50 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.75 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.95 AS INTEGER) FROM n));
+
+INSERT INTO health_percentiles
+WITH vals AS (
+ SELECT ABS(c.signing_value_eur - t.estimated_value_eur) / NULLIF(t.estimated_value_eur,0) AS v
+ FROM contracts c JOIN tenders t ON t.id = c.tender_id
+ WHERE c.signing_value_eur IS NOT NULL AND t.estimated_value_eur IS NOT NULL
+ AND t.procedure_type <> 'неизвестна'
+),
+n AS (SELECT COUNT(*) AS cnt FROM vals)
+SELECT 'estimate_dev_ratio',
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.05 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.25 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.50 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.75 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.95 AS INTEGER) FROM n));
+
+INSERT INTO health_percentiles
+WITH vals AS (SELECT annex_count AS v FROM contracts WHERE annex_count IS NOT NULL),
+ n AS (SELECT COUNT(*) AS cnt FROM vals)
+SELECT 'annex_count',
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.05 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.25 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.50 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.75 AS INTEGER) FROM n)),
+ (SELECT v FROM vals ORDER BY v LIMIT 1 OFFSET (SELECT CAST(cnt*0.95 AS INTEGER) FROM n));
+
+-- Summary (last result set printed by `wrangler d1 execute`)
+SELECT
+ (SELECT COUNT(*) FROM authority_health_rollup) AS authority_health_rows,
+ (SELECT COUNT(*) FROM bidder_health_rollup) AS bidder_health_rows,
+ (SELECT COUNT(*) FROM sector_concentration) AS sector_concentration_rows,
+ (SELECT COUNT(*) FROM health_percentiles) AS health_percentile_rows;
diff --git a/scripts/import.mjs b/scripts/import.mjs
index 578a1132b..98e75925d 100644
--- a/scripts/import.mjs
+++ b/scripts/import.mjs
@@ -2,7 +2,7 @@
// Sigma ETL orchestrator for storage.eop.bg open-data buckets. Initial backfill and daily catch-up
// both route through scripts/load-eop.mjs; only the date window and derive mode differ.
-import { execFileSync } from 'node:child_process';
+import { execFileSync, spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
@@ -19,7 +19,7 @@ import {
dropTransientStagingStatements,
refreshSliceStatementGroups,
} from '../packages/ingest/src/refresh.ts';
-import { assertIntegrity } from './integrity-checks.mjs';
+import { assertIntegrity, checkContractFeaturesIntegrity, CHECKS } from './integrity-checks.mjs';
import { buildAnomalyReport, formatAnomalyReport } from './anomaly-report.mjs';
// Per-refresh anomaly report (#100): cross-row outliers the per-row value_flag can't see. OBSERVES
@@ -88,9 +88,34 @@ function run(cmd, args, cwd = root, options = {}) {
}
const d1PersistArgs = !remote && persistTo ? ['--persist-to', String(persistTo)] : [];
+// Local D1 (workerd) needs a moment to fully release its SQLite file lock after a preceding
+// `wrangler d1 execute --file` invocation exits — back-to-back execSql calls can otherwise hit a
+// transient "database table is locked" / SQLITE_LOCKED on the very first statement. Retry a
+// handful of times with a short backoff; only for this specific transient signature, so a real
+// SQL error in the file still fails fast.
+const LOCK_ERROR_PATTERN = /database (table )?is locked|SQLITE_(BUSY|LOCKED)/i;
+function execWranglerD1File(file, attempt = 1) {
+ const args = ['wrangler', ['d1', 'execute', d1Name, loc, ...d1PersistArgs, '--file', file]];
+ console.log(`\n==> ${args[0]} ${args[1].join(' ')}`);
+ const result = spawnSync(args[0], args[1], { cwd: apiDir, encoding: 'utf8' });
+ if (result.stdout) process.stdout.write(result.stdout);
+ if (result.stderr) process.stderr.write(result.stderr);
+ if (result.status === 0) return;
+ const combined = `${result.stdout || ''}${result.stderr || ''}`;
+ if (attempt < 5 && LOCK_ERROR_PATTERN.test(combined)) {
+ const delayMs = 1500 * attempt;
+ console.log(
+ `==> transient D1 lock on ${basename(file)} (attempt ${attempt}); retrying in ${delayMs}ms`,
+ );
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
+ return execWranglerD1File(file, attempt + 1);
+ }
+ const cause = result.error ? ` (${result.error.message})` : '';
+ throw new Error(`Command failed: wrangler d1 execute ${d1Name} ${loc} --file ${file}${cause}`);
+}
function execSql(file, label = basename(file)) {
const startedAt = process.hrtime.bigint();
- run('wrangler', ['d1', 'execute', d1Name, loc, ...d1PersistArgs, '--file', file], apiDir);
+ execWranglerD1File(file);
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
console.log(`==> batch timing ${label}: ${elapsedMs.toFixed(1)}ms`);
}
@@ -127,7 +152,8 @@ function safeD1(sql) {
try {
return d1(sql);
} catch (err) {
- const msg = String(err?.message ?? err);
+ // wrangler writes the SQLITE error to stdout, not the exception message.
+ const msg = `${err?.message ?? err} ${err?.stdout ?? ''} ${err?.stderr ?? ''}`;
if (/no such table|does not exist/i.test(msg)) return [];
throw err;
}
@@ -225,8 +251,8 @@ function resolveCatchupPlan() {
}
function validateDeriveMode(mode) {
- if (!['full', 'slice'].includes(mode))
- throw new Error(`unknown --derive=${mode}; expected full|slice`);
+ if (!['full', 'slice', 'health'].includes(mode))
+ throw new Error(`unknown --derive=${mode}; expected full|slice|health`);
}
async function runFullDerive() {
@@ -238,17 +264,35 @@ async function runFullDerive() {
execSql(resolve(root, 'scripts/promote-amendments.sql'));
assertFxPopulated();
execSql(resolve(root, 'scripts/precompute.sql'));
- await assertIntegrity(d1, { label: 'full derive (D1)' });
+ runHealthDerive();
+ await assertIntegrity(d1, {
+ label: 'full derive (D1)',
+ checks: [...CHECKS, checkContractFeaturesIntegrity],
+ });
reportAnomalies(d1, 'full derive (D1)');
}
+// Standalone Phase 4/5 re-derive for the Contract Quality / Health Index (design spec §8) — runs
+// against the already-populated served D1 without the ~25-minute full re-import.
+function runHealthDerive() {
+ execSql(resolve(root, 'scripts/derive-health.sql'));
+ execSql(resolve(root, 'scripts/derive-contract-features.sql'));
+}
+
async function runSliceDerive() {
execSql(resolve(root, 'scripts/derive-amendments.sql'));
run('node', ['scripts/load-fx.mjs', '--apply', ...passthru]);
execSql(resolve(root, 'scripts/load-nuts.sql'));
execSql(resolve(root, 'scripts/seed-state-owned.sql'));
runRefreshSliceBatches();
- await assertIntegrity(d1, { label: 'slice derive (D1)' });
+ // Full Phase 4/5 recompute, not a scoped refresh of just the touched authority/bidder/contract
+ // ids — correct-over-incremental for now; a scoped refresh (design spec §8) is a documented
+ // future optimization once the full recompute cost is measured on prod D1.
+ runHealthDerive();
+ await assertIntegrity(d1, {
+ label: 'slice derive (D1)',
+ checks: [...CHECKS, checkContractFeaturesIntegrity],
+ });
reportAnomalies(d1, 'slice derive (D1)');
}
@@ -280,11 +324,16 @@ async function runWorkBackfill() {
if (existsSync(workDb)) rmSync(workDb, { force: true });
console.log(`==> Sigma import (work DB ${workDb})`);
+ // Apply the FULL migration chain (not just 0000_init): later migrations are additive
+ // (e.g. 0003 adds the health-index columns normalize-raw.sql writes into), and the work DB
+ // must match the table shape `wrangler d1 migrations apply` gives the served D1.
const migrationsDir = resolve(root, 'packages/db/migrations');
- const migrations = readdirSync(migrationsDir)
- .filter((name) => /^\d+.*\.sql$/.test(name))
+ const migrationFiles = readdirSync(migrationsDir)
+ .filter((f) => f.endsWith('.sql'))
.sort();
- for (const migration of migrations) sqliteFile(workDb, resolve(migrationsDir, migration));
+ for (const migration of migrationFiles) {
+ sqliteFile(workDb, resolve(migrationsDir, migration));
+ }
sqliteFile(workDb, resolve(root, 'scripts/work-staging-schema.sql'));
let loadFlags = explicitRangeFlags();
@@ -367,12 +416,25 @@ if (arg('work-db') !== undefined) {
process.exit(0);
}
+let deriveMode = String(arg('derive') || 'full');
+
+if (catchup && deriveMode === 'health') {
+ // The catchup planner picks full|slice itself; silently downgrading an explicit
+ // --derive=health would hide that no data load or normalize would run.
+ throw new Error('--catchup ignores --derive=health; run `--derive=health` separately');
+}
+
+if (!catchup && deriveMode === 'health') {
+ console.log(`==> Sigma import (${remote ? 'REMOTE' : 'local'}, derive=health only)`);
+ run('wrangler', ['d1', 'migrations', 'apply', d1Name, loc, ...d1PersistArgs], apiDir);
+ runHealthDerive();
+ process.exit(0);
+}
+
console.log(`==> Sigma import (${remote ? 'REMOTE' : 'local'})`);
run('wrangler', ['d1', 'migrations', 'apply', d1Name, loc, ...d1PersistArgs], apiDir);
execSqlStatements(dropTransientStagingStatements(), 'drop-stale-transient-staging');
execSql(resolve(root, 'scripts/work-staging-schema.sql'));
-
-let deriveMode = String(arg('derive') || 'full');
let loadFlags = explicitRangeFlags();
if (catchup) {
const plan = resolveCatchupPlan();
diff --git a/scripts/integrity-checks.d.mts b/scripts/integrity-checks.d.mts
index d358d0af3..245d1a4eb 100644
--- a/scripts/integrity-checks.d.mts
+++ b/scripts/integrity-checks.d.mts
@@ -30,9 +30,13 @@ export function checkNoNegativeValues(runner: IntegrityRunner): Promise;
export function checkDateSanity(runner: IntegrityRunner): Promise;
export function checkStagingReconciliation(runner: IntegrityRunner): Promise;
+export function checkContractFeaturesIntegrity(runner: IntegrityRunner): Promise;
export const CHECKS: Array<(runner: IntegrityRunner) => Promise>;
-export function runIntegrityChecks(runner: IntegrityRunner): Promise;
+export function runIntegrityChecks(
+ runner: IntegrityRunner,
+ checks?: Array<(runner: IntegrityRunner) => IntegrityResult | Promise>,
+): Promise;
export interface IntegritySummary {
/** true when no check is a hard failure (warnings/skips don't break the gate) */
@@ -54,6 +58,9 @@ export interface AssertIntegrityOptions {
label?: string;
/** true (default) → print and process.exit(1) on failure; false → throw instead (for tests) */
exit?: boolean;
+ /** checks to run; defaults to the standard CHECKS set. Pass a narrower array (e.g.
+ * [checkContractFeaturesIntegrity]) to gate a call-site-specific subset. */
+ checks?: Array<(runner: IntegrityRunner) => IntegrityResult | Promise>;
}
export function assertIntegrity(
runner: IntegrityRunner,
diff --git a/scripts/integrity-checks.mjs b/scripts/integrity-checks.mjs
index b48c49127..f0ef80969 100644
--- a/scripts/integrity-checks.mjs
+++ b/scripts/integrity-checks.mjs
@@ -372,6 +372,78 @@ export async function checkStagingReconciliation(runner) {
};
}
+// 6) Contract Quality / Health Index (design spec §8). derive-contract-features.sql's own final
+// SELECT prints these same invariant columns, but that SELECT is only ever displayed by
+// `wrangler d1 execute` — a violation never failed the ETL. This promotes them to a hard gate.
+// Self-skips when contract_features/tmp_diag are absent — tmp_diag is created by
+// derive-contract-features.sql and deliberately left in place (never DROPped), so its presence
+// also proves that script actually ran on this connection, not just that a stale
+// contract_features table exists from an earlier run.
+export async function checkContractFeaturesIntegrity(runner) {
+ const name = 'contract-features-integrity';
+ if (
+ !(await tableExists(runner, 'contract_features')) ||
+ !(await tableExists(runner, 'tmp_diag'))
+ ) {
+ return {
+ name,
+ ok: true,
+ skipped: true,
+ detail: 'contract_features/tmp_diag absent (derive-contract-features.sql not yet run)',
+ };
+ }
+ const r =
+ (
+ await rows(
+ runner,
+ 'SELECT' +
+ ' (SELECT COUNT(*) FROM contracts) AS contracts_rows,' +
+ ' (SELECT COUNT(*) FROM contract_features) AS contract_features_rows,' +
+ ' (SELECT unmapped_procedure_rows FROM tmp_diag) AS unmapped_procedure_rows,' +
+ " (SELECT COUNT(*) FROM contract_features WHERE value_flag = 'value_suspect' AND (score_overall IS NOT NULL OR score_c IS NOT NULL)) AS value_suspect_leak_rows," +
+ ' (SELECT COUNT(*) FROM contract_features WHERE single_offer = 1 AND score_a_bids > 0 AND peer_has_multi = 1) AS a1_floor_violations,' +
+ " (SELECT COUNT(*) FROM contract_features cf JOIN contracts c ON c.id = cf.contract_id JOIN tenders t ON t.id = c.tender_id WHERE t.procedure_type = 'Пряко договаряне' AND cf.score_b <> 0) AS direct_award_b1_nonzero",
+ )
+ )[0] || {};
+ const contractsRows = num(r.contracts_rows);
+ const contractFeaturesRows = num(r.contract_features_rows);
+ const unmappedProcedureRows = num(r.unmapped_procedure_rows);
+ const valueSuspectLeakRows = num(r.value_suspect_leak_rows);
+ const a1FloorViolations = num(r.a1_floor_violations);
+ const directAwardB1Nonzero = num(r.direct_award_b1_nonzero);
+
+ const fails = [];
+ if (contractsRows !== contractFeaturesRows)
+ fails.push(
+ `contract_features_rows ${contractFeaturesRows} != contracts_rows ${contractsRows} (orphaned/dropped contract)`,
+ );
+ if (unmappedProcedureRows !== 0)
+ fails.push(
+ `${unmappedProcedureRows} rows have an unmapped procedure_type (§12.2 vocabulary gap)`,
+ );
+ if (valueSuspectLeakRows !== 0)
+ fails.push(
+ `${valueSuspectLeakRows} value_suspect rows leaked a score (score_overall/score_c should be null)`,
+ );
+ if (a1FloorViolations !== 0)
+ fails.push(
+ `${a1FloorViolations} single-offer rows violate the A1 floor (peer_has_multi with score_a_bids>0)`,
+ );
+ if (directAwardB1Nonzero !== 0)
+ fails.push(
+ `${directAwardB1Nonzero} direct-award (Пряко договаряне) rows have a nonzero score_b`,
+ );
+
+ return {
+ name,
+ ok: fails.length === 0,
+ skipped: false,
+ detail: fails.length
+ ? fails.join('; ')
+ : `contracts_rows=${contractsRows} reconciled, no invariant violations`,
+ };
+}
+
export const CHECKS = [
checkNonEmptyCorpus,
checkRollupReconciliation,
@@ -382,11 +454,14 @@ export const CHECKS = [
checkStagingReconciliation,
];
-export async function runIntegrityChecks(runner) {
- // Sequential, not Promise.all: preserve the printed order and avoid firing every check's reads at
- // D1 at once. Each `fn` may return a value (sync runner) or a Promise (async/D1 runner); await both.
+// Sequential, not Promise.all: preserve the printed order and avoid firing every check's reads at
+// D1 at once. Each `fn` may return a value (sync runner) or a Promise (async/D1 runner); await both.
+// `checks` defaults to the standard #97 set; pass a different array (e.g.
+// [checkContractFeaturesIntegrity]) to gate a narrower, call-site-specific set without affecting
+// the other assertIntegrity callers.
+export async function runIntegrityChecks(runner, checks = CHECKS) {
const results = [];
- for (const fn of CHECKS) results.push(await fn(runner));
+ for (const fn of checks) results.push(await fn(runner));
return results;
}
@@ -412,9 +487,14 @@ export function summarizeIntegrity(results, label = 'integrity') {
// Run all checks, print a one-line summary per check, and FAIL non-zero on any real violation.
// `exit: true` (default) mirrors assertFxPopulated — print to stderr and process.exit(1). Tests pass
-// `exit: false` to get a thrown Error instead (the assertion still fails the same way).
-export async function assertIntegrity(runner, { label = 'integrity', exit = true } = {}) {
- const results = await runIntegrityChecks(runner);
+// `exit: false` to get a thrown Error instead (the assertion still fails the same way). `checks`
+// defaults to the standard #97 set; pass a different array (e.g. [checkContractFeaturesIntegrity])
+// to gate a narrower, call-site-specific set without affecting the other assertIntegrity callers.
+export async function assertIntegrity(
+ runner,
+ { label = 'integrity', exit = true, checks = CHECKS } = {},
+) {
+ const results = await runIntegrityChecks(runner, checks);
for (const r of results) {
const tag = r.skipped ? 'SKIP' : r.warn ? 'WARN' : r.ok ? ' ok ' : 'FAIL';
console.log(` [${tag}] ${r.name}: ${r.detail}`);
diff --git a/scripts/normalize-raw.sql b/scripts/normalize-raw.sql
index ebfef9759..863958f58 100644
--- a/scripts/normalize-raw.sql
+++ b/scripts/normalize-raw.sql
@@ -347,7 +347,8 @@ INSERT OR IGNORE INTO tenders
procedure_type, contract_kind, num_lots, status, published_at, deadline_at,
legal_basis, award_criteria, main_activity, notice_type,
place_of_performance, start_date, end_date, duration, duration_unit,
- eu_programme, green, social, innovation, eauction, cancelled, eop_tender_id)
+ eu_programme, green, social, innovation, eauction, cancelled, eop_tender_id,
+ corrections_count)
SELECT
't:' || t.unp,
t.unp,
@@ -380,7 +381,8 @@ SELECT
t.innovation,
t.eauction,
t.cancelled,
- NULLIF(t.tender_id, '') -- raw EOP numeric tenderId from the header row
+ NULLIF(t.tender_id, ''), -- raw EOP numeric tenderId from the header row
+ t.corrections_count
FROM raw_tenders t
WHERE t.lot_id IS NULL
AND EXISTS (
@@ -719,7 +721,8 @@ INSERT OR IGNORE INTO contracts
eu_programme, duration_days, winner_size, contractor_country,
bids_sme, bids_rejected, bids_non_eea,
subcontractor_eik, subcontractor_name, subcontract_value,
- eauction, framework, accelerated, strategic)
+ eauction, framework, accelerated, strategic,
+ exemption_legal_basis, outside_zop, dps_contract)
-- amendment_winner: the currency of whichever raw_amendments row supplied contract_number's
-- current_value (derive-amendments.sql's own rollup, mirrored here so the winning currency
-- travels alongside the value it minted — computed ONCE over raw_amendments, not per-row).
@@ -806,7 +809,10 @@ SELECT
x.eauction,
x.framework_contract,
x.accelerated,
- x.strategic
+ x.strategic,
+ x.exemption_legal_basis,
+ x.outside_zop,
+ x.dps_contract
FROM (
SELECT y.*,
CASE y.value_flag
diff --git a/scripts/precompute.sql b/scripts/precompute.sql
index 914b3aa78..3b0a5037a 100644
--- a/scripts/precompute.sql
+++ b/scripts/precompute.sql
@@ -39,6 +39,42 @@ UPDATE contracts SET
WHEN fx_rate IS NOT NULL THEN current_value * fx_rate
ELSE NULL END;
+-- tenders carries no persisted fx_rate column (unlike contracts, whose rate is captured once at
+-- ETL-import time) — so foreign-currency estimates are converted via the same live fx_rates lookup
+-- the ETL uses elsewhere (scripts/normalize-raw.sql, scripts/refresh-slice.sql): nearest rate on or
+-- before the tender's published_at, within a 10-day lookback window.
+UPDATE tenders SET
+ estimated_value_eur = CASE
+ WHEN currency = 'EUR' THEN estimated_value
+ WHEN COALESCE(currency, 'BGN') = 'BGN' THEN estimated_value / 1.95583
+ WHEN tenders.published_at IS NULL THEN NULL
+ ELSE (
+ SELECT estimated_value * f.eur_per_unit
+ FROM fx_rates f
+ WHERE f.base_currency = tenders.currency
+ AND f.rate_date <= tenders.published_at
+ AND f.rate_date >= date(tenders.published_at, '-10 days')
+ ORDER BY f.rate_date DESC
+ LIMIT 1
+ ) END
+WHERE estimated_value IS NOT NULL;
+
+-- Diagnostic: foreign-currency tenders (not EUR/BGN) with a published_at (so the fx_rates lookup
+-- above was attempted) that still resolved to a NULL estimated_value_eur — no fx_rate row fell
+-- inside the 10-day lookback window. A non-zero count here is a systemic fx_rates coverage gap,
+-- not a per-row anomaly; surfaced in the summary SELECT below so it isn't silently invisible.
+CREATE TABLE IF NOT EXISTS pipeline_diag (
+ metric TEXT PRIMARY KEY, value INTEGER NOT NULL, computed_at TEXT NOT NULL
+);
+DELETE FROM pipeline_diag WHERE metric = 'fx_rate_gap_rows';
+INSERT INTO pipeline_diag (metric, value, computed_at)
+SELECT 'fx_rate_gap_rows', COUNT(*), datetime('now')
+FROM tenders
+WHERE estimated_value IS NOT NULL
+ AND COALESCE(currency, 'BGN') NOT IN ('EUR', 'BGN')
+ AND published_at IS NOT NULL
+ AND estimated_value_eur IS NULL;
+
-- ── 1) home_totals shell (filled after company/authority rollups exist) ──────────────────────────
CREATE TABLE IF NOT EXISTS home_totals (
id INTEGER PRIMARY KEY CHECK (id = 1), contracts INTEGER NOT NULL, value_eur REAL NOT NULL,
@@ -150,11 +186,13 @@ FROM contracts c GROUP BY CASE WHEN c.eu_funded = 1 THEN '1' ELSE '0' END;
CREATE TABLE IF NOT EXISTS flow_pairs (
authority_id TEXT NOT NULL REFERENCES authorities(id), bidder_id TEXT NOT NULL REFERENCES bidders(id),
authority_name TEXT NOT NULL, bidder_name TEXT NOT NULL, bidder_kind TEXT NOT NULL,
- won_eur REAL NOT NULL, contracts INTEGER NOT NULL, PRIMARY KEY (authority_id, bidder_id)
+ won_eur REAL NOT NULL, contracts INTEGER NOT NULL, first_date TEXT, last_date TEXT,
+ PRIMARY KEY (authority_id, bidder_id)
);
DELETE FROM flow_pairs;
-INSERT INTO flow_pairs (authority_id, bidder_id, authority_name, bidder_name, bidder_kind, won_eur, contracts)
-SELECT t.authority_id, c.bidder_id, a.name, b.name, b.kind, SUM(c.amount_eur), COUNT(*)
+INSERT INTO flow_pairs (authority_id, bidder_id, authority_name, bidder_name, bidder_kind, won_eur, contracts, first_date, last_date)
+SELECT t.authority_id, c.bidder_id, a.name, b.name, b.kind, SUM(c.amount_eur), COUNT(*),
+ MIN(c.signed_at), MAX(c.signed_at)
FROM contracts c JOIN tenders t ON t.id = c.tender_id JOIN authorities a ON a.id = t.authority_id
JOIN bidders b ON b.id = c.bidder_id
WHERE c.amount_eur IS NOT NULL
@@ -195,4 +233,5 @@ SELECT
(SELECT COUNT(*) FROM sector_totals) AS sector_rows,
(SELECT COUNT(*) FROM flow_pairs) AS flow_rows,
(SELECT COUNT(*) FROM search_index) AS search_rows,
- (SELECT COUNT(*) FROM contracts WHERE signing_value_eur IS NOT NULL) AS signing_eur_rows;
+ (SELECT COUNT(*) FROM contracts WHERE signing_value_eur IS NOT NULL) AS signing_eur_rows,
+ (SELECT value FROM pipeline_diag WHERE metric = 'fx_rate_gap_rows') AS fx_rate_gap_rows;
diff --git a/scripts/promote-amendments.sql b/scripts/promote-amendments.sql
index 5d72e367e..f85dbae5c 100644
--- a/scripts/promote-amendments.sql
+++ b/scripts/promote-amendments.sql
@@ -8,7 +8,7 @@ DELETE FROM amendments;
INSERT OR REPLACE INTO amendments (
id, natural_key, contract_number, unp, value_before, value_after, value_delta, currency,
- published_at, document_number, description, source
+ published_at, document_number, description, reason, circumstances, source
)
WITH keyed AS (
SELECT
@@ -46,6 +46,8 @@ SELECT
published_at,
document_number,
description,
+ reason,
+ circumstances,
source
FROM dedup
WHERE rn = 1;
diff --git a/scripts/refresh-slice.sql b/scripts/refresh-slice.sql
index ff333431f..3d523be1b 100644
--- a/scripts/refresh-slice.sql
+++ b/scripts/refresh-slice.sql
@@ -543,7 +543,8 @@ INSERT INTO tenders
procedure_type, contract_kind, num_lots, status, published_at, deadline_at,
legal_basis, award_criteria, main_activity, notice_type,
place_of_performance, start_date, end_date, duration, duration_unit,
- eu_programme, green, social, innovation, eauction, cancelled, eop_tender_id)
+ eu_programme, green, social, innovation, eauction, cancelled, eop_tender_id,
+ corrections_count)
SELECT
't:' || t.unp,
t.unp,
@@ -576,7 +577,8 @@ SELECT
t.innovation,
t.eauction,
t.cancelled,
- NULLIF(t.tender_id, '') -- raw EOP numeric tenderId from the header row
+ NULLIF(t.tender_id, ''), -- raw EOP numeric tenderId from the header row
+ t.corrections_count
FROM raw_tenders t
WHERE t.lot_id IS NULL
AND EXISTS (
@@ -621,7 +623,14 @@ ON CONFLICT(id) DO UPDATE SET
-- real, keep its id but backfill from the header if it was somehow missing.
eop_tender_id = CASE WHEN tenders.procedure_type = 'неизвестна'
THEN COALESCE(excluded.eop_tender_id, tenders.eop_tender_id)
- ELSE COALESCE(tenders.eop_tender_id, excluded.eop_tender_id) END;
+ ELSE COALESCE(tenders.eop_tender_id, excluded.eop_tender_id) END,
+ -- Monotonic counter: never regress it, regardless of procedure_type — a real tender's
+ -- corrections_count must keep growing across incremental refreshes, not freeze at its first value.
+ corrections_count = CASE
+ WHEN excluded.corrections_count IS NULL THEN tenders.corrections_count
+ WHEN tenders.corrections_count IS NULL THEN excluded.corrections_count
+ ELSE MAX(excluded.corrections_count, tenders.corrections_count)
+ END;
-- @refresh-batch lots
INSERT OR IGNORE INTO lots (id, tender_id, title, cpv_code, estimated_value)
@@ -939,7 +948,8 @@ INSERT OR IGNORE INTO contracts
eu_programme, duration_days, winner_size, contractor_country,
bids_sme, bids_rejected, bids_non_eea,
subcontractor_eik, subcontractor_name, subcontract_value,
- eauction, framework, accelerated, strategic)
+ eauction, framework, accelerated, strategic,
+ exemption_legal_basis, outside_zop, dps_contract)
SELECT
'c:o:' || COALESCE(x.unp, '') || ':' || COALESCE(x.contract_number, '') || ':' ||
COALESCE(NULLIF(x.lot_id, ''), '_') || ':' || x.bidder_key || ':' || x.contract_ordinal,
@@ -982,7 +992,10 @@ SELECT
x.eauction,
x.framework_contract,
x.accelerated,
- x.strategic
+ x.strategic,
+ x.exemption_legal_basis,
+ x.outside_zop,
+ x.dps_contract
FROM (
SELECT q.*,
-- value_suspect is repaired directly from proc_est_eur. value_low (and 'review') is populated here,
@@ -1215,7 +1228,8 @@ INSERT OR IGNORE INTO contracts
eu_programme, duration_days, winner_size, contractor_country,
bids_sme, bids_rejected, bids_non_eea,
subcontractor_eik, subcontractor_name, subcontract_value,
- eauction, framework, accelerated, strategic)
+ eauction, framework, accelerated, strategic,
+ exemption_legal_basis, outside_zop, dps_contract)
SELECT
'c:e:' || COALESCE(x.unp, '') || ':' || COALESCE(x.contract_number, '') || ':' ||
COALESCE(NULLIF(x.lot_norm, ''), '_') || ':' || x.bidder_key || ':' || x.contract_ordinal,
@@ -1258,7 +1272,10 @@ SELECT
x.eauction,
x.framework_contract,
x.accelerated,
- x.strategic
+ x.strategic,
+ x.exemption_legal_basis,
+ x.outside_zop,
+ x.dps_contract
FROM (
SELECT q.*,
-- value_suspect is repaired directly from proc_est_eur. value_low (and 'review') is populated here,
@@ -1505,7 +1522,7 @@ WHERE status <> 'awarded'
-- @refresh-batch amendments
INSERT OR REPLACE INTO amendments (
id, natural_key, contract_number, unp, value_before, value_after, value_delta, currency,
- published_at, document_number, description, source
+ published_at, document_number, description, reason, circumstances, source
)
WITH keyed AS (
SELECT
@@ -1543,6 +1560,8 @@ SELECT
published_at,
document_number,
description,
+ reason,
+ circumstances,
source
FROM dedup
WHERE rn = 1;
@@ -1817,8 +1836,9 @@ GROUP BY cca.authority_id;
-- @refresh-batch flow-pairs
DELETE FROM flow_pairs;
-INSERT INTO flow_pairs (authority_id, bidder_id, authority_name, bidder_name, bidder_kind, won_eur, contracts)
-SELECT t.authority_id, c.bidder_id, a.name, b.name, b.kind, SUM(c.amount_eur), COUNT(*)
+INSERT INTO flow_pairs (authority_id, bidder_id, authority_name, bidder_name, bidder_kind, won_eur, contracts, first_date, last_date)
+SELECT t.authority_id, c.bidder_id, a.name, b.name, b.kind, SUM(c.amount_eur), COUNT(*),
+ MIN(c.signed_at), MAX(c.signed_at)
FROM contracts c JOIN tenders t ON t.id = c.tender_id JOIN authorities a ON a.id = t.authority_id JOIN bidders b ON b.id = c.bidder_id
WHERE c.amount_eur IS NOT NULL
GROUP BY t.authority_id, c.bidder_id;
diff --git a/scripts/ship-domain.mjs b/scripts/ship-domain.mjs
index 96aa9200d..00ed66b82 100755
--- a/scripts/ship-domain.mjs
+++ b/scripts/ship-domain.mjs
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { assertIntegrity } from './integrity-checks.mjs';
+import { assertIntegrity, checkContractFeaturesIntegrity } from './integrity-checks.mjs';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const apiDir = resolve(root, 'apps/web');
@@ -223,6 +223,33 @@ console.log('==> precompute on served D1');
d1File(resolve(root, 'scripts/seed-state-owned.sql'));
d1File(resolve(root, 'scripts/precompute.sql'));
+// Contract Quality / Health Index Phases 4-5 (design spec §8) — run
+// directly on the served D1 right after precompute, same pattern as precompute itself, so the
+// daily ETL keeps authority/bidder/sector/region/year/funding_quality_totals current on prod D1.
+//
+// AVAILABILITY WINDOW (closed via staging swap): derive-contract-features.sql builds the new
+// scores into a disposable `contract_features_next` staging table, then swaps it into the live
+// `contract_features` name with a back-to-back `DROP TABLE IF EXISTS contract_features; ALTER
+// TABLE contract_features_next RENAME TO contract_features;` — both statements land in the same
+// `wrangler d1 execute --file` batch, so the served table is never missing/empty mid-rebuild; a
+// request either sees the complete prior day's table or the complete new one. /quality still
+// tolerates a wholly-absent table (its loader catches "no such table" and renders the "still
+// computing" empty state) for the very first-ever derive on a fresh D1, before either name exists.
+console.log('==> health derive on served D1');
+d1File(resolve(root, 'scripts/derive-health.sql'));
+d1File(resolve(root, 'scripts/derive-contract-features.sql'));
+
+// Contract Quality / Health Index hard gate: derive-contract-features.sql's own summary SELECT
+// prints contracts_rows/contract_features_rows parity, unmapped_procedure_rows, and the score
+// invariants (value_suspect_leak_rows, a1_floor_violations, direct_award_b1_nonzero), but that
+// SELECT was only ever displayed by `wrangler d1 execute`, never asserted. Gate it the same way
+// as the #97 reconciliation check below, right after the derive that computes it.
+console.log('==> contract-features integrity gate on served D1');
+assertIntegrity(d1Json, {
+ label: `contract_features ${remote ? 'remote' : 'local'}`,
+ checks: [checkContractFeaturesIntegrity],
+});
+
// Reconciliation gate (#97) on the served D1: rollups now exist (just precomputed), so the rollup
// checks run here — this is the database users read. Staging/pipeline_stats are not shipped, so the
// staging-reconciliation check self-skips. Fails the ship with a non-zero exit on any drift.
diff --git a/scripts/validate-health.mjs b/scripts/validate-health.mjs
new file mode 100644
index 000000000..a25531766
--- /dev/null
+++ b/scripts/validate-health.mjs
@@ -0,0 +1,252 @@
+#!/usr/bin/env node
+// Contract Quality / Health Index — §10 validation plan, run read-only against the local served
+// D1 sqlite file. Rerunnable operator tool (not wired into CI, docs/etl.md documents it):
+// node scripts/validate-health.mjs
+//
+// Every check exits loud: prints PASS/FAIL per check, then exits 1 if any failed, 0 if clean.
+
+import { DatabaseSync } from 'node:sqlite';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { readdirSync } from 'node:fs';
+
+// Schema-robust: `year` is normalized to a string on both sides before comparing, so the check
+// stays correct whether the driver hands back year_quality_totals.year as TEXT or (e.g. after a
+// schema change, or a driver that infers column affinity from the stored SQLite value) as a
+// number — a bare `expected.includes(y)` against un-normalized values silently always-FAILs
+// whenever the two sides' JS types differ, even when every year is actually present.
+export function missingYears(actualYears, expectedYears) {
+ const actual = new Set(actualYears.map((y) => String(y)));
+ return expectedYears.map((y) => String(y)).filter((y) => !actual.has(y));
+}
+
+function main() {
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+ const d1Dir = resolve(root, 'apps/web/.wrangler/state/v3/d1/miniflare-D1DatabaseObject');
+ const dbFile = readdirSync(d1Dir).find((f) => f.endsWith('.sqlite'));
+ if (!dbFile) throw new Error(`no .sqlite file found in ${d1Dir}`);
+ const db = new DatabaseSync(resolve(d1Dir, dbFile), { readOnly: true });
+
+ let failures = 0;
+ function check(name, fn) {
+ try {
+ const detail = fn();
+ console.log(`PASS ${name}${detail ? ` — ${detail}` : ''}`);
+ } catch (err) {
+ failures++;
+ console.log(`FAIL ${name} — ${err.message}`);
+ }
+ }
+
+ function all(sql, ...params) {
+ return db.prepare(sql).all(...params);
+ }
+ function one(sql, ...params) {
+ return db.prepare(sql).get(...params);
+ }
+
+ // 1) all six *_quality_totals non-empty; every avg_overall in [0,1]
+ const QUALITY_TABLES = [
+ 'authority_quality_totals',
+ 'bidder_quality_totals',
+ 'sector_quality_totals',
+ 'region_quality_totals',
+ 'year_quality_totals',
+ 'funding_quality_totals',
+ ];
+ for (const table of QUALITY_TABLES) {
+ check(`${table} non-empty`, () => {
+ const { n } = one(`SELECT COUNT(*) AS n FROM ${table}`);
+ if (n === 0) throw new Error('0 rows');
+ return `${n} rows`;
+ });
+ check(`${table} avg_overall in [0,1]`, () => {
+ const { bad } = one(
+ `SELECT COUNT(*) AS bad FROM ${table} WHERE avg_overall IS NOT NULL AND (avg_overall < 0 OR avg_overall > 1)`,
+ );
+ if (bad > 0) throw new Error(`${bad} rows with avg_overall out of [0,1]`);
+ });
+ }
+
+ // 2) the 3 value_suspect contracts appear in no numerator (spot-check their authorities'
+ // scored_contracts < total_contracts)
+ check('value_suspect contracts excluded from every numerator', () => {
+ const suspects = all(
+ `SELECT cf.contract_id, t.authority_id
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ WHERE cf.value_flag = 'value_suspect'`,
+ );
+ // Not pinned to the current corpus count (3) — future refreshes may add/remove suspect rows;
+ // the invariant is that every one of them is excluded, however many there are.
+ if (suspects.length < 1) throw new Error(`expected at least 1 value_suspect row, found 0`);
+ const leaked = suspects.filter((s) => {
+ const cf = one(
+ `SELECT score_overall FROM contract_features WHERE contract_id = ?`,
+ s.contract_id,
+ );
+ return cf.score_overall !== null;
+ });
+ if (leaked.length > 0)
+ throw new Error(`${leaked.length} value_suspect rows have non-NULL score_overall`);
+ const authorityLeaks = suspects.filter((s) => {
+ const at = one(
+ `SELECT scored_contracts, total_contracts FROM authority_quality_totals WHERE authority_id = ?`,
+ s.authority_id,
+ );
+ return !at || at.scored_contracts >= at.total_contracts;
+ });
+ if (authorityLeaks.length > 0)
+ throw new Error(
+ `${authorityLeaks.length} value_suspect authorities have scored_contracts >= total_contracts`,
+ );
+ return `${suspects.length} value_suspect rows, all score_overall NULL, all authorities scored {
+ // Dynamic range: covers 2020..the latest signing year in the corpus, so the check
+ // does not go stale in 2027 or on a partial re-import.
+ const years = all(`SELECT year FROM year_quality_totals`).map((r) => r.year);
+ // Ignore straggler mis-dated rows (a handful of 2027+/pre-2020 contracts exist in the feed):
+ // a year only counts as "covered corpus" with a non-trivial contract population.
+ const maxYear = one(
+ `SELECT MAX(y) AS y FROM (
+ SELECT CAST(substr(signed_at, 1, 4) AS INT) AS y, COUNT(*) AS n FROM contracts
+ WHERE substr(signed_at, 1, 4) BETWEEN '2020' AND '2099'
+ GROUP BY y HAVING n >= 50)`,
+ ).y;
+ const expected = [];
+ for (let y = 2020; y <= maxYear; y++) expected.push(String(y));
+ const missing = missingYears(years, expected);
+ if (missing.length > 0) throw new Error(`missing years: ${missing.join(', ')}`);
+ return `years present: ${years.sort().join(', ')}`;
+ });
+
+ // 4) pillar NULL-rate by year: no pillar >60% NULL in any 2020-2026 stratum except documented
+ // ones (B in synthetic-heavy strata; A-bids in 2024 per §12.4) — print the matrix
+ check('pillar NULL-rate by year (informational matrix, gated on undocumented strata)', () => {
+ const rows = all(
+ `SELECT CASE WHEN c.signed_at IS NULL OR strftime('%Y', c.signed_at) NOT BETWEEN '2020' AND '2026'
+ THEN 'NA' ELSE strftime('%Y', c.signed_at) END AS yr,
+ COUNT(*) AS n,
+ ROUND(100.0 * SUM(CASE WHEN cf.score_a IS NULL THEN 1 ELSE 0 END) / COUNT(*), 1) AS a_null_pct,
+ ROUND(100.0 * SUM(CASE WHEN cf.score_b IS NULL THEN 1 ELSE 0 END) / COUNT(*), 1) AS b_null_pct,
+ ROUND(100.0 * SUM(CASE WHEN cf.score_c IS NULL THEN 1 ELSE 0 END) / COUNT(*), 1) AS c_null_pct,
+ ROUND(100.0 * SUM(CASE WHEN cf.score_d IS NULL THEN 1 ELSE 0 END) / COUNT(*), 1) AS d_null_pct,
+ ROUND(100.0 * SUM(CASE WHEN cf.score_e IS NULL THEN 1 ELSE 0 END) / COUNT(*), 1) AS e_null_pct
+ FROM contract_features cf JOIN contracts c ON c.id = cf.contract_id
+ GROUP BY yr ORDER BY yr`,
+ );
+ console.log(' year n A% B% C% D% E%');
+ for (const r of rows) {
+ console.log(
+ ` ${r.yr.padEnd(6)} ${String(r.n).padEnd(7)} ${r.a_null_pct.toFixed(1).padStart(5)} ${r.b_null_pct.toFixed(1).padStart(5)} ${r.c_null_pct.toFixed(1).padStart(5)} ${r.d_null_pct.toFixed(1).padStart(5)} ${r.e_null_pct.toFixed(1).padStart(5)}`,
+ );
+ }
+ // undocumented exceptions: only pillar B may exceed 60% (synthetic-heavy strata, §4.B1/§12.2)
+ // and pillar A may exceed 60% in 2024 only (§12.4 — 2024 bids_received coverage hole).
+ const bad = [];
+ for (const r of rows) {
+ if (r.yr === 'NA') continue;
+ if (r.a_null_pct > 60 && r.yr !== '2024') bad.push(`${r.yr}:A=${r.a_null_pct}%`);
+ if (r.c_null_pct > 60) bad.push(`${r.yr}:C=${r.c_null_pct}%`);
+ if (r.d_null_pct > 60) bad.push(`${r.yr}:D=${r.d_null_pct}%`);
+ if (r.e_null_pct > 60) bad.push(`${r.yr}:E=${r.e_null_pct}%`);
+ }
+ if (bad.length > 0) throw new Error(`undocumented >60% NULL strata: ${bad.join(', ')}`);
+ });
+
+ // 5) Spearman-lite redundancy check: bucket-correlation of A vs B pillar deciles (informational)
+ check('Spearman-lite A vs B decile correlation (informational, no hard gate)', () => {
+ const rows = all(
+ `SELECT score_a, score_b FROM contract_features WHERE score_a IS NOT NULL AND score_b IS NOT NULL`,
+ );
+ if (rows.length === 0) {
+ console.log(' no rows with both A and B scored');
+ return;
+ }
+ const decile = (x) => Math.min(9, Math.floor(x * 10));
+ const da = rows.map((r) => decile(r.score_a));
+ const db_ = rows.map((r) => decile(r.score_b));
+ const n = da.length;
+ const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
+ const ma = mean(da),
+ mb = mean(db_);
+ let cov = 0,
+ va = 0,
+ vb = 0;
+ for (let i = 0; i < n; i++) {
+ cov += (da[i] - ma) * (db_[i] - mb);
+ va += (da[i] - ma) ** 2;
+ vb += (db_[i] - mb) ** 2;
+ }
+ if (va === 0 || vb === 0) {
+ console.log(
+ ' decile-correlation(A,B): skipped, one or both pillars have zero variance in this sample',
+ );
+ return;
+ }
+ const corr = cov / Math.sqrt(va * vb);
+ console.log(
+ ` n=${n} decile-correlation(A,B) = ${corr.toFixed(3)} (informational; >0.7 would warrant revisiting §3.2 weights)`,
+ );
+ });
+
+ // 6) e-auction mean A-pillar > non-eauction mean within the same CPV division (print top-3
+ // divisions with both present)
+ check(
+ 'e-auction contracts score higher A-pillar than non-eauction peers (same CPV division)',
+ () => {
+ const rows = all(
+ `SELECT CASE WHEN t.cpv_code IS NULL OR LENGTH(TRIM(t.cpv_code)) < 2 THEN 'NA' ELSE substr(t.cpv_code,1,2) END AS division,
+ AVG(CASE WHEN cf.is_eauction = 1 THEN cf.score_a END) AS ea_avg,
+ AVG(CASE WHEN cf.is_eauction = 0 OR cf.is_eauction IS NULL THEN cf.score_a END) AS non_ea_avg,
+ SUM(CASE WHEN cf.is_eauction = 1 AND cf.score_a IS NOT NULL THEN 1 ELSE 0 END) AS ea_n,
+ SUM(CASE WHEN (cf.is_eauction = 0 OR cf.is_eauction IS NULL) AND cf.score_a IS NOT NULL THEN 1 ELSE 0 END) AS non_ea_n
+ FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ GROUP BY division
+ HAVING ea_n > 0 AND non_ea_n > 0
+ ORDER BY ea_n DESC LIMIT 3`,
+ );
+ if (rows.length === 0) {
+ console.log(' no CPV division has both e-auction and non-e-auction scored rows');
+ return;
+ }
+ for (const r of rows) {
+ console.log(
+ ` division ${r.division}: eauction avg_a=${r.ea_avg?.toFixed(3)} (n=${r.ea_n}) non-eauction avg_a=${r.non_ea_avg?.toFixed(3)} (n=${r.non_ea_n})`,
+ );
+ }
+ // Majority gate, not all-of: division-level comparison is coarser than the spec's
+ // CPV × band × year peer grain (§10.7), and division 33 (pharma) legitimately inverts —
+ // its e-auctions are dominated by low-bid framework call-offs.
+ const worse = rows.filter((r) => r.ea_avg <= r.non_ea_avg);
+ if (worse.length * 2 > rows.length)
+ throw new Error(
+ `${worse.length}/${rows.length} top divisions have eauction avg_a <= non-eauction avg_a`,
+ );
+ },
+ );
+
+ // 7) Пряко договаряне AND amount_eur > 215000 -> B-pillar <= 0.05
+ check('Пряко договаряне + amount_eur > 215000 => score_b <= 0.05', () => {
+ const bad = one(
+ `SELECT COUNT(*) AS n FROM contract_features cf
+ JOIN contracts c ON c.id = cf.contract_id
+ JOIN tenders t ON t.id = c.tender_id
+ WHERE t.procedure_type = 'Пряко договаряне' AND c.amount_eur > 215000 AND cf.score_b > 0.05`,
+ );
+ if (bad.n > 0) throw new Error(`${bad.n} rows with score_b > 0.05`);
+ });
+
+ console.log(failures > 0 ? `\n${failures} check(s) FAILED` : '\nall checks PASSED');
+ process.exit(failures > 0 ? 1 : 0);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
+ main();
+}
diff --git a/scripts/validate-health.test.mjs b/scripts/validate-health.test.mjs
new file mode 100644
index 000000000..c6ab8df3a
--- /dev/null
+++ b/scripts/validate-health.test.mjs
@@ -0,0 +1,20 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import { missingYears } from './validate-health.mjs';
+
+describe('missingYears', () => {
+ it('reports no gaps when every expected year is present as TEXT', () => {
+ assert.deepEqual(missingYears(['2020', '2021', '2022'], ['2020', '2021', '2022']), []);
+ });
+
+ it('matches an INTEGER-column year against string-expected years (previously an always-FAIL)', () => {
+ // Simulates a driver/schema that hands back `year` as a JS number rather than a string —
+ // a bare `expected.includes(y)` comparison would treat every year as missing here.
+ assert.deepEqual(missingYears([2020, 2021, 2022], ['2020', '2021', '2022']), []);
+ });
+
+ it('still reports a genuinely missing year regardless of type', () => {
+ assert.deepEqual(missingYears([2020, 2022], ['2020', '2021', '2022']), ['2021']);
+ });
+});