Skip to content

feat(etl): контролен код по Булстат за идентичност по ЕИК - #252

Merged
todorkolev merged 13 commits into
mainfrom
feat/eik-checksum-bulstat
Jul 23, 2026
Merged

feat(etl): контролен код по Булстат за идентичност по ЕИК#252
todorkolev merged 13 commits into
mainfrom
feat/eik-checksum-bulstat

Conversation

@todorkolev

@todorkolev todorkolev commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Част от разделянето на #203 на фокусирани PR-и по проблем. Този PR въвежда истинска проверка на контролния код по Булстат за идентичността по ЕИК.

Проблемът

eik_valid проверяваше само „9/13 цифри, всичко числа". Служебни, placeholder и сгрешени ЕИК-та (напр. 000000001, или ЕИК с една сбъркана цифра) минаваха за валидни и сливаха несвързани фирми под един фалшив ключ eik:<боклук> - на сайта се появяваше една „супер-фирма" с чужди договори и сборни пари. Това само по себе си е загуба на информация: две реални фирми, конфлатирани в една.

Решението

eik_valid смята истинската контролна сума по Булстат:

  • 9 цифри: тежести 1-8, сума mod 11; ако остатъкът е 10 → претегляне 3-10; второ 10 → контролна цифра 0.
  • 13 цифри: водещите 9 валидни + цифри 9-12 с тежести 2,7,3,5 (fallback 4,9,5,7).
  • изключени 000000000 / 0000000000000 (минаваха суровия checksum).

Ключът на изпълнителя:

eik_valid = 1  → 'eik:'||ЕИК                     (валиден → споделен ключ; реалните дублирания се сливат коректно)
иначе, има име → 'name:'||НОРМАЛИЗИРАНО_ИМЕ       (невалиден → разделя ги по име)

Блокът се повтаря на няколко места в двата ETL пътя; consistency тест заковава, че всичките копия са текстово идентични. Огледано в refresh-slice.sql (parity тест).

Как работи след фикса

  • Фирма с валиден ЕИК → една страница със само нейните договори.
  • Две различни фирми под невалиден ЕИК → две отделни страници; фалшивата „супер-фирма" изчезва.
  • Записите без потвърден ЕИК се маркират „непотвърден ЕИК" (без регистров линк).

Чисто подобрение на коректността - не изхвърля нищо. Остатъчно ограничение: name-ключираните може да се фрагментират при различни изписвания - това е отделен проблем (име по мода в PR за #194; reconciliation отделно).

Кредит: диагнозата и checksum SQL-ът са от #203 (@StanislavBG).

@todorkolev todorkolev changed the title feat(etl): контролен код по Булстат за идентичност по ЕИК [DRAFT] feat(etl): контролен код по Булстат за идентичност по ЕИК Jul 17, 2026
eik_valid now computes the real Bulstat control digit (9-digit weights 1..8 mod 11 with the remainder-10 reweight, 13-digit second pass), and rejects the degenerate all-zero code. A checksum-invalid ЕИК routes the bidder to a name: key instead of eik:, so falsely-merged distinct companies split apart; a valid ЕИК stays merged correctly. The checksum block is repeated across both ETL paths and pinned identical by a consistency test; fixtures updated to checksum-valid ЕИК-та. Extracts the checksum fix from #203 (@StanislavBG).
@midt-admin
midt-admin force-pushed the feat/eik-checksum-bulstat branch from 67ed07d to fc1878f Compare July 17, 2026 02:42
Authority name, bidder name, and authority type now use the most-frequent value per ЕИК with a deterministic tiebreak (mixed-case over ALL-CAPS via a Cyrillic-aware GLOB, then longer, then lexical), mirrored in normalize-raw.sql and refresh-slice.sql. Keys stay ЕИК-based so URLs are unaffected. Extracts the canonical-authority-name fix from #203 (@StanislavBG) and adds the two asymmetrically-missed siblings: bidder name (was MIN(contractor_name)) and authority type (was MAX(authority_type), which also mis-drove the state-company bucket).

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах стриктно на връх fc1878f, с емпирична проверка на checksum-а в реален SQLite (изпълних самата CASE-логика от PR-а срещу известни ЕИК).

Checksum-ът е коректен — доказано:

  • 9-цифрен: 131268894 (МЕДЕКС ООД) → валиден; typo-twin 131268895 → отхвърлен (точно #195 режимът — сгрешена цифра вече не слива несвързани фирми); 000000001/service, 000000000, 8-цифрен, non-digit → отхвърлени. Клонът r==10 → второ претегляне (3..10, второ 10→0) също: 240000000 → валиден.
  • 13-цифрен: валиден 1312688940008 → 1; сгрешена 13-та цифра и сгрешен 9-префикс → 0. Разширението (позиции 9..12 по 2,7,3,5 / 4,9,5,7) работи.

Паритет: 7-те checksum блока (3 в normalize-raw.sql + 4 в refresh-slice.sql) са текстово идентични — проверих независимо и eik-checksum-consistency.test.ts го заковава (fail при дрейф). Пълният rebuild и инкременталният път не могат да разминат bidder-идентичността.

Посоката е безопасна: реален регистриран ЕИК винаги минава checksum-а, тъй че само невалидните (service/typo) стават eik_valid = 0 и падат на name-key — разцепват събраните по грешка несвързани доставчици (#195), без риск да разцепят легитимна фирма.

Дребно (не блокира): consistency-тестът пази паритета, но не заковава самия алгоритъм срещу стойности — добави 2–3 known-valid/invalid ЕИК случая (напр. 131268894→1, 131268895→0), за да хване и бъдеща логическа (не текстова) промяна.

Одобрявам.

…cal authority

Under #194 the authority display name is canonicalized to the frequency
mode per ЕИК, which collapses genuine sub-units that share one ЕИК (e.g. a
school placed under МОН's ЕИК keeps the ministry name). Additionally keep
the raw per-row "Възложител" verbatim so no ordering unit is lost.

- add contracts.ordering_unit_name / tenders.ordering_unit_name, filled
  from the raw authority_name in normalize-raw and both refresh-slice paths
- getContract surfaces it on the contract page only when it differs from
  the canonical name after a Cyrillic-safe fold (case + whitespace), so
  spelling variants stay hidden and real sub-units show
- /contracts/:id.json sourceNames.authority is now genuinely verbatim; it
  previously served the canonical name despite the "verbatim" contract

Extends #251; credit StanislavBG's #203 identity work.
…bucket

The bidder key fell to NULL when a contractor had an invalid-checksum ЕИК
AND no name; `WHERE bidder_key IS NOT NULL` then silently dropped the whole
contract. Never drop a contract for identity reasons — downgrade the key.

- add a third key rung: invalid ЕИК + empty/NULL name -> one labelled
  `unknown:анонимен` bucket ("Неизвестен изпълнител", kind=unknown), so every
  previously-dropped contract stays in the corpus
- compute the checksum + the three-rung key ONCE in a scratch
  `contractor_identity` table (NULL-safe raw-pair join), replacing the ~7
  copy-pasted checksum blocks; delete the consistency test that pinned them
- keep the bucket inside company_totals so money still reconciles; hide it
  only from browsable rankings (search_index, /companies, home top suppliers)
- resolve the bucket's /companies slug so the contract-page link works
- mirror all of it in refresh-slice; the checksum algorithm is byte-identical

A new SQL test asserts no eligible contract is dropped and SUM(amount_eur)
is preserved across both the normalize and refresh paths.
Name-keyed bidders (no valid ЕИК) split the same company across surface
variants because SQLite UPPER() is ASCII-only and quote/dash encodings vary.
Normalise the name in the `contractor_identity` scratch table so Cyrillic
case, quote styles and dash encodings collapse to one key — while dash vs
space stays distinct, so genuinely different names are not over-merged.

- staged `name_norm` UPDATEs (whitespace + quotes + dashes + Cyrillic case),
  each a shallow expression: a single ~60-deep nested REPLACE overflows the
  sqlite3 CLI parser (v3.40.1) on the import path, so the fold is split into
  stages of <=15 REPLACE calls
- the fold is byte-identical in normalize-raw and refresh-slice
- tests assert quote/case/space variants merge to one key, en-dash == hyphen,
  and hyphen != space (over-fold guard); SUM(amount_eur) still reconciles

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах наново двата нови комита след одобрението ми на checksum-а (fc1878f): unknown-bucket-а (897a421) и name-fold-а (efb1091). Тестът contractor-identity-sql.test.ts е силен — describe.each(['normalize','refresh']) заковава, че двата пътя дават идентични bidder_id, а seed-ът покрива точно правилните ръбове: кавички/регистър/интервали се сливат (СТРОЙ ИНВЕСТ ЕООД), en-dash↔ASCII тире се слива (МАРИЦА-ИЗТОК), но Марица Изток (интервал) остава отделно от Марица-Изток (тире) — over-merge guard-ът работи. Rollup-ите се запазват (raw=contract=company=1070), а unknown:анонимен е изключен от search_index (n=0). Добра работа — bucket-ът връща в националните суми пари, които преди се губеха с NULL bidder_key.

Один пропуск (трябва да се затвори преди merge): изключването на unknown е приложено на всяка entity-повърхност освен една. Guard-ът kind <> 'unknown' е сложен в home.ts:57 (топ-10), companies.ts:112 (list + count + facets) и чрез search_index, но липсва в getAuthority → топ-изпълнители на страницата на възложител:

-- packages/db/src/queries/details.ts:249-252
SELECT c.bidder_id, b.name, b.kind, SUM(c.amount_eur) AS won, COUNT(*) AS n
FROM contracts c JOIN tenders t ON t.id = c.tender_id JOIN bidders b ON b.id = c.bidder_id
WHERE t.authority_id = ? AND c.amount_eur IS NOT NULL
GROUP BY c.bidder_id ORDER BY won DESC LIMIT 7

unknown:анонимен е реален ред в bidders (kind='unknown') с ненулев amount_eur, тъй че минава IS NOT NULL и се групира тук. За възложител с анонимен разход „Неизвестен изпълнител" ще се класира в публичния топ-7 — при твоя собствен seed дори №1 (300+400=700 > всяко именувано). Това представя синтетичен агрегат от несвързани договори като един топ-контрагент — точно това, което останалите повърхности умишлено крият. Фиксът е един ред, консистентен с другите три места: AND b.kind <> 'unknown' в WHERE.

Дребно, свързано: типът kind: 'company' | 'consortium' на реда (details.ts:258) вече е неточен — 'unknown' може да дойде оттук докато guard-ът липсва; след фикса пак ще е верен.

Иначе логиката е чиста и добре тествана. С този guard одобрявам.

(За протокол: серията #251/#252/#253 пипа общо normalize-raw.sql/refresh-slice.sql → трябва дефиниран merge ред + rebase; на merge canonical-name стъпката на #251 да поеме checksum bidder-key-а оттук.)

todorkolev and others added 6 commits July 22, 2026 14:55
getAuthority's top-suppliers query lacked the AND b.kind <> 'unknown' guard the
other three surfaces (home, companies, search_index) have, so the synthetic
'Неизвестен изпълнител' bucket could rank in an authority's public top-7. Add the
same guard.
A raw authority_eik can carry a joint-procurement composite ('EIK1; EIK2'). The
rewritten canonical name/kind sources and the main authority insert consumed it
verbatim, minting orphan 'auth:EIK1; EIK2' authorities referenced by no tender,
contract, rollup or search row - a full 2020-2026 rebuild measured 404 of them.
Guard every minting source with NOT LIKE '%;%'; joint tenders are attributed
through their individual members.
Resolution: bidder_canonical_name is rebuilt ON TOP of contractor_identity
(mode re-derived from raw_contracts, keyed by the checksum bidder_key) in both
normalize-raw and refresh-slice - the validated integration resolution. The
canonicalization test's bidder EIK moves to a checksum-valid one (400000004)
now that the mode fixture must survive Bulstat validation.
midt-admin
midt-admin previously approved these changes Jul 23, 2026

@midt-admin midt-admin left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Одобрено - Bulstat checksum + unknown bucket + top-suppliers guard; резолюцията с #251 е валидираната (bidder_canonical_name върху contractor_identity), пълна сюита 275/275. CI зелен.

@todorkolev
todorkolev marked this pull request as ready for review July 23, 2026 19:58
The .gitignore pattern node_modules/ matches directories only; a dev-environment
symlink slipped past it into the merge commit and broke CI's pnpm install
(ENOTDIR). Remove from tracking.
midt-admin
midt-admin previously approved these changes Jul 23, 2026

@midt-admin midt-admin left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Одобрено - Bulstat checksum + unknown bucket + top-suppliers guard; валидираната резолюция с #251 (bidder_canonical_name върху contractor_identity), сюита 275/275, CI зелен.

@midt-admin midt-admin left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Одобрено - Bulstat checksum + unknown bucket + top-suppliers guard; валидираната резолюция с #251, сюита 275/275, CI зелен.

@todorkolev
todorkolev merged commit f7a3e50 into main Jul 23, 2026
1 check passed
todorkolev added a commit that referenced this pull request Jul 23, 2026
Joint attribution rebuilt on the validated integration resolution: the lead/
member derivation коexists with contractor_identity keying and the canonical
name mode; composite-EIK guards preserved at every minting site. Also drops
node_modules symlinks inherited via the merge.
todorkolev added a commit that referenced this pull request Jul 23, 2026
Trio content taken from the validated per-stage resolutions; the #261 fold
(amount_eur through the amendment currency, trusted_currency companion,
current-amount-parity gating) re-applied as the exact integration diff. Comment-
only conflicts resolved to the fuller phrasing.
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 3, 2026
midt-bg#216 (coverage harness) merged to main, so the vendored harness reconciles to
the canonical one. Also folds in the feature work that landed since the branch
base — midt-bg#263 (worker-native FX load, rewritten refresh Workflow), midt-bg#252 (Bulstat
EIK control code), midt-bg#210 (similar-contracts cohort) — and re-validates the whole
suite against the moved source.

Conflict resolutions:
- apps/etl/vitest.config.ts: take upstream (needs the SQL text-module plugin +
  cloudflare:workers alias for midt-bg#263's real-Workflow index.ts).
- vitest.shared.ts: keep our fixtures/json/md/d.ts excludes; add **/src/test/**
  (SQLite/workers stubs are test scaffolding, not product code).
- coverage-baseline.json: keep our floors (baseline reconciliation to the merged
  tree's actuals follows in a separate commit).
- apps/etl/src/index.test.ts: take upstream's FX integration test; restore the
  orchestration coverage it does not cover in a new mock-based control-flow test.
- packages/db/src/queries/home.test.ts: union both added fakeDb params
  (singleOffer + capture).

Post-merge fixes for source drift:
- search.suggest.test.tsx: stub getDb (the route now wraps env in getDb()).
- index.control-flow.test.ts (new): capped-window, zero-ingest, FX-uncovered,
  integrity-gate logger + failure (Error and non-Error), scheduled — restoring
  the run()/scheduled() branch coverage displaced by taking upstream's test.

Full suite green: config 27, shared 56, ingest 116, etl 40, db 458, web 469.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants