From b6b4fc57822401cb54b45d8d8af90f95c2cc4915 Mon Sep 17 00:00:00 2001 From: DiyanaDimitrova Date: Sun, 12 Jul 2026 00:28:54 +0300 Subject: [PATCH 1/6] feat(web): homepage flagged-value + risk-signal breakdown (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leading number on the homepage: the total EUR running through contracts that carry a risk signal, with a breakdown by signal type and by category (sector / authority type), each drillable to the contracts behind it, next to a non-accusatory methodology link. Computed LIVE over existing columns under the 1h edge cache — NO schema / precompute / migration change (ships as a pure Worker deploy per docs/deploy.md). Signal predicates mirror the per-contract RiskIndicators (riskLogic.ts) so the homepage number stays consistent with the badge on a contract page: no-competition (admitted bids = 1), cost growth (current > 1.2x signing), value/date anomaly. - packages/db/queries/flagged.ts: shared FLAG_SQL predicates (single source of truth) + getFlaggedValue (de-duplicated total, overlapping by-type, category breakdowns summing to the total; canonical amount_eur basis, #98). - /contracts gains a risk-signal filter (flag) and an authority-type filter (type), query-layer only, so every homepage number drills down; registered in the CSV cache classifier + the edge cache-key allowlist. - Homepage section + methodology "Сигнали за риск" (de-dup vs overlap, tone). - Tests: real-SQLite aggregate + filter narrowing, parse + riskLogic parity. Closes #218 --- apps/web/app/lib/csv-export.test.ts | 2 + apps/web/app/lib/csv-export.ts | 10 +- apps/web/app/lib/filters.test.ts | 29 +++++ apps/web/app/lib/filters.ts | 12 +- apps/web/app/lib/query-params.ts | 1 + apps/web/app/routes/home.tsx | 85 +++++++++++++ apps/web/app/routes/methodology.tsx | 56 ++++++++- apps/web/app/styles/home.css | 71 +++++++++++ packages/api-contract/src/index.ts | 12 ++ packages/db/src/queries/contracts.ts | 20 +++ packages/db/src/queries/flagged.test.ts | 159 ++++++++++++++++++++++++ packages/db/src/queries/flagged.ts | 136 ++++++++++++++++++++ packages/db/src/queries/home.ts | 65 ++++++---- packages/db/src/queries/index.ts | 1 + packages/db/src/queries/keyset.test.ts | 4 + 15 files changed, 633 insertions(+), 30 deletions(-) create mode 100644 packages/db/src/queries/flagged.test.ts create mode 100644 packages/db/src/queries/flagged.ts diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts index 105c4778..2dd56383 100644 --- a/apps/web/app/lib/csv-export.test.ts +++ b/apps/web/app/lib/csv-export.test.ts @@ -335,6 +335,8 @@ describe('isUnfilteredCsvExport', () => { bidder: 'acme', q: 'rail', bids: 'one', + flags: ['no_competition'], + authorityTypes: ['министерство'], types: ['municipality'], kinds: ['company'], countBucket: '2-5', diff --git a/apps/web/app/lib/csv-export.ts b/apps/web/app/lib/csv-export.ts index 4756efda..e0daf64e 100644 --- a/apps/web/app/lib/csv-export.ts +++ b/apps/web/app/lib/csv-export.ts @@ -4,7 +4,15 @@ const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'; const CSV_CACHE_CONTROL = 'public, max-age=3600'; const CSV_MULTIPART_PART_SIZE = 8 * 1024 * 1024; -const ARRAY_FILTERS = ['years', 'sectors', 'procedureGroups', 'kinds', 'types'] as const; +const ARRAY_FILTERS = [ + 'years', + 'sectors', + 'procedureGroups', + 'kinds', + 'types', + 'flags', + 'authorityTypes', +] as const; // `bids` ('one' | null) is a response-affecting filter: without it here a „само една оферта" export // was misclassified as unfiltered and served from / written to the shared unfiltered cache object — // a cache-poisoning variant of #56/#122 on top of the wrong-data bug (#138). hasScalarFilter treats diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts index 158f1609..1aa4dbfb 100644 --- a/apps/web/app/lib/filters.test.ts +++ b/apps/web/app/lib/filters.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { CPV_SECTORS } from '@sigma/config'; +import { FLAG_TYPES } from '@sigma/db'; import { authorityListFilters, companyListFilters, @@ -13,9 +14,37 @@ import { withParams, } from './filters'; import { CANONICAL_QUERY_PARAMS } from './query-params'; +import type { RiskFlagType } from './riskLogic'; const sp = (q: string) => new URLSearchParams(q); +describe('contract risk-signal + authority-type filters (#218)', () => { + it('parses valid ?flag tokens and drops unknown ones', () => { + const f = contractListFilters(new URLSearchParams('flag=no_competition,all&flag=bogus')); + expect(f.flags).toEqual(['no_competition', 'all']); + }); + + it('leaves flags empty when the param is absent', () => { + expect(contractListFilters(new URLSearchParams('')).flags).toEqual([]); + }); + + it('parses ?type into authorityTypes', () => { + const f = contractListFilters(new URLSearchParams('type=министерство&type=община')); + expect(f.authorityTypes).toEqual(['министерство', 'община']); + }); + + it('the flagged.ts signal set matches riskLogic RiskFlagType (homepage ↔ contract-page parity)', () => { + // Compile-time: every FLAG_TYPES entry is a valid RiskFlagType. Runtime: the sets are equal. + const expected: RiskFlagType[] = [ + 'no_competition', + 'eu_no_competition', + 'high_markup', + 'anomalies', + ]; + expect(new Set(FLAG_TYPES)).toEqual(new Set(expected)); + }); +}); + describe('contractListFilters', () => { it('parses the bids filter the HTML list and CSV export must share (issue #138)', () => { const sp = new URLSearchParams('bids=1&year=2025&authority=123'); diff --git a/apps/web/app/lib/filters.ts b/apps/web/app/lib/filters.ts index 6e5d621b..af93c273 100644 --- a/apps/web/app/lib/filters.ts +++ b/apps/web/app/lib/filters.ts @@ -4,7 +4,12 @@ import { CPV_CATEGORIES, CPV_SECTORS, categoryForDivision } from '@sigma/config'; import type { EntityKind } from '@sigma/api-contract'; -import { normalizeAuthoritySort, normalizeCompanySort, normalizeContractSort } from '@sigma/db'; +import { + FLAG_TYPES, + normalizeAuthoritySort, + normalizeCompanySort, + normalizeContractSort, +} from '@sigma/db'; import type { CpvCategory } from '@sigma/config'; import type { FilterCategory, FilterGroup, FilterOption } from '../components/FilterRail'; import { CANONICAL_QUERY_PARAMS, INTENTIONALLY_UNKEYED } from './query-params'; @@ -13,6 +18,8 @@ export const PAGE_SIZE = { contracts: 15, companies: 25, authorities: 25 } as co export const MAX_MULTI_VALUES = 50; const KNOWN_SECTORS = new Set(CPV_SECTORS.map((s) => s.code)); +// Risk-signal tokens accepted on ?flag= (#218): each FlagType plus `all` (any signal). +const KNOWN_FLAGS = new Set([...FLAG_TYPES, 'all']); function allowedMulti(key: string, value: string): boolean { if (key === 'sector') return KNOWN_SECTORS.has(value); @@ -51,6 +58,8 @@ export function contractListFilters(sp: URLSearchParams) { bidder: sp.get('bidder'), q: sp.get('q'), bids: (sp.get('bids') === '1' ? 'one' : null) as 'one' | null, + flags: getMulti(sp, 'flag').filter((v) => KNOWN_FLAGS.has(v)), + authorityTypes: getMulti(sp, 'type'), }; } @@ -191,6 +200,7 @@ export const PARAM_ORDER = [ 'eu', 'bids', // /contracts single-bid filter 'value', + 'flag', 'authority', 'bidder', 'center', // /network focus entity diff --git a/apps/web/app/lib/query-params.ts b/apps/web/app/lib/query-params.ts index e7b603a3..9827f598 100644 --- a/apps/web/app/lib/query-params.ts +++ b/apps/web/app/lib/query-params.ts @@ -9,6 +9,7 @@ export const CANONICAL_QUERY_PARAMS = new Set([ 'count', 'cursor', 'eu', + 'flag', // /contracts: risk-signal filter (#218) — changes the result set + headline totals 'funding', 'g', 'kind', diff --git a/apps/web/app/routes/home.tsx b/apps/web/app/routes/home.tsx index a20541ed..68be186c 100644 --- a/apps/web/app/routes/home.tsx +++ b/apps/web/app/routes/home.tsx @@ -79,9 +79,18 @@ function SingleOfferTable({ items, allHref }: { items: ContractListItem[]; allHr ); } +// Bulgarian labels for the risk-signal types (keys mirror flagged.ts FLAG_TYPES / riskLogic.ts). +const FLAG_LABELS: Record = { + no_competition: 'Липса на конкуренция', + eu_no_competition: 'Липса на конкуренция (със средства от ЕС)', + high_markup: 'Ръст на стойността чрез анекси', + anomalies: 'Стойностна или времева аномалия', +}; + export default function Home({ loaderData }: Route.ComponentProps) { const { totals, + flagged, topCompanies, topMinistries, topMunicipalities, @@ -129,6 +138,82 @@ export default function Home({ loaderData }: Route.ComponentProps) { {totals.asOf ? `, последен договор ${date(totals.asOf)}` : ''}.

+
+

+ Договори със сигнали за риск +

+

+ Обща стойност на договорите, при които СИГМА отбелязва поне един структурен сигнал — липса + на конкуренция, ръст на стойността чрез анекси или стойностна/времева аномалия. Сигналите + са ориентири за преглед, не присъда. Как ги четем → +

+ +

+ + ≈ {moneyBare(flagged.totalEur)} € + в {count(flagged.contracts)} договора със сигнал → + +

+ +
+
+

По вид сигнал

+
    + {flagged.byType + .filter((r) => r.contracts > 0) + .map((r) => ( +
  • + + {FLAG_LABELS[r.type] ?? r.type} + + {moneyBare(r.eur)} € · {count(r.contracts)} + + +
  • + ))} +
+

+ Един договор може да носи няколко сигнала, затова редовете тук се застъпват и сборът + им надхвърля общата (де-дублирана) сума. +

+
+ +
+

По сектор

+
    + {flagged.bySector.map((s) => ( +
  • + + {s.label} + + {moneyBare(s.eur)} € · {count(s.contracts)} + + +
  • + ))} +
+
+ +
+

По тип институция

+
    + {flagged.byAuthorityType.map((a) => ( +
  • + + {a.typeGroup} + + {moneyBare(a.eur)} € · {count(a.contracts)} + + +
  • + ))} +
+
+
+
+

Най-активните институции diff --git a/apps/web/app/routes/methodology.tsx b/apps/web/app/routes/methodology.tsx index a05f92e2..a939285a 100644 --- a/apps/web/app/routes/methodology.tsx +++ b/apps/web/app/routes/methodology.tsx @@ -37,6 +37,7 @@ const TOC = [ ['identity', 'Имена, ЕИК, УНП'], ['gaps', 'Известни празнини в полетата'], ['export', 'Сваляне и достъп до данните'], + ['flagged', 'Сигнали за риск'], ['contact', 'Поправки и обратна връзка'], ]; @@ -517,8 +518,61 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {

Засега не предлагаме публично API в реално време.

+
+

10. Сигнали за риск

+

+ На началната страница СИГМА показва{' '} + общата стойност на договорите със сигнали за риск — договори, при + които поне един структурен признак заслужава преглед. Сигналите са{' '} + ориентири, не присъда: те не твърдят нарушение, а насочват към договори, + които си струва да се погледнат. Всяко число води до самите договори. +

+

+ Отбелязваме четири признака, същите, които се виждат и на страницата на всеки + договор: +

+
+
Липса на конкуренция
+
+

+ Допусната е една-единствена оферта (приети оферти = 1). Отделяме случаите със + средства от ЕС, където правилата за конкуренция са по-строги. +

+ → приети оферти = 1 +
+
Ръст на стойността чрез анекси
+
+

+ Текущата стойност е нараснала с над 20% спрямо стойността при подписване — + индикатор за оскъпяване след сключването. +

+ → текуща стойност > 1.2 × стойност при подписване +
+
Стойностна или времева аномалия
+
+

+ Стойност с непотвърдена достоверност (виж речника) или договор, подписан преди + датата си на публикуване. +

+ → value_flag / date_flag +
+
+ +

Как се смята сумата

+

+ Общата сума е де-дублирана — договор с няколко сигнала се брои + веднъж. Разбивката „по вид сигнал" обаче се застъпва (един договор може + да попадне в няколко реда), затова сборът ѝ надхвърля общата сума; разбивките по + сектор и по тип институция разделят множеството и се сумират до общата сума. + Стойностите стъпват на каноничната изчистена стойност в евро (сумираме само + договорите с достоверна стойност — договор с непотвърдена стойност се брои в + бройката, но с 0 €). +

+
+
+
-

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

+

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

Грешките поправяме ръчно при сигнал — двойни записи за институция/компания (изпратете двата ЕИК/линка) или сума, която не отговаря на оригиналния документ diff --git a/apps/web/app/styles/home.css b/apps/web/app/styles/home.css index 6f2777ec..9e8da6d0 100644 --- a/apps/web/app/styles/home.css +++ b/apps/web/app/styles/home.css @@ -183,3 +183,74 @@ font-size: 12px; white-space: nowrap; } + +/* Flagged-value section (#218): a lead accent number + three drill-down breakdown columns. */ +.flagged-lead { + margin: var(--s-5) 0 var(--s-6); +} +.flagged-lead a { + display: inline-flex; + align-items: baseline; + gap: var(--s-3); + text-decoration: none; + color: var(--ink); + flex-wrap: wrap; +} +.flagged-lead a:hover .flagged-sub { + color: var(--accent); +} +.flagged-num { + font: 600 clamp(30px, 4vw, 44px) / 1 var(--font-serif); + color: var(--accent); + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} +.flagged-sub { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--ink-soft); +} +.flagged-cols { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: var(--s-5) var(--s-7); +} +.flagged-h3 { + font: 500 10.5px/1 var(--font-mono); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-mid); + margin: 0 0 var(--s-2); + padding-bottom: var(--s-2); + border-bottom: 1px solid var(--ink); +} +.flagged-list { + list-style: none; + margin: 0; + padding: 0; +} +.flagged-list a { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--s-3); + padding: var(--s-3) 0; + border-bottom: 1px solid var(--rule-soft); + font: 14px/1.35 var(--font-sans); + text-decoration: none; + color: var(--ink); +} +.flagged-list a:hover { + color: var(--accent); +} +.flagged-val { + color: var(--ink-soft); + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 12px; + white-space: nowrap; +} +.flagged-note { + margin-top: var(--s-3); +} diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index ad7d6d1d..31d80712 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -53,8 +53,20 @@ export interface HomeTotals { refreshedAt: string; } +/** Homepage flagged-value summary (issue #218). `totalEur` is de-duplicated (a contract with several + * signals counts once); `byType` slices overlap; `bySector`/`byAuthorityType` partition the flagged set. */ +export interface FlaggedValue { + totalEur: number; + contracts: number; + byType: { type: string; eur: number; contracts: number }[]; + bySector: { code: string; label: string; eur: number; contracts: number }[]; + byAuthorityType: { typeGroup: string; eur: number; contracts: number }[]; +} + export interface HomeData { totals: HomeTotals; + /** Total € running through contracts with a risk signal, + breakdowns (issue #218). */ + flagged: FlaggedValue; topCompanies: CompanyListItem[]; topMinistries: AuthorityListItem[]; topMunicipalities: AuthorityListItem[]; diff --git a/packages/db/src/queries/contracts.ts b/packages/db/src/queries/contracts.ts index 4138c927..59f8da0f 100644 --- a/packages/db/src/queries/contracts.ts +++ b/packages/db/src/queries/contracts.ts @@ -6,6 +6,7 @@ import { CPV_SECTORS, PROCEDURE_GROUPS, procedureGroup } from '@sigma/config'; import { cleanName, entityName } from '@sigma/shared'; import { csvCell } from './csv'; import { assertCovers } from './filter-guard'; +import { flagPredicate } from './flagged'; import { authoritySlug, bareContractId, @@ -30,6 +31,8 @@ export interface ContractListParams { bidder?: string | null; // bidder slug q?: string | null; bids?: 'one' | null; + flags?: string[]; // risk-signal tokens (#218): a FlagType or `all`; OR-combined + authorityTypes?: string[]; // authority `type_group` buckets (#218 breakdown drill-down, `?type=`) cursor?: string | null; pageSize?: number; } @@ -44,6 +47,8 @@ export const CONTRACT_FILTER_KEYS = [ 'bidder', 'q', 'bids', + 'flags', + 'authorityTypes', ] as const satisfies readonly (keyof ContractListParams)[]; // Compile-time completeness guard (issue #138 bug class) — see filter-guard.ts. If this line @@ -177,6 +182,19 @@ function buildFilters(p: ContractListParams): { sql: string; params: unknown[] } ); params.push(match); } + if (p.flags?.length) { + // Risk-signal filter (#218): OR the requested flag predicates. An unrecognised token yields no + // predicate; if none are valid the filter matches nothing (rather than silently showing all). + const preds = p.flags + .map(flagPredicate) + .filter((x): x is string => x != null) + .map((x) => `(${x})`); + where.push(preds.length ? `(${preds.join(' OR ')})` : '1=0'); + } + if (p.authorityTypes?.length) { + where.push(`a.type_group IN (${qs(p.authorityTypes.length)})`); + params.push(...p.authorityTypes); + } return { sql: where.length ? ' WHERE ' + where.join(' AND ') : '', params }; } @@ -192,6 +210,8 @@ function contractFilterSignature(p: ContractListParams): string { bidder, q: searchMatchQuery(p.q ?? ''), bids: p.bids ?? null, + flags: p.flags?.length ? [...p.flags].sort() : null, + authorityTypes: p.authorityTypes?.length ? [...p.authorityTypes].sort() : null, } satisfies Record<(typeof CONTRACT_FILTER_KEYS)[number], unknown>; return filterSignature(filters); } diff --git a/packages/db/src/queries/flagged.test.ts b/packages/db/src/queries/flagged.test.ts new file mode 100644 index 00000000..f451aa2e --- /dev/null +++ b/packages/db/src/queries/flagged.test.ts @@ -0,0 +1,159 @@ +/// +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getFlaggedValue } from './flagged'; +import { listContracts } from './contracts'; + +// Integration test against a REAL SQLite built from the production migrations (node:sqlite), so the +// flag predicates and aggregate are proven to narrow/sum actual rows — the fake-D1 unit tests ignore +// WHERE clauses. Fixture exercises: de-duplicated total, overlapping by-type, category sums-to-total, +// and the amount_eur NULL basis (value_suspect contributes to the count but €0). +const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../migrations'); +const migrations = readdirSync(migrationsDir) + .filter((f) => f.endsWith('.sql')) + .sort(); + +// c1 no_competition (община, sector 45, 1000) · c2 eu_no_competition (министерство, 72, 2000) +// c3 high_markup (община, 45, 1500) · c4 anomalies via value_suspect (министерство, 72, amount NULL) +// c5 OVERLAP no_competition + high_markup (община, 45, 2000) · c6 clean, unflagged (5000) +const FIXTURE = ` +INSERT INTO authorities (id, name, bulstat, type_group) VALUES + ('auth:obshtina', 'Община', '100000001', 'община'), + ('auth:min', 'Министерство', '100000002', 'министерство'); +INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES + ('eik:200000001', 'Фирма', '200000001', '200000001', 1, 'company'); +INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status) VALUES + ('t:o45', 'UNP-1', 'Строеж', 'auth:obshtina', '45000000', 'открита процедура', 'awarded'), + ('t:m72', 'UNP-2', 'ИТ', 'auth:min', '72000000', 'открита процедура', 'awarded'); +INSERT INTO contracts + (id, tender_id, bidder_id, amount, currency, signed_at, bids_received, bids_rejected, eu_funded, + value_flag, date_flag, signing_value_eur, current_value_eur, amount_eur) VALUES + ('c1', 't:o45', 'eik:200000001', 1000, 'EUR', '2024-01-01', 1, 0, 0, 'ok', 'ok', 1000, 1000, 1000), + ('c2', 't:m72', 'eik:200000001', 2000, 'EUR', '2024-01-02', 1, 0, 1, 'ok', 'ok', 2000, 2000, 2000), + ('c3', 't:o45', 'eik:200000001', 1500, 'EUR', '2024-01-03', 3, 0, 0, 'ok', 'ok', 1000, 1500, 1500), + ('c4', 't:m72', 'eik:200000001', 9000, 'EUR', '2024-01-04', 3, 0, 0, 'value_suspect', 'ok', NULL, NULL, NULL), + ('c5', 't:o45', 'eik:200000001', 2000, 'EUR', '2024-01-05', 1, 0, 0, 'ok', 'ok', 1000, 2000, 2000), + ('c6', 't:m72', 'eik:200000001', 5000, 'EUR', '2024-01-06', 3, 0, 0, 'ok', 'ok', 5000, 5000, 5000); +`; + +function d1(db: DatabaseSync): D1Database { + return { + prepare(sql: string) { + let bound: (string | number | null)[] = []; + const stmt = { + bind(...params: (string | number | null)[]) { + bound = params; + return stmt; + }, + async all() { + return { results: db.prepare(sql).all(...bound) as T[] }; + }, + async first() { + return (db.prepare(sql).get(...bound) ?? null) as T | null; + }, + }; + return stmt; + }, + } as unknown as D1Database; +} + +let open: DatabaseSync | null = null; +function realDb(): D1Database { + const db = new DatabaseSync(':memory:'); + for (const m of migrations) db.exec(readFileSync(resolve(migrationsDir, m), 'utf8')); + db.exec(FIXTURE); + open = db; + return d1(db); +} +afterEach(() => { + open?.close(); + open = null; +}); + +const byType = (f: Awaited>, t: string) => + f.byType.find((r) => r.type === t)!; + +describe('getFlaggedValue', () => { + it('de-duplicates the total (a contract with two signals counts once)', async () => { + const f = await getFlaggedValue(realDb()); + // c1..c5 flagged (c6 clean). c4 is value_suspect → NULL amount_eur → counted, €0. + expect(f.contracts).toBe(5); + expect(f.totalEur).toBe(6500); // 1000 + 2000 + 1500 + 0 + 2000 + }); + + it('reports overlapping by-type slices (their sum exceeds the de-duplicated total)', async () => { + const f = await getFlaggedValue(realDb()); + expect(byType(f, 'no_competition')).toMatchObject({ eur: 3000, contracts: 2 }); // c1 + c5 + expect(byType(f, 'eu_no_competition')).toMatchObject({ eur: 2000, contracts: 1 }); // c2 + expect(byType(f, 'high_markup')).toMatchObject({ eur: 3500, contracts: 2 }); // c3 + c5 + expect(byType(f, 'anomalies')).toMatchObject({ eur: 0, contracts: 1 }); // c4 (NULL amount) + const sum = f.byType.reduce((n, r) => n + r.eur, 0); + expect(sum).toBe(8500); + expect(sum).toBeGreaterThan(f.totalEur); // c5 double-counted across two types + }); + + it('partitions by sector so the slices sum to the total', async () => { + const f = await getFlaggedValue(realDb()); + const s45 = f.bySector.find((s) => s.code === '45')!; + const s72 = f.bySector.find((s) => s.code === '72')!; + expect(s45).toMatchObject({ eur: 4500, contracts: 3 }); // c1 + c3 + c5 + expect(s72).toMatchObject({ eur: 2000, contracts: 2 }); // c2 + c4 + expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBe(f.totalEur); + }); + + it('partitions by authority type so the slices sum to the total', async () => { + const f = await getFlaggedValue(realDb()); + const obshtina = f.byAuthorityType.find((a) => a.typeGroup === 'община')!; + const min = f.byAuthorityType.find((a) => a.typeGroup === 'министерство')!; + expect(obshtina).toMatchObject({ eur: 4500, contracts: 3 }); + expect(min).toMatchObject({ eur: 2000, contracts: 2 }); + expect(f.byAuthorityType.reduce((n, a) => n + a.eur, 0)).toBe(f.totalEur); + }); +}); + +describe('/contracts flag filter', () => { + it('flag=no_competition narrows to the single-offer rows (incl. the overlapping one)', async () => { + const r = await listContracts(realDb(), { flags: ['no_competition'], pageSize: 10 }); + expect(r.total).toBe(2); // c1, c5 + }); + + it('flag=high_markup narrows to the cost-growth rows', async () => { + const r = await listContracts(realDb(), { flags: ['high_markup'], pageSize: 10 }); + expect(r.total).toBe(2); // c3, c5 + }); + + it('flag=all matches every flagged contract', async () => { + const r = await listContracts(realDb(), { flags: ['all'], pageSize: 10 }); + expect(r.total).toBe(5); // c1..c5, not the clean c6 + }); + + it('multiple flag tokens are OR-combined (union)', async () => { + const r = await listContracts(realDb(), { + flags: ['no_competition', 'anomalies'], + pageSize: 10, + }); + expect(r.total).toBe(3); // c1, c5 (no_competition) ∪ c4 (anomalies) + }); + + it('an unrecognised flag token matches nothing (not everything)', async () => { + const r = await listContracts(realDb(), { flags: ['bogus'], pageSize: 10 }); + expect(r.total).toBe(0); + }); + + it('type= narrows to contracts of that authority type_group', async () => { + const r = await listContracts(realDb(), { authorityTypes: ['министерство'], pageSize: 10 }); + expect(r.total).toBe(3); // c2, c4, c6 (all министерство tenders) + }); + + it('flag composes with sector/type instead of replacing them', async () => { + const r = await listContracts(realDb(), { + flags: ['all'], + authorityTypes: ['министерство'], + pageSize: 10, + }); + expect(r.total).toBe(2); // министерство ∩ flagged = c2, c4 + }); +}); diff --git a/packages/db/src/queries/flagged.ts b/packages/db/src/queries/flagged.ts new file mode 100644 index 00000000..6d5d3398 --- /dev/null +++ b/packages/db/src/queries/flagged.ts @@ -0,0 +1,136 @@ +// Flagged value (issue #218): the total € running through contracts that carry a risk signal, plus a +// breakdown by signal type and by category. Computed LIVE over existing columns (no schema/precompute +// change) — the caller edge-caches it for an hour, same basis as the single-offer scan in home.ts. +// +// The signal predicates mirror the per-contract RiskIndicators (apps/web/app/lib/riskLogic.ts) so the +// homepage number stays consistent with the badge a visitor sees on a contract page. Keep FLAG_TYPES in +// sync with riskLogic's RiskFlagType (asserted by flagged.test.ts). `c` is the `contracts` alias. + +import { CPV_SECTORS } from '@sigma/config'; +import type { FlaggedValue } from '@sigma/api-contract'; + +// The "unconfirmed value" flags (mirrors details.ts value.suspect: value_suspect | annex_suspect | +// review | value_low). Kept as a SQL list literal so the fragments read cleanly. +const SUSPECT_VALUE_FLAGS = "('value_suspect', 'annex_suspect', 'review', 'value_low')"; + +/** Per-signal SQL predicates — the single source of truth reused by the aggregate and the /contracts + * `flag` filter. Each is a self-contained boolean over the `contracts c` row. */ +export const FLAG_SQL = { + // Admitted bids (received − rejected) == 1, without EU funding. + no_competition: + 'c.bids_received IS NOT NULL AND (c.bids_received - COALESCE(c.bids_rejected, 0)) = 1 ' + + 'AND (c.eu_funded IS NULL OR c.eu_funded = 0)', + // Same, but EU-funded (surfaced separately, like riskLogic). + eu_no_competition: + 'c.bids_received IS NOT NULL AND (c.bids_received - COALESCE(c.bids_rejected, 0)) = 1 ' + + 'AND c.eu_funded = 1', + // Current value grew > 20% over the signing value (annex-driven cost growth); non-suspect only, so a + // value anomaly isn't double-counted here (mirrors details.ts deltaPct, which is null when suspect). + high_markup: + `c.value_flag NOT IN ${SUSPECT_VALUE_FLAGS} AND c.signing_value_eur IS NOT NULL ` + + 'AND c.signing_value_eur <> 0 AND (c.current_value_eur - c.signing_value_eur) > 0.2 * c.signing_value_eur', + // Value or date anomaly. + anomalies: `c.date_flag = 'signed_after_publication' OR c.value_flag IN ${SUSPECT_VALUE_FLAGS}`, +} as const; + +export type FlagType = keyof typeof FLAG_SQL; +export const FLAG_TYPES = Object.keys(FLAG_SQL) as FlagType[]; + +/** A contract is "flagged" when it carries at least one signal. Each predicate is parenthesised because + * `anomalies` is itself an OR. */ +export const ANY_FLAG_SQL = FLAG_TYPES.map((t) => `(${FLAG_SQL[t]})`).join(' OR '); + +/** Map a filter token (a FlagType, or `all`) to a WHERE fragment. Unknown tokens → null (ignored). */ +export function flagPredicate(token: string): string | null { + if (token === 'all') return ANY_FLAG_SQL; + return token in FLAG_SQL ? FLAG_SQL[token as FlagType] : null; +} + +const SECTOR_LABEL = new Map(CPV_SECTORS.map((s) => [s.code, s.short ?? s.label])); + +const FROM = `FROM contracts c + JOIN tenders t ON t.id = c.tender_id + JOIN authorities a ON a.id = t.authority_id`; + +interface TotalRow { + total_eur: number; + total_contracts: number; + [k: string]: number; +} +interface SectorRow { + code: string | null; + eur: number; + contracts: number; +} +interface AuthTypeRow { + type_group: string | null; + eur: number; + contracts: number; +} + +/** + * Homepage flagged-value summary. Money (€) sums only trustworthy figures — `SUM(amount_eur)` skips the + * NULLs the ETL leaves on unrecoverable `value_suspect` rows (canonical basis, #98) — while the CONTRACT + * tally counts every flagged row, so a value-suspect contract is counted but contributes €0. The `byType` + * slices OVERLAP (a contract can be both single-offer and cost-growth), so they sum to more than the + * de-duplicated total; `bySector`/`byAuthorityType` partition the flagged set, so they sum to the total. + */ +export async function getFlaggedValue(db: D1Database): Promise { + const typeCols = FLAG_TYPES.flatMap((t) => [ + `COALESCE(SUM(CASE WHEN (${FLAG_SQL[t]}) THEN c.amount_eur END), 0) AS ${t}_eur`, + `COUNT(CASE WHEN (${FLAG_SQL[t]}) THEN 1 END) AS ${t}_n`, + ]); + + const [totalRow, sectors, authTypes] = await Promise.all([ + db + .prepare( + `SELECT + COALESCE(SUM(CASE WHEN (${ANY_FLAG_SQL}) THEN c.amount_eur END), 0) AS total_eur, + COUNT(CASE WHEN (${ANY_FLAG_SQL}) THEN 1 END) AS total_contracts, + ${typeCols.join(',\n ')} + ${FROM}`, + ) + .first(), + db + .prepare( + `SELECT substr(t.cpv_code, 1, 2) AS code, + COALESCE(SUM(c.amount_eur), 0) AS eur, COUNT(*) AS contracts + ${FROM} + WHERE (${ANY_FLAG_SQL}) AND t.cpv_code IS NOT NULL + GROUP BY code ORDER BY eur DESC LIMIT 6`, + ) + .all(), + db + .prepare( + `SELECT a.type_group AS type_group, + COALESCE(SUM(c.amount_eur), 0) AS eur, COUNT(*) AS contracts + ${FROM} + WHERE (${ANY_FLAG_SQL}) AND a.type_group IS NOT NULL + GROUP BY a.type_group ORDER BY eur DESC`, + ) + .all(), + ]); + + const byType = FLAG_TYPES.map((t) => ({ + type: t, + eur: totalRow?.[`${t}_eur`] ?? 0, + contracts: totalRow?.[`${t}_n`] ?? 0, + })); + + return { + totalEur: totalRow?.total_eur ?? 0, + contracts: totalRow?.total_contracts ?? 0, + byType, + bySector: sectors.results + .filter((r): r is SectorRow & { code: string } => r.code != null) + .map((r) => ({ + code: r.code, + label: SECTOR_LABEL.get(r.code) ?? r.code, + eur: r.eur, + contracts: r.contracts, + })), + byAuthorityType: authTypes.results + .filter((r): r is AuthTypeRow & { type_group: string } => r.type_group != null) + .map((r) => ({ typeGroup: r.type_group, eur: r.eur, contracts: r.contracts })), + }; +} diff --git a/packages/db/src/queries/home.ts b/packages/db/src/queries/home.ts index fe588134..d0562bab 100644 --- a/packages/db/src/queries/home.ts +++ b/packages/db/src/queries/home.ts @@ -6,6 +6,7 @@ import { type CompanyTotalsRow, } from './rows'; import { listSingleOfferContracts } from './contracts'; +import { getFlaggedValue } from './flagged'; interface HomeTotalsRow { contracts: number; @@ -50,37 +51,47 @@ export async function getHomeData(db: D1Database): Promise { }; const placeholders = STATE_TYPES.map(() => '?').join(', '); - const [companies, ministries, municipalities, recentSingleOffer, topSingleOffer, singleOfferRow] = - await Promise.all([ - db - .prepare(`SELECT * FROM company_totals ORDER BY won_eur DESC, bidder_id LIMIT 10`) - .all(), - db - .prepare( - `SELECT * FROM authority_totals WHERE type_group IN (${placeholders}) ORDER BY spent_eur DESC, authority_id LIMIT 6`, - ) - .bind(...STATE_TYPES) - .all(), - db - .prepare( - `SELECT * FROM authority_totals WHERE type_group = 'община' ORDER BY spent_eur DESC, authority_id LIMIT 6`, - ) - .all(), - listSingleOfferContracts(db, 'recent', 10), - listSingleOfferContracts(db, 'value', 10), - // Money portion of single-offer contracts vs the whole corpus (totals.valueEur is the - // denominator). Same clean-row basis as the single-offer list above: bids = 1, non-suspect, - // positive amount. Edge-cached for an hour, so the full scan runs rarely. - db - .prepare( - `SELECT COALESCE(SUM(amount_eur), 0) AS value_eur, COUNT(*) AS contracts + const [ + companies, + ministries, + municipalities, + recentSingleOffer, + topSingleOffer, + singleOfferRow, + flagged, + ] = await Promise.all([ + db + .prepare(`SELECT * FROM company_totals ORDER BY won_eur DESC, bidder_id LIMIT 10`) + .all(), + db + .prepare( + `SELECT * FROM authority_totals WHERE type_group IN (${placeholders}) ORDER BY spent_eur DESC, authority_id LIMIT 6`, + ) + .bind(...STATE_TYPES) + .all(), + db + .prepare( + `SELECT * FROM authority_totals WHERE type_group = 'община' ORDER BY spent_eur DESC, authority_id LIMIT 6`, + ) + .all(), + listSingleOfferContracts(db, 'recent', 10), + listSingleOfferContracts(db, 'value', 10), + // Money portion of single-offer contracts vs the whole corpus (totals.valueEur is the + // denominator). Same clean-row basis as the single-offer list above: bids = 1, non-suspect, + // positive amount. Edge-cached for an hour, so the full scan runs rarely. + db + .prepare( + `SELECT COALESCE(SUM(amount_eur), 0) AS value_eur, COUNT(*) AS contracts FROM contracts WHERE bids_received = 1 AND value_flag = 'ok' AND amount_eur > 0`, - ) - .first<{ value_eur: number; contracts: number }>(), - ]); + ) + .first<{ value_eur: number; contracts: number }>(), + // Flagged-value summary (#218) — live aggregate over existing columns, under the 1h edge cache. + getFlaggedValue(db), + ]); return { totals, + flagged, topCompanies: companies.results.map(toCompanyListItem), topMinistries: ministries.results.map(toAuthorityListItem), topMunicipalities: municipalities.results.map(toAuthorityListItem), diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index 2ed922e7..5ebd027d 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -7,6 +7,7 @@ export * from './keyset'; export * from './sectors'; export * from './rows'; export * from './home'; +export * from './flagged'; export * from './methodology'; export * from './companies'; export * from './authorities'; diff --git a/packages/db/src/queries/keyset.test.ts b/packages/db/src/queries/keyset.test.ts index d82982eb..bd0647f3 100644 --- a/packages/db/src/queries/keyset.test.ts +++ b/packages/db/src/queries/keyset.test.ts @@ -6,8 +6,10 @@ import { decodeCursor, encodeCursor, filterSignature, keyset, pageCursors } from const FILTER_VALUE: Record = { authority: '000695089', + authorityTypes: ['министерство'], bidder: '103267194', bids: 'one', + flags: ['no_competition'], countBucket: '2-5', eu: 'eu', kinds: ['company'], @@ -76,6 +78,8 @@ describe('route filter signatures', () => { 'bidder', 'q', 'bids', + 'flags', + 'authorityTypes', ]); expect([...COMPANY_FILTER_KEYS]).toEqual([ 'kinds', From ee38c8884014fb517c12def69a48fa5f10d107b4 Mon Sep 17 00:00:00 2001 From: DiyanaDimitrova Date: Sun, 12 Jul 2026 10:31:30 +0300 Subject: [PATCH 2/6] refactor(web): address #218 review + reuse single-offer components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict review fixes: - HIGH: methodology listed "рискови сигнали" as not-yet-built while this PR ships them (section 10) — remove from the in-development list, point to section 10. - MEDIUM: the by-sector (top 6) and by-authority-type breakdowns exclude rows with a NULL cpv_code/type_group and cap sector to 6, so they do NOT necessarily sum to the total. Correct the flagged.ts docstring + the methodology copy, and add a NULL-dimension fixture row proving the slices sum to less than the total. - LOW: the total/by-type aggregate references only c.*, so give it FROM contracts c (no joins) — fewer rows read on the hottest scan. UI: render the flagged section with the same components as "Поръчки с една оферта" — a SingleOfferPortion bar (flagged EUR as a share of all contract value) and a SingleOfferTable of the top flagged contracts (via listContracts flag=all) — while keeping the by-type / by-sector / by-authority-type drill-down breakdowns. Refs #218 --- apps/web/app/routes/home.tsx | 14 +++++---- apps/web/app/routes/methodology.tsx | 18 ++++++------ apps/web/app/styles/home.css | 30 ++----------------- packages/api-contract/src/index.ts | 2 ++ packages/db/src/queries/flagged.test.ts | 38 +++++++++++++++---------- packages/db/src/queries/flagged.ts | 5 ++-- packages/db/src/queries/home.ts | 6 +++- 7 files changed, 54 insertions(+), 59 deletions(-) diff --git a/apps/web/app/routes/home.tsx b/apps/web/app/routes/home.tsx index 68be186c..d1d9e980 100644 --- a/apps/web/app/routes/home.tsx +++ b/apps/web/app/routes/home.tsx @@ -91,6 +91,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { const { totals, flagged, + topFlagged, topCompanies, topMinistries, topMunicipalities, @@ -148,12 +149,11 @@ export default function Home({ loaderData }: Route.ComponentProps) { са ориентири за преглед, не присъда. Как ги четем →

-

- - ≈ {moneyBare(flagged.totalEur)} € - в {count(flagged.contracts)} договора със сигнал → - -

+
@@ -212,6 +212,8 @@ export default function Home({ loaderData }: Route.ComponentProps) {
+ +
diff --git a/apps/web/app/routes/methodology.tsx b/apps/web/app/routes/methodology.tsx index a939285a..ce87b05f 100644 --- a/apps/web/app/routes/methodology.tsx +++ b/apps/web/app/routes/methodology.tsx @@ -494,9 +494,10 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {

- Място на изпълнение, собственици и свързани лица и{' '} - рискови сигнали са в процес на разработка за следваща версия — - изискват пълно сливане с допълнителни източници и отделен аналитичен слой. + Място на изпълнение и собственици и свързани лица{' '} + са в процес на разработка за следваща версия — изискват пълно сливане с допълнителни + източници и отделен аналитичен слой. Първите структурни сигнали за риск вече са + налични — виж §10.

@@ -562,11 +563,12 @@ export default function Methodology({ loaderData }: Route.ComponentProps) {

Общата сума е де-дублирана — договор с няколко сигнала се брои веднъж. Разбивката „по вид сигнал" обаче се застъпва (един договор може - да попадне в няколко реда), затова сборът ѝ надхвърля общата сума; разбивките по - сектор и по тип институция разделят множеството и се сумират до общата сума. - Стойностите стъпват на каноничната изчистена стойност в евро (сумираме само - договорите с достоверна стойност — договор с непотвърдена стойност се брои в - бройката, но с 0 €). + да попадне в няколко реда), затова сборът ѝ надхвърля общата сума. Разбивките по + сектор (първите шест) и по тип институция показват къде се концентрира рискът; те + не сумират непременно до общата сума — договори без известен сектор или тип + институция остават извън тях. Стойностите стъпват на каноничната изчистена + стойност в евро (сумираме само договорите с достоверна стойност — договор с + непотвърдена стойност се брои в бройката, но с 0 €).

diff --git a/apps/web/app/styles/home.css b/apps/web/app/styles/home.css index 9e8da6d0..341016a8 100644 --- a/apps/web/app/styles/home.css +++ b/apps/web/app/styles/home.css @@ -184,34 +184,10 @@ white-space: nowrap; } -/* Flagged-value section (#218): a lead accent number + three drill-down breakdown columns. */ -.flagged-lead { - margin: var(--s-5) 0 var(--s-6); -} -.flagged-lead a { - display: inline-flex; - align-items: baseline; - gap: var(--s-3); - text-decoration: none; - color: var(--ink); - flex-wrap: wrap; -} -.flagged-lead a:hover .flagged-sub { - color: var(--accent); -} -.flagged-num { - font: 600 clamp(30px, 4vw, 44px) / 1 var(--font-serif); - color: var(--accent); - font-variant-numeric: tabular-nums; - letter-spacing: -0.01em; -} -.flagged-sub { - font-family: var(--font-mono); - font-size: 12px; - letter-spacing: 0.04em; - color: var(--ink-soft); -} +/* Flagged-value section (#218): a SingleOfferPortion bar + three drill-down breakdown columns + a + top-flagged contracts table (SingleOfferTable). Only the breakdown columns need custom styling. */ .flagged-cols { + margin-block: var(--s-5); display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--s-5) var(--s-7); diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts index 31d80712..2c82f5b4 100644 --- a/packages/api-contract/src/index.ts +++ b/packages/api-contract/src/index.ts @@ -67,6 +67,8 @@ export interface HomeData { totals: HomeTotals; /** Total € running through contracts with a risk signal, + breakdowns (issue #218). */ flagged: FlaggedValue; + /** Top flagged contracts by value — for the homepage table (issue #218). */ + topFlagged: ContractListItem[]; topCompanies: CompanyListItem[]; topMinistries: AuthorityListItem[]; topMunicipalities: AuthorityListItem[]; diff --git a/packages/db/src/queries/flagged.test.ts b/packages/db/src/queries/flagged.test.ts index f451aa2e..9472a475 100644 --- a/packages/db/src/queries/flagged.test.ts +++ b/packages/db/src/queries/flagged.test.ts @@ -22,12 +22,14 @@ const migrations = readdirSync(migrationsDir) const FIXTURE = ` INSERT INTO authorities (id, name, bulstat, type_group) VALUES ('auth:obshtina', 'Община', '100000001', 'община'), - ('auth:min', 'Министерство', '100000002', 'министерство'); + ('auth:min', 'Министерство', '100000002', 'министерство'), + ('auth:none', 'Без тип', '100000003', NULL); INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, kind) VALUES ('eik:200000001', 'Фирма', '200000001', '200000001', 1, 'company'); INSERT INTO tenders (id, source_id, title, authority_id, cpv_code, procedure_type, status) VALUES ('t:o45', 'UNP-1', 'Строеж', 'auth:obshtina', '45000000', 'открита процедура', 'awarded'), - ('t:m72', 'UNP-2', 'ИТ', 'auth:min', '72000000', 'открита процедура', 'awarded'); + ('t:m72', 'UNP-2', 'ИТ', 'auth:min', '72000000', 'открита процедура', 'awarded'), + ('t:nocpv', 'UNP-3', 'Без CPV', 'auth:none', NULL, 'открита процедура', 'awarded'); INSERT INTO contracts (id, tender_id, bidder_id, amount, currency, signed_at, bids_received, bids_rejected, eu_funded, value_flag, date_flag, signing_value_eur, current_value_eur, amount_eur) VALUES @@ -36,7 +38,10 @@ INSERT INTO contracts ('c3', 't:o45', 'eik:200000001', 1500, 'EUR', '2024-01-03', 3, 0, 0, 'ok', 'ok', 1000, 1500, 1500), ('c4', 't:m72', 'eik:200000001', 9000, 'EUR', '2024-01-04', 3, 0, 0, 'value_suspect', 'ok', NULL, NULL, NULL), ('c5', 't:o45', 'eik:200000001', 2000, 'EUR', '2024-01-05', 1, 0, 0, 'ok', 'ok', 1000, 2000, 2000), - ('c6', 't:m72', 'eik:200000001', 5000, 'EUR', '2024-01-06', 3, 0, 0, 'ok', 'ok', 5000, 5000, 5000); + ('c6', 't:m72', 'eik:200000001', 5000, 'EUR', '2024-01-06', 3, 0, 0, 'ok', 'ok', 5000, 5000, 5000), + -- c7: flagged (no_competition) but on a tender with NULL cpv + authority with NULL type_group, + -- so it lands in the total/count but in NEITHER the bySector nor byAuthorityType breakdown. + ('c7', 't:nocpv', 'eik:200000001', 500, 'EUR', '2024-01-07', 1, 0, 0, 'ok', 'ok', 500, 500, 500); `; function d1(db: DatabaseSync): D1Database { @@ -79,45 +84,48 @@ const byType = (f: Awaited>, t: string) => describe('getFlaggedValue', () => { it('de-duplicates the total (a contract with two signals counts once)', async () => { const f = await getFlaggedValue(realDb()); - // c1..c5 flagged (c6 clean). c4 is value_suspect → NULL amount_eur → counted, €0. - expect(f.contracts).toBe(5); - expect(f.totalEur).toBe(6500); // 1000 + 2000 + 1500 + 0 + 2000 + // c1..c5 + c7 flagged (c6 clean). c4 is value_suspect → NULL amount_eur → counted, €0. + expect(f.contracts).toBe(6); + expect(f.totalEur).toBe(7000); // 1000 + 2000 + 1500 + 0 + 2000 + 500 }); it('reports overlapping by-type slices (their sum exceeds the de-duplicated total)', async () => { const f = await getFlaggedValue(realDb()); - expect(byType(f, 'no_competition')).toMatchObject({ eur: 3000, contracts: 2 }); // c1 + c5 + expect(byType(f, 'no_competition')).toMatchObject({ eur: 3500, contracts: 3 }); // c1 + c5 + c7 expect(byType(f, 'eu_no_competition')).toMatchObject({ eur: 2000, contracts: 1 }); // c2 expect(byType(f, 'high_markup')).toMatchObject({ eur: 3500, contracts: 2 }); // c3 + c5 expect(byType(f, 'anomalies')).toMatchObject({ eur: 0, contracts: 1 }); // c4 (NULL amount) const sum = f.byType.reduce((n, r) => n + r.eur, 0); - expect(sum).toBe(8500); + expect(sum).toBe(9000); expect(sum).toBeGreaterThan(f.totalEur); // c5 double-counted across two types }); - it('partitions by sector so the slices sum to the total', async () => { + it('breaks down by sector as top slices that need NOT sum to the total', async () => { const f = await getFlaggedValue(realDb()); const s45 = f.bySector.find((s) => s.code === '45')!; const s72 = f.bySector.find((s) => s.code === '72')!; expect(s45).toMatchObject({ eur: 4500, contracts: 3 }); // c1 + c3 + c5 expect(s72).toMatchObject({ eur: 2000, contracts: 2 }); // c2 + c4 - expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBe(f.totalEur); + // c7 has a NULL cpv_code → excluded from every sector slice, so the slices sum to LESS than total. + expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBe(6500); + expect(f.bySector.reduce((n, s) => n + s.eur, 0)).toBeLessThan(f.totalEur); }); - it('partitions by authority type so the slices sum to the total', async () => { + it('breaks down by authority type as slices that need NOT sum to the total', async () => { const f = await getFlaggedValue(realDb()); const obshtina = f.byAuthorityType.find((a) => a.typeGroup === 'община')!; const min = f.byAuthorityType.find((a) => a.typeGroup === 'министерство')!; expect(obshtina).toMatchObject({ eur: 4500, contracts: 3 }); expect(min).toMatchObject({ eur: 2000, contracts: 2 }); - expect(f.byAuthorityType.reduce((n, a) => n + a.eur, 0)).toBe(f.totalEur); + // c7's authority has a NULL type_group → excluded, so the slices sum to LESS than total. + expect(f.byAuthorityType.reduce((n, a) => n + a.eur, 0)).toBeLessThan(f.totalEur); }); }); describe('/contracts flag filter', () => { it('flag=no_competition narrows to the single-offer rows (incl. the overlapping one)', async () => { const r = await listContracts(realDb(), { flags: ['no_competition'], pageSize: 10 }); - expect(r.total).toBe(2); // c1, c5 + expect(r.total).toBe(3); // c1, c5, c7 }); it('flag=high_markup narrows to the cost-growth rows', async () => { @@ -127,7 +135,7 @@ describe('/contracts flag filter', () => { it('flag=all matches every flagged contract', async () => { const r = await listContracts(realDb(), { flags: ['all'], pageSize: 10 }); - expect(r.total).toBe(5); // c1..c5, not the clean c6 + expect(r.total).toBe(6); // c1..c5, c7 — not the clean c6 }); it('multiple flag tokens are OR-combined (union)', async () => { @@ -135,7 +143,7 @@ describe('/contracts flag filter', () => { flags: ['no_competition', 'anomalies'], pageSize: 10, }); - expect(r.total).toBe(3); // c1, c5 (no_competition) ∪ c4 (anomalies) + expect(r.total).toBe(4); // c1, c5, c7 (no_competition) ∪ c4 (anomalies) }); it('an unrecognised flag token matches nothing (not everything)', async () => { diff --git a/packages/db/src/queries/flagged.ts b/packages/db/src/queries/flagged.ts index 6d5d3398..df2c0283 100644 --- a/packages/db/src/queries/flagged.ts +++ b/packages/db/src/queries/flagged.ts @@ -73,7 +73,8 @@ interface AuthTypeRow { * NULLs the ETL leaves on unrecoverable `value_suspect` rows (canonical basis, #98) — while the CONTRACT * tally counts every flagged row, so a value-suspect contract is counted but contributes €0. The `byType` * slices OVERLAP (a contract can be both single-offer and cost-growth), so they sum to more than the - * de-duplicated total; `bySector`/`byAuthorityType` partition the flagged set, so they sum to the total. + * de-duplicated total. `bySector` (top 6) and `byAuthorityType` are TOP slices that need not sum to the + * total: flagged rows with a NULL `cpv_code`/`type_group` are excluded, and `bySector` is capped at 6. */ export async function getFlaggedValue(db: D1Database): Promise { const typeCols = FLAG_TYPES.flatMap((t) => [ @@ -88,7 +89,7 @@ export async function getFlaggedValue(db: D1Database): Promise { COALESCE(SUM(CASE WHEN (${ANY_FLAG_SQL}) THEN c.amount_eur END), 0) AS total_eur, COUNT(CASE WHEN (${ANY_FLAG_SQL}) THEN 1 END) AS total_contracts, ${typeCols.join(',\n ')} - ${FROM}`, + FROM contracts c`, ) .first(), db diff --git a/packages/db/src/queries/home.ts b/packages/db/src/queries/home.ts index d0562bab..d3b495a8 100644 --- a/packages/db/src/queries/home.ts +++ b/packages/db/src/queries/home.ts @@ -5,7 +5,7 @@ import { type AuthorityTotalsRow, type CompanyTotalsRow, } from './rows'; -import { listSingleOfferContracts } from './contracts'; +import { listContracts, listSingleOfferContracts } from './contracts'; import { getFlaggedValue } from './flagged'; interface HomeTotalsRow { @@ -59,6 +59,7 @@ export async function getHomeData(db: D1Database): Promise { topSingleOffer, singleOfferRow, flagged, + topFlaggedPage, ] = await Promise.all([ db .prepare(`SELECT * FROM company_totals ORDER BY won_eur DESC, bidder_id LIMIT 10`) @@ -87,11 +88,14 @@ export async function getHomeData(db: D1Database): Promise { .first<{ value_eur: number; contracts: number }>(), // Flagged-value summary (#218) — live aggregate over existing columns, under the 1h edge cache. getFlaggedValue(db), + // Top flagged contracts by value, for the homepage table (same shape as the single-offer list). + listContracts(db, { flags: ['all'], sort: 'value-desc', pageSize: 10 }), ]); return { totals, flagged, + topFlagged: topFlaggedPage.items, topCompanies: companies.results.map(toCompanyListItem), topMinistries: ministries.results.map(toAuthorityListItem), topMunicipalities: municipalities.results.map(toAuthorityListItem), From 6b69786b3982dacb6f1873c570e8b82cd5d48177 Mon Sep 17 00:00:00 2001 From: DiyanaDimitrova Date: Mon, 13 Jul 2026 16:24:53 +0300 Subject: [PATCH 3/6] fix(web): address government security/GDPR/a11y review of #218 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data protection (blocking): - Exclude natural-person (sole-trader ЕТ) bidders from the homepage „Договори със сигнали за риск" table: over-fetch and drop names via isNaturalPersonProfileName so an identifiable individual is never shown under a risk label on the indexed, edge-cached homepage (mirrors the existing noindex on sole-trader profiles). - Add robots noindex to /contracts when a ?flag= filter is active, so risk-filtered, name-bearing lists stay out of search indexes. Transparency / accuracy (blocking): - methodology.tsx §1 no longer claims the site „не маркира фирми като рискови" (contradicted §10); reworded to: marks contracts with structural signals, not entities as offenders. - privacy.tsx: add a „Производни показатели (сигнали за риск)" section disclosing the derived risk-signal processing, its lawful basis, and the rectification (Art. 16) / objection (Art. 21) rights (GDPR Art. 13/14). Security: - Validate ?type= on /contracts against the closed authority type_group set (new AUTHORITY_TYPE_GROUPS). /contracts is not rate-limited, so an unvalidated ?type= let each distinct value mint a fresh edge-cache key and an uncached full-table scan (cache-cardinality / DoS). Adds a regression test. Accessibility (WCAG 2.1 AA): - methodology §10 Callout uses the title prop (renders

) instead of a raw

, restoring the heading outline. - Parametrize SingleOfferTable's caption so the flagged table has a correct, distinct accessible name instead of the single-offer caption. - Add a „Смятате сигнал за грешен?" rectification link to the homepage flagged section. --- apps/web/app/lib/filters.test.ts | 7 +++++++ apps/web/app/lib/filters.ts | 10 +++++++++- apps/web/app/routes/contracts.tsx | 11 +++++++++-- apps/web/app/routes/home.tsx | 20 +++++++++++++++++--- apps/web/app/routes/methodology.tsx | 9 +++++---- apps/web/app/routes/privacy.tsx | 18 ++++++++++++++++++ packages/db/src/queries/home.ts | 10 ++++++++-- packages/db/src/queries/rows.ts | 5 +++++ 8 files changed, 78 insertions(+), 12 deletions(-) diff --git a/apps/web/app/lib/filters.test.ts b/apps/web/app/lib/filters.test.ts index 1aa4dbfb..660900ac 100644 --- a/apps/web/app/lib/filters.test.ts +++ b/apps/web/app/lib/filters.test.ts @@ -33,6 +33,13 @@ describe('contract risk-signal + authority-type filters (#218)', () => { expect(f.authorityTypes).toEqual(['министерство', 'община']); }); + it('drops unknown ?type buckets (cache-cardinality / DoS allow-list guard, #218 review)', () => { + const f = contractListFilters( + new URLSearchParams('type=министерство&type=