From 4dd28ce4f5df59adac549904b1c3e7f8802fdbe1 Mon Sep 17 00:00:00 2001 From: Bilko Date: Sun, 28 Jun 2026 23:12:03 -0700 Subject: [PATCH 01/37] feat(etl): add contract health index foundation columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 9 columns to contracts, amendments, tenders, and flow_pairs as the schema and ETL foundation for the contract quality / health index. No scoring logic — columns are populated by the existing pipeline (normalize-raw.sql, promote-amendments.sql, precompute.sql, refresh-slice.sql). New columns: - contracts.exemption_legal_basis TEXT -- правно основание за изключение - contracts.outside_zop INTEGER -- извън ЗОП - contracts.dps_contract INTEGER -- договор по ДСП - amendments.reason TEXT -- причини за изменение - amendments.circumstances TEXT -- обстоятелства - tenders.corrections_count INTEGER -- брой поправки (corrigenda) - tenders.estimated_value_eur REAL -- estimated_value → EUR (BGN÷1.95583) - flow_pairs.first_date TEXT -- MIN(signed_at) over pair's contracts - flow_pairs.last_date TEXT -- MAX(signed_at) over pair's contracts Migration 0002_contract_health.sql applies the columns to existing local D1 databases. 0000_init.sql updated inline so fresh installs need no migration. The CREATE TABLE IF NOT EXISTS flow_pairs guard in precompute.sql kept in sync. --- packages/db/migrations/0000_init.sql | 9 +++++ .../db/migrations/0002_contract_health.sql | 15 ++++++++ scripts/normalize-raw.sql | 14 +++++--- scripts/precompute.sql | 15 ++++++-- scripts/promote-amendments.sql | 4 ++- scripts/refresh-slice.sql | 34 +++++++++++++------ 6 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 packages/db/migrations/0002_contract_health.sql diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql index 13fb9d801..d32796a4d 100644 --- a/packages/db/migrations/0000_init.sql +++ b/packages/db/migrations/0000_init.sql @@ -68,6 +68,8 @@ CREATE TABLE tenders ( eauction INTEGER, -- Електронен търг cancelled INTEGER, -- Отменена eop_tender_id TEXT, -- raw EOP numeric tenderId; documents deep-link https://app.eop.bg/today/ (NOT the УНП / noticeId) + corrections_count INTEGER, -- Брой поправки на обявлението (corrigenda) + estimated_value_eur REAL, -- estimated_value materialized in EUR (BGN÷1.95583; EUR as-is; foreign→NULL) created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -148,6 +150,9 @@ CREATE TABLE contracts ( framework INTEGER, -- Договор по рамково споразумение accelerated INTEGER, -- Ускорена процедура strategic INTEGER, -- Стратегическа поръчка + exemption_legal_basis TEXT, -- Правно основание за изключение (outside ZOP) + outside_zop INTEGER, -- Договорът е извън приложното поле на ЗОП + dps_contract INTEGER, -- Договор по ДСП (динамична система за покупки) created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -165,6 +170,8 @@ CREATE TABLE amendments ( published_at TEXT, document_number TEXT, description TEXT, + reason TEXT, -- Причини за изменение (ЗОП основание) + circumstances TEXT, -- Обстоятелства source TEXT NOT NULL ); CREATE INDEX idx_amendments_contract ON amendments(unp, contract_number); @@ -272,6 +279,8 @@ CREATE TABLE flow_pairs ( bidder_kind TEXT NOT NULL, won_eur REAL NOT NULL, contracts INTEGER NOT NULL, + first_date TEXT, -- MIN(signed_at) over the pair's contracts + last_date TEXT, -- MAX(signed_at) over the pair's contracts PRIMARY KEY (authority_id, bidder_id) ); diff --git a/packages/db/migrations/0002_contract_health.sql b/packages/db/migrations/0002_contract_health.sql new file mode 100644 index 000000000..a1a5bcf97 --- /dev/null +++ b/packages/db/migrations/0002_contract_health.sql @@ -0,0 +1,15 @@ +-- Health-index foundation: add the nine columns required by the Contract Quality / Health Index spec +-- (docs/contract-quality-spec.local.md §7.1). All nine use ADD COLUMN which is idempotent-safe when +-- run once against an already-seeded local D1 (D1/SQLite silently ignores duplicate ADD COLUMN only +-- via IF NOT EXISTS — not supported — so this migration must be applied exactly once). +-- The same columns are also folded into 0000_init.sql so fresh imports include them directly. + +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/scripts/normalize-raw.sql b/scripts/normalize-raw.sql index 2bdb9e254..2c21e1ea0 100644 --- a/scripts/normalize-raw.sql +++ b/scripts/normalize-raw.sql @@ -74,7 +74,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, @@ -105,7 +106,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 (SELECT 1 FROM authorities a WHERE a.id = 'auth:' || t.authority_eik); @@ -329,7 +331,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 CASE WHEN x.source LIKE 'eop:%' THEN 'c:e:' || COALESCE(x.unp, '') || ':' || COALESCE(x.contract_number, '') || ':' || @@ -383,7 +386,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 d52642d16..b22d2b898 100644 --- a/scripts/precompute.sql +++ b/scripts/precompute.sql @@ -39,6 +39,13 @@ UPDATE contracts SET WHEN fx_rate IS NOT NULL THEN current_value * fx_rate ELSE NULL END; +UPDATE tenders SET + estimated_value_eur = CASE + WHEN currency = 'EUR' THEN estimated_value + WHEN COALESCE(currency, 'BGN') = 'BGN' THEN estimated_value / 1.95583 + ELSE NULL END +WHERE estimated_value IS NOT 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, @@ -133,11 +140,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 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 e051a7ed2..72c32b9d0 100644 --- a/scripts/refresh-slice.sql +++ b/scripts/refresh-slice.sql @@ -142,7 +142,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, @@ -173,7 +174,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 (SELECT 1 FROM authorities a WHERE a.id = 'auth:' || t.authority_eik) @@ -211,7 +213,8 @@ 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, + corrections_count = CASE WHEN tenders.procedure_type = 'неизвестна' THEN COALESCE(excluded.corrections_count, tenders.corrections_count) ELSE tenders.corrections_count END; -- @refresh-batch lots INSERT OR IGNORE INTO lots (id, tender_id, title, cpv_code, estimated_value) @@ -522,7 +525,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, @@ -563,7 +567,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, @@ -766,7 +773,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, @@ -807,7 +815,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, @@ -1007,7 +1018,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 @@ -1045,6 +1056,8 @@ SELECT published_at, document_number, description, + reason, + circumstances, source FROM dedup WHERE rn = 1; @@ -1293,8 +1306,9 @@ WHERE authority_id IN (SELECT authority_id FROM refresh_touched_authorities); -- @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; From e3fc6b3516aa81e7cbfa809b7dd5b2f611e6abf4 Mon Sep 17 00:00:00 2001 From: Bilko Date: Wed, 1 Jul 2026 17:30:24 -0700 Subject: [PATCH 02/37] feat(etl): health index phase-4 rollups (derive-health.sql) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/derive-health.sql building authority_health_rollup, bidder_health_rollup, sector_concentration, and health_percentiles on the served D1 (docs/contract-quality-spec.local.md §7.2/§8), and wire a new --derive=health mode into scripts/import.mjs so these can be rebuilt standalone against an existing local corpus without the full ~25-minute re-import. This is the entity-grain foundation the per-contract scoring (next PRD group) joins against. --- packages/db/migrations/0000_init.sql | 38 ++++++ scripts/derive-health.sql | 179 +++++++++++++++++++++++++++ scripts/import.mjs | 26 +++- 3 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 scripts/derive-health.sql diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql index d32796a4d..1e7d08721 100644 --- a/packages/db/migrations/0000_init.sql +++ b/packages/db/migrations/0000_init.sql @@ -365,3 +365,41 @@ 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 docs/contract-quality-spec.local.md §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 +); diff --git a/scripts/derive-health.sql b/scripts/derive-health.sql new file mode 100644 index 000000000..c08ceab86 --- /dev/null +++ b/scripts/derive-health.sql @@ -0,0 +1,179 @@ +-- 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: docs/contract-quality-spec.local.md §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; +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)) +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 91ad72937..3c629add2 100644 --- a/scripts/import.mjs +++ b/scripts/import.mjs @@ -119,7 +119,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; } @@ -217,8 +218,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`); } function runFullDerive() { @@ -230,10 +231,17 @@ function runFullDerive() { execSql(resolve(root, 'scripts/promote-amendments.sql')); assertFxPopulated(); execSql(resolve(root, 'scripts/precompute.sql')); + runHealthDerive(); assertIntegrity(d1, { label: 'full derive (D1)' }); reportAnomalies(d1, 'full derive (D1)'); } +// Standalone Phase 4/5 re-derive for the Contract Quality / Health Index (docs/contract-quality-spec.local.md +// §8) — runs against the already-populated served D1 without the ~25-minute full re-import. +function runHealthDerive() { + execSql(resolve(root, 'scripts/derive-health.sql')); +} + function runSliceDerive() { execSql(resolve(root, 'scripts/derive-amendments.sql')); run('node', ['scripts/load-fx.mjs', '--apply', ...passthru]); @@ -355,12 +363,19 @@ if (arg('work-db') !== undefined) { process.exit(0); } +let deriveMode = String(arg('derive') || 'full'); + +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(); @@ -374,6 +389,7 @@ validateDeriveMode(deriveMode); run('node', ['scripts/load-eop.mjs', '--apply', ...loadFlags, ...passthru]); if (deriveMode === 'slice') runSliceDerive(); +else if (deriveMode === 'health') runHealthDerive(); else runFullDerive(); execSqlStatements(dropTransientStagingStatements(), 'drop-transient-staging'); From 885775d231b6b2beac40e0061f2b62ffd1c3c38e Mon Sep 17 00:00:00 2001 From: Bilko Date: Wed, 1 Jul 2026 18:06:11 -0700 Subject: [PATCH 03/37] feat(etl): contract_features leaves, peer keys, coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5a+5b of the Contract Quality / Health Index (docs/contract-quality-spec.local.md §5-§7.3, §12): scripts/derive-contract-features.sql builds contract_features (one row per contract, 194,484 rows), populating raw leaf values, the effective peer key (§5.6 fine→mid→coarse→GLOBAL fallback), and the [0,1] coverage score. Scoring UPDATEs (score_a..score_overall) are left NULL for the next PRD (group 338). Verified against the local corpus: contract_features_rows = contracts_rows = 194,484; score_coverage non-NULL and in [0,1] for all rows; effective_peer_key non-NULL with peer_n >= 30 or 'GLOBAL'; scoring_regime='framework' on 3,238 rows (ДСП/КС/DPS regime, contract-grain); single_offer=1 on 78,739 rows (matches the corpus bids_received=1 tally). --- packages/db/migrations/0000_init.sql | 35 ++++ scripts/derive-contract-features.sql | 287 +++++++++++++++++++++++++++ scripts/import.mjs | 1 + 3 files changed, 323 insertions(+) create mode 100644 scripts/derive-contract-features.sql diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql index 1e7d08721..9c7891c27 100644 --- a/packages/db/migrations/0000_init.sql +++ b/packages/db/migrations/0000_init.sql @@ -403,3 +403,38 @@ 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 docs/contract-quality-spec.local.md §7.3. +-- score_a..score_e / score_overall are REALs in [0,1]; populated by the scoring UPDATEs +-- (group 338 PRD), NULL until then. +-- =================================================================================== + +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 +); +CREATE INDEX idx_contract_features_overall ON contract_features(score_overall); +CREATE INDEX idx_contract_features_peer ON contract_features(effective_peer_key); diff --git a/scripts/derive-contract-features.sql b/scripts/derive-contract-features.sql new file mode 100644 index 000000000..4fade7c64 --- /dev/null +++ b/scripts/derive-contract-features.sql @@ -0,0 +1,287 @@ +-- 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: docs/contract-quality-spec.local.md §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 (this PRD): leaves + effective_peer_key/peer_n + score_coverage only. score_a..score_e and +-- score_overall are left NULL — the scoring UPDATEs are the NEXT PRD (group 338). +-- +-- 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. + +CREATE TABLE IF NOT EXISTS 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 — populated by the NEXT PRD (group 338) + score_a REAL, score_b REAL, score_c REAL, score_d REAL, score_e REAL, + score_overall REAL, computed_at TEXT +); +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); + +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; + +DELETE FROM contract_features; + +-- ── 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). +CREATE TABLE contract_regime AS +SELECT + c.id AS contract_id, + CASE + WHEN t.procedure_type IN ( + 'Динамична система за покупки', 'Квалификационна система', + 'Ограничена процедура по ДСП', 'Ограничена процедура по КС', + 'Договаряне с предварителна покана за участие по КС' + ) OR c.dps_contract = 1 THEN 1 ELSE 0 + END AS is_framework_regime, + CASE + 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.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 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). + SELECT unp, contract_number, value_delta AS first_delta, published_at AS first_published_at + FROM ( + SELECT unp, contract_number, value_delta, published_at, + 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 ( + 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, + CASE WHEN fa.first_delta IS NULL THEN NULL + WHEN fa.first_delta > 0 AND c.signing_value > 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 +JOIN tenders t ON t.id = c.tender_id +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 +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.contract_id; + +DROP TABLE contract_regime; +DROP TABLE peer_fine_counts; +DROP TABLE peer_mid_counts; +DROP TABLE peer_coarse_counts; + +-- 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. +SELECT + (SELECT COUNT(*) FROM contract_features) AS contract_features_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; diff --git a/scripts/import.mjs b/scripts/import.mjs index 3c629add2..47a3f3384 100644 --- a/scripts/import.mjs +++ b/scripts/import.mjs @@ -240,6 +240,7 @@ function runFullDerive() { // §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')); } function runSliceDerive() { From 9e30d46083e74d6b63e094715c098f32d1fb892e Mon Sep 17 00:00:00 2001 From: Bilko Date: Wed, 1 Jul 2026 18:06:31 -0700 Subject: [PATCH 04/37] fix(etl): guard first_amend_shock against NULL/mismatched-currency signing values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on the contract_features PRD caught two gaps in the NEW-C6 first-amendment- shock leaf: it fell back to 0 (not NULL) when signing_value was NULL/<=0 instead of staying unknown, and compared amendments.value_delta (amendments.currency) directly against contracts.signing_value (contracts.currency) with no currency check. Both now resolve to NULL — re-verified against the local corpus (194,484/194,484 rows, first_amend_shock: 179,100 NULL / 15,151 zero / 233 flagged). --- scripts/derive-contract-features.sql | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/derive-contract-features.sql b/scripts/derive-contract-features.sql index 4fade7c64..0331bdb71 100644 --- a/scripts/derive-contract-features.sql +++ b/scripts/derive-contract-features.sql @@ -119,9 +119,11 @@ amendment_agg AS ( 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). - SELECT unp, contract_number, value_delta AS first_delta, published_at AS first_published_at + -- `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, + 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 @@ -194,8 +196,14 @@ SELECT 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 fa.first_delta > 0 AND c.signing_value > 0 AND fa.first_delta > 0.30 * c.signing_value + WHEN c.signing_value IS NULL OR c.signing_value <= 0 THEN NULL + WHEN fa.first_currency IS NOT NULL AND 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 @@ -278,8 +286,12 @@ DROP TABLE peer_mid_counts; DROP TABLE peer_coarse_counts; -- 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. +-- §12.2 completeness guard for the 21-value procedure_type vocabulary. contract_features_rows must +-- equal contracts_rows — the leaf INSERT inner-joins tenders/bidders/contract_regime, so a future +-- orphaned contracts.bidder_id/tender_id (SQLite doesn't enforce FKs unless PRAGMA foreign_keys=ON) +-- would silently drop that contract from the feature store without this check. SELECT + (SELECT COUNT(*) FROM contracts) AS contracts_rows, (SELECT COUNT(*) FROM contract_features) AS contract_features_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, From dcd4e304d2b13093fb4f319b2311a7f76d1d463e Mon Sep 17 00:00:00 2001 From: Bilko Date: Wed, 1 Jul 2026 20:18:39 -0700 Subject: [PATCH 05/37] feat(etl): contract health scoring 0-1, quality rollups, and pipeline wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pillar scores A-E and score_overall = 0.6*wmean + 0.4*worst on the [0,1] scale with the five-state value_flag gate; six *_quality_totals rollup grains; health derive wired into full/slice derive and ship-domain; scripts/validate-health.mjs runs the spec §10 checks (18/18 pass on the 194k-contract local corpus, 194,481 scored, 3 value_suspect unknown). --- docs/etl.md | 9 + packages/db/migrations/0000_init.sql | 41 ++- scripts/derive-contract-features.sql | 485 ++++++++++++++++++++++++++- scripts/import.mjs | 30 +- scripts/ship-domain.mjs | 7 + scripts/validate-health.mjs | 199 +++++++++++ 6 files changed, 764 insertions(+), 7 deletions(-) create mode 100644 scripts/validate-health.mjs diff --git a/docs/etl.md b/docs/etl.md index a060a6853..3a2b0feac 100644 --- a/docs/etl.md +++ b/docs/etl.md @@ -421,6 +421,15 @@ 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 — пълно преизчисление, не инкрементално. + ## Свързани документи - [`architecture.md`](architecture.md) — архитектурата на платформата. diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql index 9c7891c27..2f054e259 100644 --- a/packages/db/migrations/0000_init.sql +++ b/packages/db/migrations/0000_init.sql @@ -434,7 +434,46 @@ CREATE TABLE contract_features ( 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 + score_overall REAL, computed_at TEXT, + -- A1 leaf, auditable (§5.5/§5.6 PERCENT_RANK floor) — populated by the scoring UPDATEs (group 338) + 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 +); diff --git a/scripts/derive-contract-features.sql b/scripts/derive-contract-features.sql index 0331bdb71..767df5220 100644 --- a/scripts/derive-contract-features.sql +++ b/scripts/derive-contract-features.sql @@ -26,7 +26,12 @@ -- 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. -CREATE TABLE IF NOT EXISTS contract_features ( +-- contract_features gains score_a_bids/peer_has_multi this PRD (group 338, §12.0 [0,1] scale) — an +-- already-applied local table predates those columns, and SQLite has no "ADD COLUMN IF NOT EXISTS", +-- so DROP+CREATE (every run already DELETEs and fully re-INSERTs all rows below, so this is a no-op +-- for data, schema-only for structure) replaces the old CREATE TABLE IF NOT EXISTS. +DROP TABLE IF EXISTS contract_features; +CREATE TABLE contract_features ( contract_id TEXT PRIMARY KEY REFERENCES contracts(id), -- peer + coverage effective_peer_key TEXT, peer_n INTEGER, @@ -47,9 +52,11 @@ CREATE TABLE IF NOT EXISTS contract_features ( -- 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 — populated by the NEXT PRD (group 338) + -- 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 + 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 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); @@ -285,6 +292,281 @@ 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 (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). +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 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 +WHERE bids_received >= 1; +CREATE UNIQUE INDEX idx_tmp_a1 ON tmp_a1(contract_id); + +UPDATE contract_features SET score_a_bids = tmp_a1.a1 +FROM tmp_a1 WHERE tmp_a1.contract_id = contract_features.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 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 SET peer_has_multi = tmp_peer_multi.has_multi +FROM tmp_peer_multi WHERE tmp_peer_multi.effective_peer_key = contract_features.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 +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 cf JOIN tmp_score_ctx ctx ON ctx.contract_id = cf.contract_id; +CREATE UNIQUE INDEX idx_tmp_b1 ON tmp_b1(contract_id); + +SELECT (SELECT COUNT(*) FROM tmp_b1 WHERE unmapped = 1) AS unmapped_procedure_rows; + +UPDATE contract_features +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.is_outside_zop = 1 AND w.exemption_legal_basis IS NULL THEN -0.20 + WHEN contract_features.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.is_meat = 0 THEN -0.05 ELSE 0 END + + CASE WHEN contract_features.is_accelerated = 1 THEN -0.15 ELSE 0 END + + CASE WHEN contract_features.bid_window_days < 15 AND contract_features.is_open_procedure = 1 + AND contract_features.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.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, + 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 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 +SET score_c = CASE + WHEN contract_features.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.has_reason_text = 0 THEN -0.15 ELSE 0 END + + CASE WHEN contract_features.first_amend_shock = 1 THEN -0.10 ELSE 0 END + )) * (CASE WHEN contract_features.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.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 cf; +CREATE UNIQUE INDEX idx_tmp_d ON tmp_d(contract_id); + +UPDATE contract_features +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.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 +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.date_flag = 'signed_after_publication' THEN 0.10 ELSE 0 END + - CASE WHEN contract_features.subcontract_passthrough >= 1.0 THEN 0.15 + WHEN contract_features.subcontract_passthrough > 0.70 THEN 0.10 ELSE 0 END + - CASE WHEN contract_features.corrections_count >= 3 THEN 0.10 ELSE 0 END + - CASE WHEN contract_features.duration_days > 1825 AND contract_features.scoring_regime <> 'framework' THEN 0.15 + WHEN contract_features.duration_days > 1095 AND contract_features.scoring_regime <> 'framework' THEN 0.10 ELSE 0 END + ), 3) +FROM tmp_score_ctx ctx +WHERE ctx.contract_id = contract_features.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 +SET score_overall = CASE + WHEN contract_features.value_flag = 'value_suspect' THEN NULL + WHEN contract_features.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 +) AS w +WHERE w.contract_id = contract_features.contract_id; + -- 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 — the leaf INSERT inner-joins tenders/bidders/contract_regime, so a future @@ -296,4 +578,199 @@ SELECT (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 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, + (SELECT MIN(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 score_d 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 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 score_d 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/import.mjs b/scripts/import.mjs index 47a3f3384..dfa6e8e52 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, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -80,9 +80,31 @@ 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); + } + throw new Error(`Command failed: wrangler d1 execute ${d1Name} ${loc} --file ${file}`); +} 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`); } @@ -249,6 +271,10 @@ function runSliceDerive() { execSql(resolve(root, 'scripts/load-nuts.sql')); execSql(resolve(root, 'scripts/seed-state-owned.sql')); runRefreshSliceBatches(); + // 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 (docs/contract-quality-spec.local.md + // §8) is a documented future optimization once the full recompute cost is measured on prod D1. + runHealthDerive(); assertIntegrity(d1, { label: 'slice derive (D1)' }); reportAnomalies(d1, 'slice derive (D1)'); } diff --git a/scripts/ship-domain.mjs b/scripts/ship-domain.mjs index 531751fd7..94a99d538 100755 --- a/scripts/ship-domain.mjs +++ b/scripts/ship-domain.mjs @@ -222,6 +222,13 @@ 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 (docs/contract-quality-spec.local.md §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. +console.log('==> health derive on served D1'); +d1File(resolve(root, 'scripts/derive-health.sql')); +d1File(resolve(root, 'scripts/derive-contract-features.sql')); + // 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..96440cd3f --- /dev/null +++ b/scripts/validate-health.mjs @@ -0,0 +1,199 @@ +#!/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 } from 'node:url'; +import { readdirSync } from 'node:fs'; + +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'`, + ); + if (suspects.length !== 3) throw new Error(`expected 3 value_suspect rows, found ${suspects.length}`); + 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 `3 value_suspect rows, all score_overall NULL, all authorities scored { + const years = all(`SELECT year FROM year_quality_totals`).map((r) => r.year); + const missing = ['2020', '2021', '2022', '2023', '2024', '2025', '2026'].filter((y) => !years.includes(y)); + 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.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; + } + 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); From ffb7b6e5b472dbc9d0c046f08c45f7db1bcba6a9 Mon Sep 17 00:00:00 2001 From: Bilko Date: Wed, 1 Jul 2026 19:29:57 -0700 Subject: [PATCH 06/37] =?UTF-8?q?feat(web):=20=D0=BE=D0=B1=D0=B7=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=B3=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D0=B5=20=E2=80=94=20=D0=BB=D0=B5=D1=89=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5/CPV/=D0=BA=D1=80=D1=8A=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=81=D0=B0=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/components/ComboTrendChart.tsx | 154 +++++ apps/web/app/components/TrendChart.tsx | 9 +- apps/web/app/lib/analytics-lenses.ts | 4 +- apps/web/app/routes/trends.tsx | 623 ++++++++++++++++---- apps/web/app/styles/pages.css | 562 ++++++++++++++++++ packages/api-contract/src/index.ts | 40 +- packages/db/src/queries/trend.test.ts | 231 +++++++- packages/db/src/queries/trend.ts | 276 ++++++++- 8 files changed, 1771 insertions(+), 128 deletions(-) create mode 100644 apps/web/app/components/ComboTrendChart.tsx diff --git a/apps/web/app/components/ComboTrendChart.tsx b/apps/web/app/components/ComboTrendChart.tsx new file mode 100644 index 000000000..3c09c600b --- /dev/null +++ b/apps/web/app/components/ComboTrendChart.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; +import { count, money, monthYear } from '@sigma/shared'; + +// 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); + + // Final period is partial (still filling): dashed line tail + faded bar, like TrendChart. + const partialIdx = points.findIndex((p) => p.partial); + 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)}` : ''; + + // x-axis year labels at the first period of each year (or every point at year grain). + const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01'; + const ticks = points + .map((p, i) => ({ i, year: p.period.slice(0, 4) })) + .filter(({ i }) => yearStart == null || points[i]!.period.endsWith(yearStart)); + + 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/TrendChart.tsx b/apps/web/app/components/TrendChart.tsx index 9248669de..84450aebd 100644 --- a/apps/web/app/components/TrendChart.tsx +++ b/apps/web/app/components/TrendChart.tsx @@ -1,4 +1,4 @@ -import type { TrendPoint } from '@sigma/api-contract'; +import type { TrendGranularity, TrendPoint } from '@sigma/api-contract'; // 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 +13,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 +32,11 @@ 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). + // x-axis ticks at the first month/quarter of each year, or at every point (year granularity). + const yearStart = granularity === 'year' ? null : granularity === 'quarter' ? '-Q1' : '-01'; const ticks = points .map((p, i) => ({ i, year: p.period.slice(0, 4) })) - .filter((t, idx) => granularity === 'year' || points[idx]!.period.endsWith('-01')); + .filter((_t, idx) => yearStart == null || points[idx]!.period.endsWith(yearStart)); // 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..53c8dec0f 100644 --- a/apps/web/app/lib/analytics-lenses.ts +++ b/apps/web/app/lib/analytics-lenses.ts @@ -11,8 +11,8 @@ export const ANALYTICS_LENSES = [ }, { href: '/trends', - title: 'Тренд', - desc: 'Как се движат разходите във времето по месеци и години.', + title: 'Договори — обзор', + desc: 'Договорите във времето, по CPV код, или двете наведнъж — с типичните цени по група.', }, { href: '/competition', diff --git a/apps/web/app/routes/trends.tsx b/apps/web/app/routes/trends.tsx index bd521e54c..164c16c30 100644 --- a/apps/web/app/routes/trends.tsx +++ b/apps/web/app/routes/trends.tsx @@ -1,23 +1,31 @@ -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 } 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, + 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 +34,523 @@ 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 = context.cloudflare.env.DB; - 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 = await getCpvGroupMedians(db, 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('.', ',')}`; +} + +function relLabel(valueEur: number, medianEur: number): { text: string; cls: string } { + 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; + +function logMax(groups: CpvGroupStat[]): number { + const max = Math.max(1e6, ...groups.map((g) => g.maxEur)); + return 10 ** Math.ceil(Math.log10(max)); +} + +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 ( + + ); +} + +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 ( + + ); +} + +// ── 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 && ( + + )} + {cpvRows.map((g) => { + const active = g.group === cpv; + return ( + + {compact && ( + + )} + {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 код, или двете наведнъж. Изберѝ ъгъл; списъкът долу се сглобява от избора. Договорите без валидна дата или стойност не влизат в изгледа." /> -
submit(e.currentTarget)} - > - - - - -
- -

- {navigating ? 'Обновяване на визуализацията…' : 'Визуализацията е обновена.'} -

- - {unknownSector && ( - -

Избраният сектор не съществува. Показваме всички сектори.

-
+ + )} -
- {data.points.length >= 2 ? ( - + {angle === 'cpv' && cpvPanel(false)} + + {angle === 'cross' && ( +
+
+

+ Избери година +

+

После стеснѝ по CPV код от съседния списък.

+ {trend.points.length >= 2 && ( + + )} +
+ {yearCards.map((y) => ( + + {y.year} + + ))} +
+
+ {cpvPanel(true)} +
+ )} + +
+
+
+

+ {sort === 'value' ? 'Договори · по стойност' : 'Договори · най-нови'} +

+

+ {count(contracts.length)} {plural(contracts.length, 'договор', 'договора')} + {contracts.length === 24 ? ' (показани първите 24)' : ''} · {scopeText} +

+
+
+ Подредба +
+ {sorts.map((s) => ( + + {s.label} + + ))} +
+
+
+ {contracts.length ? ( +
    + {contracts.map((c) => { + const cohort = c.cpvGroup ? cohorts.get(c.cpvGroup) : undefined; + const rel = cohort ? relLabel(c.valueEur, cohort.medianEur) : null; + return ( +
  • + + + {fmtDate(c.signedAt)} + {money(c.valueEur)} + + {c.authorityName} + + + {c.bidderName} + + + {c.cpvGroup && CPV {c.cpvGroup}} + {cohort?.name ?? ''} + {rel && {rel.text}} + + +
  • + ); + })} +
) : ( -

Няма достатъчно данни за избраните филтри.

+

Няма договори за този избор.

)} -
- -
- r.year} - caption="Разходи по години" - /> -
+

+ „Спрямо типичното" сравнява стойността на договора с медианата за неговия CPV код. + Данните нямат количества, затова по-високата стойност често значи просто по-голям обем — + това е ориентир за разглеждане, не оценка. +

+

- Графиката включва договорите с валидна дата на сключване ({pct(data.coverage.pct)} от - тях). Последният период е непълен и е отбелязан като „частично". Виж методологията за - подробности. + Изгледът включва договорите с валидна дата на сключване и стойност в евро. Последният + период е непълен и е отбелязан като „частично". Виж методологията за подробности.

diff --git a/apps/web/app/styles/pages.css b/apps/web/app/styles/pages.css index 6b2507d45..00ec8f79a 100644 --- a/apps/web/app/styles/pages.css +++ b/apps/web/app/styles/pages.css @@ -613,3 +613,565 @@ 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; + } +} diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 2adfeb292..709f3bfcb 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -427,8 +427,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 @@ -443,7 +445,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 @@ -452,10 +454,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. diff --git a/packages/db/src/queries/trend.test.ts b/packages/db/src/queries/trend.test.ts index 4a92e0b77..ef88d1750 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 { + 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,197 @@ 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'); + }); +}); + +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..fd9fbf96e 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,226 @@ 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; + const at = (rank: number) => rows.find((r) => r.rn === rank)?.v ?? rows[0]!.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 }>(), + 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, + })); +} From f2ea743102bc0ee84f59d4d275283e4282e244fa Mon Sep 17 00:00:00 2001 From: Bilko Date: Wed, 1 Jul 2026 19:29:42 -0700 Subject: [PATCH 07/37] =?UTF-8?q?feat(web):=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20=E2=80=9E=D0=98=D0=BD=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=BE=D1=82=D0=BE"=20(contract=20quality=20index)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/lib/analytics-lenses.ts | 5 + apps/web/app/routes.ts | 1 + apps/web/app/routes/analytics.tsx | 36 +- apps/web/app/routes/quality.tsx | 920 ++++++++++++++++++++++++ apps/web/app/styles/pages.css | 638 ++++++++++++++++ packages/api-contract/src/index.ts | 116 +++ packages/db/src/queries/index.ts | 1 + packages/db/src/queries/quality.test.ts | 369 ++++++++++ packages/db/src/queries/quality.ts | 508 +++++++++++++ 9 files changed, 2591 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/routes/quality.tsx create mode 100644 packages/db/src/queries/quality.test.ts create mode 100644 packages/db/src/queries/quality.ts diff --git a/apps/web/app/lib/analytics-lenses.ts b/apps/web/app/lib/analytics-lenses.ts index 53c8dec0f..dcac9ca5e 100644 --- a/apps/web/app/lib/analytics-lenses.ts +++ b/apps/web/app/lib/analytics-lenses.ts @@ -19,6 +19,11 @@ export const ANALYTICS_LENSES = [ title: 'Конкуренция', desc: 'Къде има висок дял „една оферта“ и концентрация на доставчици.', }, + { + href: '/quality', + title: 'Индекс на качеството', + desc: 'Колко здрав е процесът по всеки договор: пет измерения, една оценка 0–100.', + }, ] as const; export const ANALYTICS_NAV_PATHS = [ 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 9565a8caf..eeddae990 100644 --- a/apps/web/app/routes/analytics.tsx +++ b/apps/web/app/routes/analytics.tsx @@ -1,5 +1,11 @@ import { Link } from 'react-router'; -import { getCompetitionSummary, getFlows, getRegionalSpending, getSpendingTrend } from '@sigma/db'; +import { + getCompetitionSummary, + getFlows, + getQualitySummary, + getRegionalSpending, + getSpendingTrend, +} from '@sigma/db'; import { count, money, pct } from '@sigma/shared'; import type { ReactNode } from 'react'; import type { Route } from './+types/analytics'; @@ -29,11 +35,12 @@ export function headers() { export async function loader({ context }: Route.LoaderArgs) { const db = context.cloudflare.env.DB; - 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(() => null), // the quality tables land with the next full derive ]); return { @@ -53,6 +60,7 @@ export async function loader({ context }: Route.LoaderArgs) { totals: competition.totals, topConcentration: competition.topConcentration, }, + quality, }; } @@ -65,7 +73,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 ( <> @@ -188,6 +196,28 @@ export default function Analytics({ loaderData }: Route.ComponentProps) { )} )} + {lens.href === '/quality' && ( +
+

Среден индекс на корпуса

+ {quality && quality.scoredContracts > 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/quality.tsx b/apps/web/app/routes/quality.tsx new file mode 100644 index 000000000..b804fa4c8 --- /dev/null +++ b/apps/web/app/routes/quality.tsx @@ -0,0 +1,920 @@ +import { Link } from 'react-router'; +import type { + QualityContractRow, + QualityCoverageTier, + QualityGrain, + QualityPillars, + QualityRankRow, + QualityScorecard, +} from '@sigma/api-contract'; +import { count, date, money, pct, plural } from '@sigma/shared'; +import { getQuality, QUALITY_WEIGHTS } 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 { TotalsStrip, type Total } from '../components/TotalsStrip'; +import { Callout, Chip, Section } from '../components/ui'; +import { publicCache } from '../lib/cache'; +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: ['ред на дати', 'разкрито подизпълнение', 'срок / заключване', 'корекции по обявата'], + }, +]; + +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 = context.cloudflare.env.DB; + const sp = new URL(request.url).searchParams; + const data = await getQuality(db, { + grain: (sp.get('grain') as QualityGrain | null) ?? undefined, + sort: sp.get('sort') === 'contracts' ? 'contracts' : 'score', + contractSort: sp.get('csort') === 'value' ? 'value' : 'score', + sel: sp.get('sel'), + contractId: sp.get('contract'), + }); + 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'; +} + +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; + const { overview, ranking, contracts, scorecard, scope } = data; + + // Preserve the page state in every internal link (grain/sort/selection/scorecard subject). + 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, + csort: scope.contractSort === 'score' ? null : scope.contractSort, + sel: scope.sel, + ...patch, + }; + for (const [k, v] of Object.entries(state)) if (v != null && v !== '') params.set(k, 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) => ( +
  • +