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 && ( + <> + + + + )} + + + {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 ( + + + + + ); +} 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) {

+
+

7. Индексът за здраве на договора

+

+ Таблото Качество на договора дава на всеки договор + съставен индекс 0–100 (по-високо = по-здрав процес) от пет измерения. Това е{' '} + сигнал за преглед, не присъда: ниската оценка говори за слабо + качество на процеса, не доказва нарушение. +

+

+ Индексът не открива тръжни картели, необичайно ниски оферти, скрита собственост или + конфликт на интереси — тези данни липсват във фийда. Договор без достатъчно данни + получава етикет „недостатъчно данни", никога нула, и не влиза в + нито една средна. Всяка оценка е проследима до конкретните договори. +

+ +

Петте измерения и теглата им

+
+
+
A · Контестабилност — 30%
+
+ Колко състезателен е бил изборът: брой оферти спрямо сходни поръчки, единствена + оферта, дял на МСП, електронен търг. +
+
+
+
B · Откритост на процедурата — 15%
+
+ Видът и откритостта на процедурата: пряко договаряне, ускорена процедура, срок + за подаване на оферти. +
+
+
+
C · Интегритет на стойността — 25%
+
+ Поведението на стойността: брой анекси, превишение над подписаното, отклонение + от прогнозната стойност. +
+
+
+
D · Здраве на връзките — 20%
+
+ Концентрация около купувача: HHI на възложителя, повторни печалби, възраст на + връзката, дял в сектора. +
+
+
+
E · Прозрачност на данните — 10%
+
+ Пълнота и подреденост на записа: ред на датите, разкрито подизпълнение, срок и + заключване, корекции по обявата. +
+
+
+ +

Как се сглобява оценката

+

+ Във всяко измерение: претеглена средна на наличните показатели. + Между измеренията:{' '} + 0,6 × претеглената средна + 0,4 × най-слабото измерение — така едно + слабо звено не се компенсира изцяло от силните. Измерение без никакви данни отпада, + а теглата се пренормират до сбор 1. Сравнението за всеки показател е спрямо група + сходни договори: CPV дивизия × стойностен клас × вид процедура × година. +

+

+ Стойностният флаг мени как се третира измерение C: „review" (сива зона на + надценяване) го умножава по 0,90; „value_low" зануля точността на прогнозата; + „annex_suspect" зануля превишението; а „value_suspect" зануля цялото измерение C и + оставя договора неоценен, извън всички средни. +

+ +

Покритие, увереност и обобщения

+

+ До всяка оценка се докладва покритие (дял налични показатели), но то{' '} + никога не влиза в аритметиката. Праговете: ≥ 0,80 „високо", + 0,60–0,79 „средно" (и двете се публикуват), 0,40–0,59 „ниско" (публикува се с + уговорка), под 0,40 — оценката се задържа като „недостатъчно данни". +

+

+ Средните по институция и доставчик са претеглени по стойност, но с + таван от 15% за един договор, за да не доминира един мега-договор; по CPV сектор, + регион и финансиране — претеглени по стойност без таван; по година — обикновена + (непретеглена) средна, за да са сравними година спрямо година. Институция или + доставчик се показва само с поне 20 оценени договора. Неоценените договори се + изключват и от числителя, и от знаменателя — никога не се броят като нула. +

+ +

+ Ниската оценка е сигнал за слаб процес, не доказана злоупотреба. Индексът не + открива картели, необичайно ниски оферти или конфликт на интереси. Ориентир за + преглед, не заключение. +

+
+
+
-

7. Имена, ЕИК, УНП

+

8. Имена, ЕИК, УНП

Един и същ субект често се изписва различно в хиляди обявления. Това е най-чувствителната част от данните: @@ -458,7 +553,7 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {

-

8. Известни празнини в полетата

+

9. Известни празнини в полетата

Кои полета са налични, кои са частични и кои липсват. Частичните се показват само за записите, за които има данни — никога като измислена стойност. @@ -500,7 +595,7 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {

-

9. Сваляне и достъп до данните

+

10. Сваляне и достъп до данните

Всеки списък може да бъде свален като CSV — точно това, което виждаш, с приложените филтри: @@ -518,7 +613,7 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {

-

10. Поправки и обратна връзка

+

11. Поправки и обратна връзка

Грешките поправяме ръчно при сигнал — двойни записи за институция/компания (изпратете двата ЕИК/линка) или сума, която не отговаря на оригиналния документ 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." + > +
+
+

Съставяне

+
    +
  1. + Във всяко измерение — претеглена средна на наличните показатели. +
  2. +
  3. + Между измеренията — 0,6 × средна + 0,4 × най-слабото, за да не се + „изкупува“ слабо звено със силни. +
  4. +
  5. + Измерение без никакви данни отпада, а теглата се пренормират до сбор 1. +
  6. +
  7. + Сравнението е спрямо група сходни договори: CPV дивизия × стойностен клас × + вид процедура × година. +
  8. +
+
+
+

Какво не твърди

+
    +
  • + Ниска оценка е сигнал за слаб процес, не доказана злоупотреба. +
  • +
  • + Не открива картели, необичайно ниски оферти, скрита собственост или конфликт на + интереси — тези данни липсват във фийда. +
  • +
  • + Всяка оценка е проследима до конкретните договори; няма скрито тегло. +
  • +
+
+
+ +

Какво влиза във всяко измерение

+
+ {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) => ( +
  • +