feat(etl): контролен код по Булстат за идентичност по ЕИК - #252
Conversation
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).
67ed07d to
fc1878f
Compare
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
left a comment
There was a problem hiding this comment.
Прегледах стриктно на връх fc1878f, с емпирична проверка на checksum-а в реален SQLite (изпълних самата CASE-логика от PR-а срещу известни ЕИК).
Checksum-ът е коректен — доказано:
- 9-цифрен:
131268894(МЕДЕКС ООД) → валиден; typo-twin131268895→ отхвърлен (точно #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
left a comment
There was a problem hiding this comment.
Прегледах наново двата нови комита след одобрението ми на 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-а оттук.)
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
left a comment
There was a problem hiding this comment.
Одобрено - Bulstat checksum + unknown bucket + top-suppliers guard; резолюцията с #251 е валидираната (bidder_canonical_name върху contractor_identity), пълна сюита 275/275. CI зелен.
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
left a comment
There was a problem hiding this comment.
Одобрено - Bulstat checksum + unknown bucket + top-suppliers guard; валидираната резолюция с #251 (bidder_canonical_name върху contractor_identity), сюита 275/275, CI зелен.
midt-admin
left a comment
There was a problem hiding this comment.
Одобрено - Bulstat checksum + unknown bucket + top-suppliers guard; валидираната резолюция с #251, сюита 275/275, CI зелен.
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.
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.
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.
Част от разделянето на #203 на фокусирани PR-и по проблем. Този PR въвежда истинска проверка на контролния код по Булстат за идентичността по ЕИК.
Проблемът
eik_validпроверяваше само „9/13 цифри, всичко числа". Служебни, placeholder и сгрешени ЕИК-та (напр.000000001, или ЕИК с една сбъркана цифра) минаваха за валидни и сливаха несвързани фирми под един фалшив ключeik:<боклук>- на сайта се появяваше една „супер-фирма" с чужди договори и сборни пари. Това само по себе си е загуба на информация: две реални фирми, конфлатирани в една.Решението
eik_validсмята истинската контролна сума по Булстат:000000000/0000000000000(минаваха суровия checksum).Ключът на изпълнителя:
Блокът се повтаря на няколко места в двата ETL пътя; consistency тест заковава, че всичките копия са текстово идентични. Огледано в
refresh-slice.sql(parity тест).Как работи след фикса
Чисто подобрение на коректността - не изхвърля нищо. Остатъчно ограничение: name-ключираните може да се фрагментират при различни изписвания - това е отделен проблем (име по мода в PR за #194; reconciliation отделно).
Кредит: диагнозата и checksum SQL-ът са от #203 (@StanislavBG).