fix(etl): authority/executor identity — canonical name, EIK checksum, multi-ЕИК guard (#194 #195 #196) - #203
Conversation
Authorities dedupe on ЕИК, but the display label was chosen with MIN(authority_name) — the alphabetically-earliest string. For a shared ЕИК (e.g. 000695114, where a school and its parent ministry file under one legal entity) MIN() let the rare school variant „БСУ Д-р Петър Берон" beat the dominant „МИНИСТЕРСТВО НА ОБРАЗОВАНИЕТО И НАУКАТА" (620 rows). Contracts were already linked correctly; only the label was wrong. Pick the MODE (most-frequent label) per ЕИК instead, tie-broken toward a mixed-case label over ALL-CAPS (detected via a GLOB lowercase range, since SQLite UPPER()/LOWER() do not fold Cyrillic), then longer, then lexically-first for determinism. The ЕИК key is unchanged, so slugs and URLs (which key on ЕИК) are unaffected — only the label changes.
The bidder identity key trusted a purely SYNTACTIC ЕИК check (digits-only, length 9 or 13). The fake/service code „000000001" and wrong-digit typo twins passed it, so unrelated foreign suppliers (Elsevier + Clarivate/Web of Science + a gas consultancy + a 102 EUR construction line) collapsed onto one node labelled „Elsevier B. V." (14 contracts, 45.7M EUR), and real publishers grew a wrong-checksum twin (131106522 „Просвета" vs typo 131106552). Add the Bulgarian ЕИК/Булстат control-digit algorithm to eik_valid: - 9-digit: weight positions 1..8 by 1..8 (fallback 3..10; second 10 → 0) - 13-digit: valid leading 9-digit ЕИК, then positions 9..12 by 2,7,3,5 (fallback 4,9,5,7; second 10 → 0) Invalid codes now get eik_valid = 0 and fall back to the name-based key, which correctly splits the merged suppliers. Verified against the live corpus: 64 of 13134 previously-valid EIK nodes fail the checksum (000000001 and the Просвета twin among them); real EIKs (131106522, 2016195800141, 204799888, …) still pass. Alias-merge (proposal 2 in midt-bg#195) is intentionally left as follow-up.
… into refresh-slice (parity)
|
Consolidated the identity fixes into this one PR and mirrored them into both ETL paths so the parity test passes:
Applied to |
The bidders INSERT and touched-bidder detection already keyed on the Bulgarian ЕИК/Булстат checksum (eik_valid, midt-bg#195), but the four contract-derivation bidder_key computations in normalize-raw.sql and refresh-slice.sql still used the old syntactic check (eik_clean NOT GLOB '*[^0-9]*' AND LENGTH IN (9,13)). That produced contracts.bidder_id = 'eik:<code>' for codes that fail the checksum, while bidders.id fell back to the name key — so the EXISTS (bidders WHERE id = bidder_key) guard dropped those contracts and the full/slice parity diverged. Replace all four sites (normalize-raw + refresh-slice, 2 each) with the identical checksum-based key: nest the same eik_valid CASE and emit 'eik:' || eik_clean only when eik_valid = 1, else the name key. Both scripts now share byte-identical contract bidder_key logic, so parity holds by construction. Update the refresh-slice test fixtures that relied on the old syntactic validity (111111111 / 222222222 are not valid ЕИК checksums) to real checksum-valid codes (111111113 / 222222226), preserving each test's two-distinct-entity intent.
|
@todorkolev готов за ревю 🙏 — CI зелен, mergeable. Този PR консолидира трите data-quality поправки по гражданския сигнал (#194 каноничното име, #195 Булстат контролна сума, #196 многоЕИК гард); #205 и #207 са слети тук и затворени. Поправено в двата ETL пътя (normalize-raw + refresh-slice), parity минава: refresh-slice 12/12, целият @sigma/db 183/183. |
Ревю на PR #203 —
|
Ревю на PR #203 —
|
|
Ревю на PR #203 — Благодаря за консолидираната и много прегледно документирана работа. Прегледах диффа ред по ред на глава Съответствие с тикетите
Сигурност / OWASP
Паритет: Checksum CASE блокът е байт-идентичен на всичките 7 места (3 в normalize-raw + 4 в refresh-slice), а модата и гардът са огледални в двата пътя → паритетът е по конструкция и е защитен от Незадължителни бележки (не блокират):
Verdict: Approve на същество — одобрявам по същество; двете бележки са незадължителни follow-up, не блокери. |
Ревю на PR #203 —
|
|
Всичко проверено локално. Извеждам финалния коментар. Ревю на PR #203 —
|
ydimitrof
left a comment
There was a problem hiding this comment.
Преглед на PR: fix(etl): authority/executor identity (#194 #195 #196)
Резюме на промяната. PR-ът решава три отделни проблема в ETL идентичността, и трите добре мотивирани и документирани в SQL коментарите:
- #194 — каноничното име на възложителя вече е MODE (най-честият етикет) на ЕИК вместо MIN() (азбучно първи), с детерминистични tiebreak-ове (mixed-case > ALL-CAPS, после по-дълго, после лексикографски).
- #195 — въведена е реална проверка на контролната цифра на българския ЕИК/Булстат (9- и 13-цифрен), която замества чисто синтактичната „9/13 цифри" проверка и разделя погрешно слети доставчици.
- #196 — guard за multi-ЕИК списъци (
NOT LIKE '%;%') при съвместни поръчки, който изключва фантомните авторитети.
Проверки по дименсии:
- Сигурност (Phase 0 + agent-level): CLEAN. Няма секрети, URL промени, зависимости или инжекция. SQL-ът е статичен (без интерполиран вход). ✅
- Коректност: Алгоритъмът за контролна цифра е коректен (9-цифрен: тегла 1..8, при остатък 10 → тегла 3..10, при втори 10 → 0; 13-цифрен изисква валиден водещ 9-цифрен + тегла 2,7,3,5 / fallback 4,9,5,7). Тестовите ЕИК-та са коректно обновени към валидни по checksum стойности (
111111113,222222226— проверих ръчно, минават контролната цифра). ✅ - Тестове: Обновяването на
refresh-slice.test.tsе задължително, защото старите фиктивни ЕИК-та (111111111/222222222) вече се отхвърлят от checksum-а. Има spomenat parity тест между пълния rebuild и инкременталния път. ✅ - Производителност: Добавени са индекси за корелирания MODE подзаявка — виж бележката по-долу за проверка дали реално се използват.
- Поддръжимост: Основната ми забележка е дублирането на checksum блока (виж inline коментар).
Композитна оценка: ~8.7/10. Логиката е коректна и добре обоснована. Не блокирам за сигурност, но повдигам две неблокиращи забележки (дублиране на код и потвърждаване на плана на заявката за MODE подзаявката) преди merge. Препоръка: COMMENT — адресирайте забележките или потвърдете, че са приемлив компромис.
Забележки
- Дублиране на код (CLAUDE.md: NO CODE DUPLICATION). Идентичният ~30-редов checksum CASE блок се повтаря 7 пъти между
normalize-raw.sqlиrefresh-slice.sql. Паритетът се пази само ръчно + parity тест. Тъй като чист SQLite скрипт не поддържа функции, разгледайте генериране на SQL от шаблон или регистрирана UDF, за да няма риск от разминаване при бъдеща корекция. - Производителност (за потвърждение). Корелираната MODE подзаявка стъпва върху
UNION ALLderived table; уверете се сEXPLAIN QUERY PLAN, че добавените индекси реално се използват, а не се re-materializira обединението за всяка група. - Пълнота на данните (#196). Guard-ът мълчаливо изключва ~405 съвместни поръчки от всички downstream rollup-и — документирано и умишлено (deferred), но добавете метрика/лог за броя изключени редове, за да е видимо.
lyubomir-bozhinov
left a comment
There was a problem hiding this comment.
ЕИК checksum-ът (#195) е верен — валидирах го емпирично, не само по спецификация. Извадих eik_valid CASE-а точно както е тук и го пуснах в sqlite3 срещу реални 13-значни ЕИК от данните на ЦАИС ЕОП:
| ЕИК | eik_valid |
|---|---|
0006723430010 (реален клон), …030, …078, …097 |
1 |
0000934421416, 0001931150243 (реални) |
1 |
000672343 (9-зн. база), 000695089 (ИО) |
1 |
0006723430011 (счупен d13) |
0 |
0006723439010 (счупен клон-digit) |
0 |
0006723440010 (счупена база) |
0 |
-0416.453.068 (malformed) |
0 |
Ръчна кръстосана проверка на 0006723430010: база 000672343 → пас1 124 % 11 = 3 = d9 ✓; клон позиции 9–12 = 3,0,0,1 → (2·3 + 5·1) = 11, 11 % 11 = 0 = d13 ✓. Тежестите (9-зн. 1..8 / 3..10; клон 2,7,3,5 / fallback 4,9,5,7 върху позиции 9–12), изискването база-да-е-валидна преди клона и ELSE 0 fallback-ите са коректни спрямо официалния Булстат алгоритъм. Без false-accept на едно-цифрени повреди. Отлична работа тук.
За скоупа (затова коментар, не одобрение):
- #194 (каноничното име) — този PR се застъпва със #215 на същите SQL блокове (
normalize-raw.sql+refresh-slice.sql); само единият влиза. Караме по #215 за #194 (носи ADR-0007 + curated override). - value-нормализация — PR-ът носи и over-valuation repair към procedure estimate (
value_suspect). Това пипа суми и е отделна грижа от идентичността; влиза под value-integrity бара и заслужава собствен review + accept тест. - #196 остава отворен — тук е DEFERRED (редовете стават traceable, но не се сплитват по реален ЕИК).
Предложение: извади checksum-а (#195) в самостоятелен тесен PR върху текущия main — чист win, лесен за merge; #194 остава на #215; value-repair-ът в трети PR.
…query The Bulgarian ЕИК control-digit checksum was hand-copied 7 times across normalize-raw.sql and refresh-slice.sql with no shared source of truth. SQLite scripts can't define functions, so the two SQL files are now generated (pnpm generate:sql) from *.template.sql + `-- @include` markers expanding scripts/lib/eik-valid.fragment.sql; a vitest drift-guard regenerates and diffs against the checked-in output so a hand-edit or a stale template fails CI instead of the two scripts silently disagreeing. Separately, EXPLAIN QUERY PLAN against a 4874-authority fixture confirmed the "index-backed" canonical authority-name subquery was never index-seeking its correlated predicate — SQLite re-SCANs the UNION ALL of raw_contracts/ raw_tenders per outer row (O(groups×rows); ~46s on the fixture). Both scripts now precompute the mode name once via GROUP BY + ROW_NUMBER() into a scratch authority_canonical_name table and join it in (same fixture: well under 100ms). normalize-raw.sql's now-unused authority_eik indexes are dropped; refresh-slice.sql's stay (still used by the touched-authorities IN-subquery).
normalize-raw.sql/refresh-slice.sql silently exclude any authority_eik that is a ';'-joined multi-ЕИК list (joint procurements, ~405 of ~4874 authorities) — an intentional, conservative exclusion (midt-bg#196) with no visibility into whether the excluded count grows over time. The per-refresh anomaly report (already wired into both the full and slice derive paths) now also counts distinct excluded authority_eik lists and raw rows across raw_contracts/raw_tenders and logs it every run.
|
Addressed all 3 threads:
Verified: |
ydimitrof
left a comment
There was a problem hiding this comment.
Ревю на PR #194/#195/#196 — authority/executor identity (обобщено)
Какво прави PR-ът
PR-ът консолидира идентичността на възложителите/изпълнителите в ETL слоя през три тикета:
- #194 — канонично име чрез MODE (най-често срещаното) вместо MIN, реализирано с оконна функция (
ROW_NUMBER()) и детерминистични tie-break-ове; ключът по ЕИК остава непроменен, така че слуговете/URL-ите не се влияят. - #195 — валидация на ЕИК/Булстат контролна сума (9- и 13-цифрена), с падане към ключ по име при невалиден код, за да се разделят погрешно слети субекти.
- #196 — консервативен multi-ЕИК guard (изключване на съвместните поръчки) + нова наблюдаемост в отчета за аномалии; пълното разпределяне е умишлено отложено за proposal 1.
Въведен е и единен източник на истина за контролната сума (scripts/lib/eik-valid.fragment.sql + generate:sql + тест за синхрон), който предпазва от дрейф между дублираните места.
Сигурност — ЧИСТО ✅
И трите партиди потвърждават: няма зашити тайни (курсът 1.95583 и публичните ЕИК на държавни субекти не са credentials), няма нови URL адреси или промени в зависимости, няма backdoor/обфускация. SQL-ът е изцяло статичен; конкатенациите вграждат стойности от колони, а @include е build-time — няма повърхност за SQL инжекция. Единствената защита в дълбочина: валидиране на името на фрагмента срещу path traversal в generate-sql.mjs (нисък риск).
Основни находки за потвърждение
Блокиращи / за изясняване преди merge:
- Възможна тиха загуба на данни при канонично име (#194): новото име може да е NULL там, където старият
MIN()даваше празен низ; при NOT NULL колона +INSERT OR IGNOREтова може тихо да отпадне възложител и каскадно неговите поръчки/договори. Потвърдете далиauthorities.nameе nullable, или добавете fallback. - „EOP wins" защита колабира при NULL
contract_number:COALESCE(...,'')дава'' = ''и подтиска всеки OCDS договор без номер, ако в прозореца съществува кой да е EOP ред без номер (същото вc2/c3). Потвърдете, че огледваnormalize-raw.sql, или добавете тест за случая. - Липсващ индекс
raw_contracts(contract_number): коментарът твърди, чеidx_raw_cnum„кара seek-а", но такъв индекс не се създава в този файл — рискът е деградация до O(n²). Предлага се добавяне наCREATE INDEX IF NOT EXISTS. - Scoping vs. „SCOPED" декларация: няколко UPDATE-а (напр.
bidders.ownership_kindбезWHERE, enrich/touch-entities) минават през цялата таблица при всеки refresh. Акоraw_*е кумулативна, това е сериозна write-amplification и на практика отменя scoping-а. Потвърдете семантиката наraw_*(прозоречни vs. кумулативни). - Multi-ЕИК guard (#196):
authority_eik NOT LIKE '%;%'изхвърля съвместните поръчки отauthorities→ риск от осиротели референции при downstream join; освен това хваща само разделител;. Потвърдете продуктово, че пълното изключване (а не разпределяне) е приемливо до proposal 1 и че downstream логиката го понася. - Корелирана подзаявка за
bidder_key: реферира външенc.contractor_eikот вложенFROM (SELECT …)без таблица. Уверете се, че версията на SQLite в D1 я поддържа и че parity тестът покрива именно този път (не само INSERT).
По-малки бележки
- Дублиране: ~15-редовият чексум израз е копиран 4 пъти (+ в
normalize-raw.sql); паритетът се пази само от тест — оправдано от липсата на SQL UDF в SQLite/D1, но кандидат за генериране от единен шаблон. home_totals.biddersброиcompany_totals, което изключва bidders сamount_eur IS NULL— вероятно по замисъл.- Пълни
DELETE+recompute наflow_pairs,data_freshness,home_totalsи др. — умишлено, но кандидат за бъдеща оптимизация при растеж. - Дивизиите са коректно защитени (
signing_value > 0,NULLIF(...,0)). - Уверете се, че
fx_rates(base_currency, rate_date)е индексиран заради повтарящите се FX подзаявки. - Семантична бележка за етикета на брояча в
anomaly-report.mjs(само наблюдаемост).
Качество / CLAUDE.md
Тестовете са смислени и консистентно обновени (тестовите ЕИК сменени на валидни по контролната сума — 111111113/222222226); няма cheater-тестове, мъртъв код или TODO-та; отложеното е ясно документирано като продуктово решение. Пълната преценка за паритет с normalize-raw.sql зависи от изпълнението на packages/db/src/refresh-slice.test.ts, което не бе възможно локално в рамките на партидите.
Обща оценка
Няма открити блокиращи проблеми по сигурност или зловреден код. Вердиктите по партидите варират от COMMENT до REQUEST_CHANGES заради нерешените въпроси по целостта на данните и производителността. Препоръка: изчистете точки 1–3 (тиха загуба на данни и липсващият индекс са с най-висок приоритет) и потвърдете точки 4–6 преди merge; останалите са за поддръжка/наблюдаемост.
…idx_raw_cnum Point the stale docs/etl-pipeline.md reference at its renamed docs/etl.md. Wrap the authorities.name insert in COALESCE(acn.canonical_name, s.authority_eik) in both normalize-raw and refresh-slice templates, so an authority whose raw rows all carry a NULL/blank name no longer silently drops the whole authorities row (and every FK'd tender/contract) via INSERT OR IGNORE against the NOT NULL column. Switch refresh-slice's OCDS-vs-EOP "EOP wins" dedup from COALESCE(x,'') = COALESCE(y,'') to bare equality, and drop the null-branch UNION in the post-insert cleanup DELETE, matching normalize-raw's already-safe dedup: SQL's `=` is never true for two NULLs, so two distinct contracts that both lack a contract_number no longer false-collapse into one. Add the idx_raw_cnum index both templates' comments already reference by name. Regenerate normalize-raw.sql/refresh-slice.sql from the templates.
…ng, ownership_kind scoping - scripts/generate-sql.mjs: reject `@include` fragment names containing `/`, `\`, or `..` before resolving the path, closing a path-traversal window in the template expander. - scripts/anomaly-report.mjs: relabel the multi-ЕИК metric "lists excluded" — the query counts distinct authority_eik list-strings, not distinct authorities. - scripts/refresh-slice.template.sql: scope the ownership_kind UPDATE to bidders touched by the current refresh batch (via a new refresh_batch_bidders scratch table), matching the file's SCOPED header instead of re-evaluating every bidder ever seen on every refresh. normalize-raw.sql is a full rebuild by design and needs no equivalent scoping. - packages/db/src/refresh-slice.test.ts: add a parity test exercising the correlated scalar bidder_key subquery (contracts-batch bidder_id), confirm the ownership_kind scoping leaves untouched bidders alone, and cover the fragment-name traversal guard.
refresh-slice.template.sql's ownership_kind scoping fix (86dad8a) re-derived the bidder_key/eik_valid checksum a second time via a new refresh_batch_bidders scratch table — a verbatim duplicate of the derivation the step-2 INSERT INTO bidders already computes, re-introducing the exact duplication midt-bg#129 removed via the @include mechanism. Confirmed the scoping itself is not a no-op: raw_contracts is a transient staging table (scripts/work-staging-schema.sql:8) created and populated only for the current refresh window and dropped at the end of the run (packages/ingest/src/refresh.ts:87, apps/etl/src/index.ts:66/133) — so it never holds the full corpus. refresh_batch_bidders now carries the full row-level derivation (bidder_key, contractor_name, eik_clean, eik_valid, grp), computed once via a single @include of eik-valid.fragment.sql, and both the bidders INSERT and the ownership_kind UPDATE's scoping subquery read from it. normalize-raw.sql is a full rebuild and needs no equivalent scoping — left unchanged. Existing regression test (refresh-slice.test.ts) already seeds a pre-existing bidder outside the refresh window and asserts its ownership_kind is untouched; re-verified green against the real packages/db test runner (194/194).
ydimitrof
left a comment
There was a problem hiding this comment.
Ревю на PR #194/#195/#196 — обобщение
fix(etl): идентичност на възложител/изпълнител — каноничнo име, ЕИК контролна сума, multi-ЕИК guard
ВЕРДИКТ: КОМЕНТАР — няма блокиращи проблеми по сигурността, но има един въпрос за интегритет на данните (двойно броене) и един пропуск в тестовото покритие, които трябва да се изяснят преди финално одобрение. (Синтезът покрива партиди 1 и 2; окончателното одобрение изисква и партида 3.)
Какво прави PR-ът
Подобрява идентичността на възложители и изпълнители в ETL пайплайна чрез три свързани промени:
- #194 — каноничнo име: извеждане на канонично име на възложителя чрез
MODE+ прозореченROW_NUMBERс детерминистични tiebreak-ове и precompute въвauthority_canonical_name, което заменя скъпата correlated subquery;COALESCE(canonical_name, eik)пазиNOT NULLи не изпуска редове. - #195 — контролна сума на ЕИК/Булстат: валидиране на 9- и 13-цифрени ЕИК по българския стандарт; невалидните кодове (напр. фалшивия
000000001и ~595 „не се публикува") се връщат към name-базиран ключ, за да не се слеят несвързани изпълнители. - #196 — multi-ЕИК guard: изключване на списъчните
authority_eik(NOT LIKE '%;%') от всички rollup-и, като raw staging остава непокътнат и проследим; консистентно с броячаFETCH_MULTI_EIK_EXCLUDEDвanomaly-report.mjs.
Сигурност — ЧИСТО ✅
- Няма твърдо кодирани тайни, нови зависимости или нови/променени URL адреси (само промяна на референция към документация).
- Няма backdoor, обфускация или инжекция. SQL е статичен и се изпълнява като атомарен D1 batch върху staging таблици; стойностите от данните се записват като данни, а не се конкатенират в изпълним SQL — няма вектор за SQL injection (A03 неприложимо).
- Обхождане на пътища в
generate-sql.mjsе коректно затворено (защита срещу/,\,..) и покрито с регресионен тест. - Алгоритъмът за контролна цифра е потвърден като коректен спрямо официалния стандарт.
Основни находки
1. (Изисква изясняване) Възможно двойно броене при EOP редове с NULL contract_number — normalize-raw.template.sql, dedup стъпка.
Дедупът на кумулативните дневни EOP кофи разчита на a.contract_number = c.contract_number. Коментарът твърди, че contract_number е гарантирано non-null от base keep-filter, но такъв филтър не се вижда в скрипта. Ако EOP ред има NULL contract_number, NOT EXISTS е винаги истина → кумулативните дубликати оцеляват и се броят многократно в EUR сумите. Моля потвърдете с COUNT(*) WHERE source LIKE 'eop:%' AND contract_number IS NULL; ако не е 0 — добавете явен NULL guard. (Забележка: смяната към bare = в refresh-slice.sql за OCDS↔EOP е коректна — там NULL≠NULL е желаното поведение.)
2. Липсва отрицателен тест за същината на #195. Тестовете упражняват валидния eik: път, но не и fallback-а при невалиден checksum (напр. 000000001 да НЕ колабира върху един bidder). Ако такъв тест не е в друга партида, препоръчвам изричен регресионен тест с таблица известни-валидни/невалидни ЕИК.
3. Дублиране на код (CLAUDE.md: NO CODE DUPLICATION). В refresh-slice.sql ~30-редовата checksum логика е инлайн-повторена поне 3 пъти, а големият value_flag CASE е дублиран между главния INSERT и pipeline_stats. Този файл не е template и няма @include, въпреки коментар обратното. Риск от разминаване при бъдеща промяна — препоръчва се същият генериран fragment механизъм за единен източник на истина. (За генерираните шаблони този проблем вече е адресиран и покрит с sync-тест.)
Второстепенни наблюдения
- Производителност: checksum и
value_lowклонът преизчисляват correlated под-заявки на ред (checksum двойно, fx_rates до ~3 пъти); за ~380k реда си струва проверка сEXPLAIN QUERY PLAN. Добавените индекси (idx_raw_cnum,idx_raw_tenders_tender_id,idx_raw_contracts_authority_eik) са уместни. - Асиметрия при валидацията: изпълнителите минават през checksum, но възложителите се ключоват на
'auth:' || authority_eikбез валидация — вероятно умишлено, но си струва да се потвърди в тикета. - Остатъчна scratch таблица:
authority_canonical_nameе non-TEMP (обяснено с D1 batch поведение) и не се изтрива — приемливо, но заслужава коментар за почистване.
Заключение
Много добре изпълнена, документирана и тествана работа. Преди финално одобрение препоръчвам да се адресират: (1) потвърждаване/затваряне на риска от двойно броене при NULL contract_number и (2) регресионният тест за невалиден checksum.
…sql.mjs Reject dot-only/empty @include fragment names (path.basename round-trip check) alongside the existing /, \, .. rejections, and wrap the fragment readFileSync in a try/catch that names the template and fragment on failure instead of surfacing a bare ENOENT. Addresses PR midt-bg#203 review threads on scripts/generate-sql.mjs:25/28. The third thread (refresh-slice.sql:99 checksum duplication) was already resolved by the existing eik-valid.fragment.sql @include mechanism — no further change needed there.
The refresh_batch_bidders comment claimed "single @include of the checksum fragment", which read as false when viewed in the generated refresh-slice.sql (a build artifact with no literal @include marker, and the checksum inlined at multiple bidder_key sites). The checksum was already deduplicated at the source: refresh-slice.template.sql pulls scripts/lib/eik-valid.fragment.sql into all 4 bidder_key sites via @include, mirroring normalize-raw.template.sql. Reword the comment to describe the generated-file/template relationship accurately and regenerate via `pnpm generate:sql` (comment-only diff, no SQL logic changed).
ydimitrof
left a comment
There was a problem hiding this comment.
Ревю на PR — fix(etl): authority/executor identity (#194 #195 #196)
ВЕРДИКТ: COMMENT — няма блокиращи проблеми по сигурност или цялост. Има няколко въпроса за потвърждение преди merge.
Какво прави PR-ът
Уеднаквява идентичността на възложители/изпълнители в ETL пайплайна чрез три свързани промени:
- #194 — канонично име: извежда се като MODE per ЕИК през
ROW_NUMBER()с детерминистични tiebreak-ове (вместо корелиран subquery) — по-коректно и с реално performance подобрение (новidx_raw_cnum). - #195 — ЕИК checksum: валидацията на контролната цифра е изнесена в
scripts/lib/eik-valid.fragment.sqlи се включва навсякъде чрез@include, което пази паритет сnormalize-raw. Подходът „една дефиниция на едно място" е правилен. - #196 — multi-ЕИК guard:
authority_eik NOT LIKE '%;%'изключва съвместните поръчки от downstream агрегациите.
Кодът е с високо качество, много добре коментиран и покрит с тестове.
Сигурност (Phase 0 — ЧИСТО и в трите batch-а)
- Няма хардкоднати тайни, ключове, пароли или токени.
- Няма URL/мрежови промени (единствената „URL" промяна е доку-път в коментар) и няма нови зависимости.
- Целият SQL е статичен/шаблонен, върху вече заредени staging таблици — няма повърхност за SQL инжекция. Стойностите в
state_owned_eikса референтни данни, не credentials. - Path-traversal защитата за
@includeвgenerate-sql.mjsе солидна и директно покрита с тестове. Няма backdoor/обфускация/инжектиране на код.
Коректност — потвърдени силни страни
- ЕИК checksum алгоритъмът (9- и 13-цифрен) е независимо преизчислен срещу реални кодове и съвпада точно с fragment-а и инлайн копията.
- Фикстурите са уеднаквени с новата валидация (невалидните
111111111/222222222→ валидните111111113/222222226). COALESCE(acn.canonical_name, s.authority_eik)предпазва от тихо изпускане на authority ред (и FK-свързаните tenders/contracts) при NULL име.- Замяната на
COALESCE(x,'')=COALESCE(y,'')с гола равенственост и премахването на NULL/NULLUNION-клона в refresh-slice е реална поправка на бъг — два различни договора с NULLcontract_numberвече не се сливат погрешно като дубликат. - Паритетът между пълния rebuild и инкременталния път е поддържан и покрит с тест.
Въпроси за потвърждение преди merge (не блокиращи, но важни)
-
EOP dedup — риск от двойно броене (batch 2): кумулативната дедупликация в normalize-raw (стъпка 5) ключова е на
a.contract_number = c.contract_number. Ако EOP ред може да носи NULLcontract_number, редът избягва дедупликацията и сумите му се броят двойно. Коментарът твърди non-null „base keep-filter", но такъв не се вижда в файла. Моля потвърдете инварианта; ако NULL е възможен, добавете композитен fallback ключ (unp+lot+contractor) за NULL случая. -
Multi-ЕИК guard — тиха загуба на покритие (#196): изключва ~405 съвместни поръчки и транзитивно техните tenders/contracts от всяка downstream агрегация. Умишлено е и вече е наблюдаемо чрез
multiEikExcluded/anomaly-report. Потвърдете продуктово, че отложеното разделяне е приемливо за момента. -
Подвеждащ обхват на enrichment (batch 3):
enrich-authorities/enrich-biddersказват в коментара „touched by refreshed staging", но условиетоWHERE EXISTS (... parties p ...)обновява ВСИЧКИ entity с party — пълно сканиране при всеки refresh. Идемпотентно е, но коригирайте коментара или скопирайте поrefresh_touched_*. -
authority-regionможе да изтрие стойност:SET region = (SELECT ...)презаписваregionс NULL, когатоnutsе NULL или липсва вnuts_regions— на инкременталния път губи вече попълненregion. ОбмислетеCOALESCE(..., region). -
ownership_kind: нулира се на NULL при липса на съвпадение вstate_owned_eik. Коректно е, ако allowlist-ът е единственият източник — моля потвърдете.
По-малки бележки
- Паритет на source-филтъра: normalize събира canonical name без source филтър, а refresh-slice филтрира
eop:%/ocds:%— уеднаквете или потвърдете, че е без значение. authority_canonical_nameе персистентна (не-TEMP) scratch таблица — документирайте я като служебна.- Ред на изпълнение:
state_owned_eik/seed трябва да е изпълнен преди normalize, иначеownership_kindостава NULL. flow_pairsиsearch_indexсе преизчисляват изцяло при всеки refresh — най-скъпата част при растеж на корпуса; струва си мониторинг/SLA.- Tiebreak
GLOB '*[a-zа-я]*'не покрива цялата кирилска малка азбука (напр.ё) — само display, пренебрежимо. - Per-row корелираният checksum subquery е по-скъп от стария синтактичен чек — струва си да се следи на пълния корпус.
Тестове
Покритието в batch 1 е много добро и смислено (NULL име на authority, два договора с NULL contract_number, скаларен bidder_id subquery, scope на ownership_kind, sync на генериран SQL спрямо шаблони, path-traversal guard-ове). Препоръка: добавете тест за идемпотентност (двукратно пускане на един прозорец → еднакви домейн редове). Общото покритие ≥90% не може да се потвърди от отделните batch-ове.
Заключение: Няма блокери по сигурност или цялост. Препоръчвам потвърждение по въпроси 1 и 2 (и по възможност 3–5) преди merge.
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).
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).
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).
Contracts whose authority_eik is a '; '-joined list of co-authorities are no longer excluded/dropped (was 579 contracts / 302M EUR vanishing). A lead authority is chosen per joint tender (co-author with the modal canonical name, fallback first ЕИК) so the contract attaches to a real authority; all co-authorities are recorded in a new contract_co_authorities bridge (migration 0002). Full contract value is attributed to the lead only in authority_totals.spent_eur (no split, no double-count); co-participation lives in a separate authority_joint_participation rollup that never enters spent_eur. Builds on the #196 diagnosis in #203 (@StanislavBG); related to #250 (@atanasster).
…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.
ydimitrof
left a comment
There was a problem hiding this comment.
Обобщено ревю на PR: fix(etl): authority/executor identity — canonical name, EIK checksum, multi-ЕИК guard (#194 #195 #196)
ВЕРДИКТ: COMMENT / необходими потвърждения преди merge — сигурността е напълно чиста, но има една потенциално блокираща бележка за целостта на данните (двойно броене) и няколко точки за потвърждение около инкременталния refresh.
Какво прави PR-ът
Уеднаквява идентичността на органи/изпълнители в ETL по три тикета:
- #194 — детерминистично каноничнo име на изпълнителния орган (MODE вместо MIN, с tie-break: cnt DESC → mixed-case → дължина → лексикографски).
- #195 — валидиране на ЕИК с контролна сума по стандарта БУЛСТАТ (9- и 13-цифрен алгоритъм с re-weight при остатък 10).
- #196 — консервативен guard срещу multi-ЕИК стойности (изключване на списъчните
authority_eik).
Логиката на checksum-а е изведена в единствен източник (eik-valid.fragment.sql + pnpm generate:sql) и се вмъква build-time в SQL артефактите, със sync/паритет тест, който проваля CI при разминаване — добро решение срещу дублиране на кода на ~7 места.
Фаза 0 — Сигурност: ЧИСТО ✅ (и в трите партиди)
- Няма hardcoded тайни, нови/променени URL адреси или промени в зависимости.
- Няма SQL injection повърхност — всичкият продукционен SQL е статичен/генериран;
@includeе build-time вмъкване, не runtime конкатенация; стойностите идват от staging таблици. - Path traversal в
generate-sql.mjsе коректно адресиран (отхвърляне при/,\,.., само-точкови имена,basename(x) !== x), с негативни тестове. - DELETE операциите в refresh са скоупнати (
c:[eo]:*GLOB +refresh_touched_*), админскитеc:редове се пазят; bare-equality вместоCOALESCE(...,'')избягва NULL-collapse при изтриване. - Няма backdoor/обфускация/code injection. OWASP: няма релевантни находки.
Най-важна находка (потенциално блокираща)
Двойно броене при EOP dedup (Висок). В normalize-raw.template.sql (стъпка 5, EOP клон) и в EOP клона на refresh-slice дедупът разчита на голо равенство a.contract_number = c.contract_number, а коментарът предполага непразен contract_number, без явен филтър в самия файл. При NULL/празен contract_number условието NOT EXISTS е вакуумно вярно за всички копия от кумулативните дневни кофи → редовете не се дедуплицират → дублирани договори и двойно броене на EUR сумите (clean_total_eur_bn, authority_totals, company_totals). Моля потвърдете, че load-eop.mjs гарантира непразен contract_number; ако не — добавете резервен dedup (unp+lot+contractor+date) или WHERE contract_number IS NOT NULL.
Точки за потвърждение преди APPROVE
- Скоуп на enrich в refresh (регресия при мащаб).
enrich-authorities/enrich-biddersUPDATE използватWHERE EXISTS (parties p WHERE p.eik = ...), което обхваща всеки орган/участник с ред в постояннатаpartiesтаблица, изпълнявайки по 5 корелирани ORDER-BY подзаявки върху цялата таблица при всеки refresh — противоречи на инкременталната цел. Ограничете доrefresh_touched_*или потвърдете умисъла. - OCDS lot_id не се нормализира за разлика от EOP (
lot_norm). Ако OCDSraw_contracts.lot_idидва катоLOT-3, FK къмlots.id(lot:unp:3) няма да съвпадне → висящи lot референции;@refresh-batch lotsвкарва редове само отraw_tenders. Потвърдете нормализация нагоре по потока или изравнете с EOP. state_owned_eikсе създава празна и не се seed-ва тук —ownership_kindзависи изцяло от нея; потвърдете, че се попълва от миграция/seed, иначе винаги ще е NULL.- Multi-ЕИК guard намалява покритието — филтрира само по
;и премахва ~405 списъчни ЕИК заедно с транзитивно свързаните tenders/contracts. Потвърдете, че;е единственият разделител в източника (иначе фантомни authority редове при,//), и логвайте отпадналия брой за проследимост. ownership_kindscoping в refresh — за недържавен bidder от партидата подзаявката връща NULL; потвърдете, че предходно класифициран bidder не може да бъде „нулиран“ при повторно докосване.- Твърди прагове/пълни преизчисления —
home_totals.first_date >= '2020-01-01'изключва договори преди 2020 (документирайте, ако е умишлено);flow_pairsсе преизчислява изцяло при всеки refresh (потвърдете SLA при растеж). - Твърда зависимост от raw staging в
anomaly-report(четеraw_contracts/raw_tenders) —buildAnomalyReportще хвърля върху БД без raw staging (напр. публикуван slice); потвърдете контекста на runner-а.
По-малки бележки
- Scratch таблица:
authority_canonical_nameсе създава, но не се трие — обмислете финаленDROP(обоснованието защо е plain, а не TEMP, заради D1 batch, е прието). - Производителност: checksum-ът за
bidder_keyе корелирана скаларна подзаявка на ред (2 места);pipeline_statsудвоява най-тежкото сканиране — приемливо за reconciliation, заслужава наблюдение. Precompute на mode чрезGROUP BY + ROW_NUMBER()е добро подобрение. amount_eur/NULL fx_rate: редът се вкарва, но се изключва от rollup сумите (amount_eur IS NOT NULL) — прието поведение.- CI/паритет: осигурете, че генерираните
.sqlартефакти са регенерирани и committed, а паритет-тестовете (refresh-slice.test.ts) покриват ВСИЧКИ разгънати места на checksum-а (bidder INSERT, дватаbidder_key, touched-bidders) и са зелени, с покритие ≥90% за новата логика.
Обща оценка
Висококачествена, добре документирана и добре тествана промяна с ясна проследимост към тикетите. Сигурността е чиста. Merge се препоръчва след адресиране на EOP dedup риска (двойно броене) и потвърждаване на точките около инкременталния refresh (scoping на enrich, OCDS lot нормализация, seed на state_owned_eik) при зелен паритет-тест.
Забележка: част от claim-овете (покритие на тестове, CI паритет) не можаха да бъдат изпълнени локално и трябва да се потвърдят в CI.
| const FETCH_MULTI_EIK_EXCLUDED = ` | ||
| SELECT COUNT(DISTINCT authority_eik) AS excludedAuthorities, COUNT(*) AS excludedRows | ||
| FROM ( | ||
| SELECT authority_eik FROM raw_contracts WHERE authority_eik LIKE '%;%' |
There was a problem hiding this comment.
Тази нова заявка въвежда твърда зависимост от raw_contracts/raw_tenders. buildAnomalyReport вече ще хвърли изключение (и ще счупи целия отчет), ако бъде извикан върху БД, в която raw staging таблиците не съществуват — напр. публикуван slice. Моля потвърдете, че anomaly report се изпълнява само в контекст, където raw таблиците присъстват; в противен случай обвийте заявката така, че липсата им да връща {authorities: 0, rows: 0} вместо да проваля отчета.
| -- a TEMP table survives across statements the way a local sqlite3 session would. | ||
| DROP TABLE IF EXISTS authority_canonical_name; | ||
| CREATE TABLE authority_canonical_name (authority_eik TEXT PRIMARY KEY, canonical_name TEXT); | ||
| INSERT INTO authority_canonical_name (authority_eik, canonical_name) |
There was a problem hiding this comment.
authority_canonical_name е plain (не TEMP) scratch таблица — създава се тук, но не се трие в края на скрипта, така че остава в БД до следващото изпълнение (когато DROP TABLE IF EXISTS я пресъздава). Обосновката за plain таблица заради D1 batch е разумна, но обмислете финален DROP TABLE authority_canonical_name; след като бъде join-ната в authorities, за да не се разнася временно състояние в изходната/публикуваната БД.
| WHERE s.authority_eik NOT LIKE '%;%' | ||
| GROUP BY s.authority_eik; | ||
|
|
||
| -- 1b) Friendly authority type buckets — heuristic from name + ЗОП type (non-critical display field; |
There was a problem hiding this comment.
Multi-ЕИК guard-ът (WHERE s.authority_eik NOT LIKE '%;%') филтрира само по разделител ;. Ако изходните данни някога съдържат списъци с друг разделител (напр. , или /), такъв ред ще премине guard-а и ще създаде един фантомен authority за целия списък — точно регресията, която #196 цели да предотврати. Моля потвърдете, че ; е единственият разделител на съвместни поръчки в източника, или разширете условието.
| WHERE e.source LIKE 'eop:%' | ||
| AND COALESCE(e.contract_number, '') = COALESCE(x.contract_number, '') | ||
| AND e.contract_number = x.contract_number | ||
| ) |
There was a problem hiding this comment.
Голото равенство тук е коректно за посоката „OCDS отстъпва на EOP“ (NULL contract_number в OCDS ред → редът се запазва). Обърнете внимание обаче на симетричния случай в normalize-raw.template.sql стъпка 5 (EOP self-dedup): там същият модел с гола равенство разчита EOP contract_number да е винаги непразен, иначе кумулативните дневни копия не се дедуплицират и сумите се броят двойно. Моля потвърдете, че load-eop.mjs гарантира непразен contract_number, или добавете резервен dedup за NULL случая.
|
Благодаря, @StanislavBG. Затварям този PR, но не защото работата е отпаднала - напротив, тя влезе в основата на решението. Какво стана. #203 обединяваше три отделни проблема (#194 име по мода, #195 контролен код по ЕИК, #196 многоЕИК). Разделихме ги на по един PR на проблем, за да може всеки да се прегледа и върне поотделно:
Какво от тук е взето директно. Правилото за подредба при равен брой в #251 е твоето от този PR: първо с малки букви пред изцяло главни (през GLOB диапазон Извеждането на контролния код в един общ фрагмент вместо седем копия също остана като идея - в #252 то е направено през междинна таблица Защо не продължихме в този клон. Трите промени пипат едни и същи файлове и се сливат трудно като един пакет. Освен това генерирането на Извинявам се, че отговорът дойде толкова късно спрямо отварянето на PR-а. |
…ties (#246) Adds 300 checksum-valid ЕИК to scripts/seed-state-owned.sql (state 194, municipal 69, mixed 37) from a research sweep over the АППК public-enterprise register, the БЕХ energy group, state forestry enterprises, state/municipal hospitals and ДКЦ, ports/airports, and the municipal companies of the largest cities. Every entity's current majority ownership was verified against a source; majority-private and privatized companies (Петрол, УниКредит Булбанк, А1, ЧЕЗ/Енерго-Про, Софарма Трейдинг, ДЖИ ПИ Груп, Хидрострой, ...) were deliberately excluded. Also drops the pre-existing checksum-invalid Топлофикация София dup (831609043); identical one-line removal as #203, no conflict.
* feat(etl): canonical entity fields by frequency mode, not MIN/MAX 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). * feat(etl,web): preserve verbatim ordering-unit name beside the canonical 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. * fix(etl): exclude composite joint EIKs from authority minting 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. --------- Co-authored-by: t <t@e.com>
* fix(etl): enforce bulstat checksum for eik identity 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). * feat(etl): canonical entity fields by frequency mode, not MIN/MAX 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). * feat(etl,web): preserve verbatim ordering-unit name beside the canonical 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. * fix(etl,web): preserve identity-poor contracts via an unknown-bidder 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. * fix(etl): fold contractor name-key over case, quotes and dash variants 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 * fix(db): exclude the unknown-bidder bucket from authority top-suppliers 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. * fix(etl): exclude composite joint EIKs from authority minting 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. * chore: drop accidentally committed node_modules symlinks 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. * style: prettier-format companies.test.ts --------- Co-authored-by: t <t@e.com>
* fix(etl): enforce bulstat checksum for eik identity 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). * feat(etl): canonical entity fields by frequency mode, not MIN/MAX 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). * feat(db): attribute joint procurements to each co-authority Contracts whose authority_eik is a '; '-joined list of co-authorities are no longer excluded/dropped (was 579 contracts / 302M EUR vanishing). A lead authority is chosen per joint tender (co-author with the modal canonical name, fallback first ЕИК) so the contract attaches to a real authority; all co-authorities are recorded in a new contract_co_authorities bridge (migration 0002). Full contract value is attributed to the lead only in authority_totals.spent_eur (no split, no double-count); co-participation lives in a separate authority_joint_participation rollup that never enters spent_eur. Builds on the #196 diagnosis in #203 (@StanislavBG); related to #250 (@atanasster). * feat(etl,web): preserve verbatim ordering-unit name beside the canonical 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. * fix(etl,web): preserve identity-poor contracts via an unknown-bidder 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. * fix(etl): fold contractor name-key over case, quotes and dash variants 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 * feat(db): pick joint leads by УНП prefix and value the participation Three refinements to the joint-procurement attribution: - Lead selection gains a top tier: the УНП prefix. УНП is `<AOP authority number>-<year>-<sequence>`, so the prefix identifies the authority that REGISTERED the procedure, i.e. the organiser. The map is learned from single-authority rows, where 4030 prefixes resolve to exactly one authority. Measured against the 354 joint tenders in the corpus, the prefix agrees with the previous first-EIK fallback 292 times, corrects it twice, and never points outside the member list (60 have no usable prefix). Name match and first EIK stay as the lower tiers. - `authority_joint_participation` gains `joint_contract_value_eur`: the total value of the joint procurements an authority took part in. It is informational only and is never summed into `authority_totals.spent_eur` or any national/leaderboard total, so a non-lead co-authority still shows zero spend for the contract while its page can state the amount involved. - `contract_co_authorities` and `authority_joint_participation` were defined both in `0000_init.sql` and in `0002_contract_co_authorities.sql`. The ETL work DB is built from 0000 only, so 0000 is the single home and the redundant 0002 is deleted; its table assertions stay in migrations.test.ts, now covering 0000. refresh-slice mirrors all of it. It learns the prefix map from the existing tender history rather than raw staging, which holds only the touched slice. * fix(db): exclude the unknown-bidder bucket from authority top-suppliers 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. * fix(etl): exclude composite joint EIKs from authority minting 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. * fix(etl): skip undecomposable composite EIKs when seeding joint members A ';'-bearing member EIK is a composite the splitter could not decompose - it is not a real single authority, and member_defaults would mint an orphan 'auth:EIK1; EIK2' row from it. Guard both the normalize and refresh member inserts. * style: prettier-format companies.test.ts --------- Co-authored-by: t <t@e.com>
…ent currency (#245) (#261) * fix(etl): enforce bulstat checksum for eik identity 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). * feat(etl): canonical entity fields by frequency mode, not MIN/MAX 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). * feat(db): attribute joint procurements to each co-authority Contracts whose authority_eik is a '; '-joined list of co-authorities are no longer excluded/dropped (was 579 contracts / 302M EUR vanishing). A lead authority is chosen per joint tender (co-author with the modal canonical name, fallback first ЕИК) so the contract attaches to a real authority; all co-authorities are recorded in a new contract_co_authorities bridge (migration 0002). Full contract value is attributed to the lead only in authority_totals.spent_eur (no split, no double-count); co-participation lives in a separate authority_joint_participation rollup that never enters spent_eur. Builds on the #196 diagnosis in #203 (@StanislavBG); related to #250 (@atanasster). * feat(etl,web): preserve verbatim ordering-unit name beside the canonical 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. * fix(etl,web): preserve identity-poor contracts via an unknown-bidder 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. * fix(etl): fold contractor name-key over case, quotes and dash variants 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 * feat(db): pick joint leads by УНП prefix and value the participation Three refinements to the joint-procurement attribution: - Lead selection gains a top tier: the УНП prefix. УНП is `<AOP authority number>-<year>-<sequence>`, so the prefix identifies the authority that REGISTERED the procedure, i.e. the organiser. The map is learned from single-authority rows, where 4030 prefixes resolve to exactly one authority. Measured against the 354 joint tenders in the corpus, the prefix agrees with the previous first-EIK fallback 292 times, corrects it twice, and never points outside the member list (60 have no usable prefix). Name match and first EIK stay as the lower tiers. - `authority_joint_participation` gains `joint_contract_value_eur`: the total value of the joint procurements an authority took part in. It is informational only and is never summed into `authority_totals.spent_eur` or any national/leaderboard total, so a non-lead co-authority still shows zero spend for the contract while its page can state the amount involved. - `contract_co_authorities` and `authority_joint_participation` were defined both in `0000_init.sql` and in `0002_contract_co_authorities.sql`. The ETL work DB is built from 0000 only, so 0000 is the single home and the redundant 0002 is deleted; its table assertions stay in migrations.test.ts, now covering 0000. refresh-slice mirrors all of it. It learns the prefix map from the existing tender history rather than raw staging, which holds only the touched slice. * fix(db,web): convert current_value_eur from the amendment currency, not signing currency (#245) contracts.current_value is populated from the latest amendment's value_after, denominated in THAT amendment's own currency — but every EUR conversion of current_value used contracts.currency (the contract's original signing currency), silently re-converting an already-EUR amendment (e.g. one recorded after ЦАИС ЕОП's 2026 BGN->EUR feed switch) and halving the reported value. Add contracts.current_value_currency to track which currency last set current_value, and route every current_value -> EUR conversion (refresh-slice, normalize-raw, precompute, the contract detail query) through it instead of contracts.currency. signing_value/signing_value_eur are unaffected — signing really is denominated in the contract's own currency. * fix(etl): route amount_eur through the amendment currency, not the signing currency (#245) current_value is minted from the latest amendment, which can be denominated in a different currency than the contract's signing currency. amount_eur — the column every rollup sums — still converted via contracts.currency, so a BGN contract with a EUR amendment carried roughly half its true value across all aggregates while the detail page showed the corrected current_value_eur, a self-contradicting DB. Pair a trusted_currency column with trusted_native so the conversion always uses the currency of the value actually chosen: the amendment currency when current_value was selected, the contract currency on the signing_value fallback (incl. annex_suspect). Mirror the same routing into eff_eur and add an integrity check asserting amount_eur and current_value_eur agree within a cent for value_flag='ok' rows with a current value — the invariant whose absence let this ship. * fix(etl): apply the whole migration chain to the work DB and served D1 (#245) The work-DB rebuild seeded schema from 0000_init.sql only, so a numbered migration (0002_current_value_currency) never reached it and normalize-raw aborted on the missing column. Apply every packages/db/migrations/*.sql in sorted order, which also lets 0001's index reach rebuilt work DBs for the first time. The served D1 gets the column via deploy.yml: probe the real table (wrangler's migration ledger is empty because the base schema was created out-of-band), then ALTER + backfill + precompute, gated on a completion-marker table so a partial first deploy resumes without a double ALTER. backfill-current-value-currency.sql repairs the ~2,510 already-shipped rows and self-checks parity. * fix(db): current-amount-parity must skip before precompute (#245) current_value_eur is populated by precompute on the served D1, not by normalize on the work DB. The integrity gate also runs on the work DB (import.mjs), where every current_value_eur is still NULL — so the parity check failed on every amended contract and aborted the rebuild. Gate it on home_totals like rollup-reconciliation: skip pre-precompute, assert on the served DB. Found by a full local rebuild; the served-DB data satisfies the invariant with 0 mismatches. * fix(db): exclude the unknown-bidder bucket from authority top-suppliers 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. * fix(etl): exclude composite joint EIKs from authority minting 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. * fix(etl): skip undecomposable composite EIKs when seeding joint members A ';'-bearing member EIK is a composite the splitter could not decompose - it is not a real single authority, and member_defaults would mint an orphan 'auth:EIK1; EIK2' row from it. Guard both the normalize and refresh member inserts. * style: prettier-format companies.test.ts * fix(merge): carry the full fold set for the amendment-currency composition The fold diff was applied only to the six textually-conflicted files; four more files it touches (async current-amount-parity in integrity-checks.mjs, migration 0002 in the two SQL-execution tests) auto-merged to stale versions - the sync parity check against async helpers returned NaN and failed the gate. Apply the fold to them too. * test(etl): teach the gate fixture about current-amount-parity #261 adds current-amount-parity to the shared roster; the fakeD1 from #156 did not answer its query (NaN) and the skip-count expectation assumed only rollup-reconciliation un-skips with rollups present. Answer the parity query clean and expect two fewer skips. --------- Co-authored-by: Bilko <StanislavBG@gmail.com> Co-authored-by: t <t@e.com>
Fixes #194. Also addresses #195 and #196.
Problem
Authorities are deduped on ЕИК (correct — one legal entity), but the displayed name was chosen with
MIN(authority_name).MIN()returns the alphabetically-earliest string, so where several labels share one ЕИК, a rare variant wins over the dominant one.Concrete case (shared ЕИК
000695114): „БСУ Д-р Петър Берон" sorts before „МИНИСТЕРСТВО НА ОБРАЗОВАНИЕТО И НАУКАТА", so the ministry (620 rows, dominant) lost its page title to the school. Contracts were already linked to the single entity — only the label was wrong.What changed
Canonical authority name (#194) —
MIN(authority_name)replaced with a mode (most-frequent label) per ЕИК, with a deterministic tiebreak:COUNT(*) DESC— the most-common label wins.UPPER()/LOWER()are ASCII-only and don't fold Cyrillic, so mixed-case is detected as "contains a lowercase letter" via a GLOB range*[a-zа-я]*(Latin + Cyrillic).The ЕИК key (
id = 'auth:'||ЕИК) is untouched, so slugs/URLs — which key on ЕИК inpackages/db/src/queries/identity.ts— don't change. Only the label changes.ЕИК checksum (#195) —
eik_validnow enforces the Bulstat checksum. The ~30-line inline logic (repeated 3+ times) was extracted into a sharedscripts/lib/eik-valid.fragment.sql, expanded via a new@includemechanism inscripts/generate-sql.mjs.Multi-ЕИК guard (#196) — list-valued
authority_eikrows are excluded from the canonical-name step, superseding #205. The exclusion is deliberate and tracked in #196;scripts/anomaly-report.mjsreports the count so it isn't silent.Other fixes landed in review — NULL authority-name fallback; an EOP-wins NULL-collapse bug (bare equality, so two different contracts with a NULL
contract_numberno longer merge as duplicates);idx_raw_cnum; an O(groups×rows) canonical-name subquery;ownership_kindUPDATE scoped to the refresh window; path-traversal hardening on@includefragment names.How it was tested
Local staging (
raw_contracts/raw_tenders) had been cleared after the last ETL, so the SQL was validated against representative fixture rows in an in-memory SQLite (node:sqlite, same engine):MIN()picked the school/all-caps variant (reproducing the bug); the mode picksМИНИСТЕРСТВО НА ОБРАЗОВАНИЕТО И НАУКАТА. PASSОБЩИНА ТЕСТvsОбщина Тест→ mixed-case wins. PASSVerification pass 1 —
pnpm --filter db test -- refresh-slice: 194/194 pass, including thebidder_idscalar-subquery +ownership_kindscoping regression test and the template/checksum-parity sync test (GENERATED_TARGETS, diffing regenerated output against the checked-in.sql).Verification pass 2 (independent) — full suite re-run against a clean worktree at the PR head with a locally-fetched
sqlite3binary: same 194/194, confirming the fix doesn't depend on stale local state.Path-traversal guard confirmed manually:
expandTemplaterejects any@includefragment name containing/,\, or..beforeresolve()/readFileSync.Quality checks
checkjob) on head3629ae9.normalize-raw.sql(full rebuild) andrefresh-slice.sql(incremental), enforced bypackages/db/src/refresh-slice.test.ts. The one intentional divergence:normalize-raw.sqlhas noownership_kindWHEREscoping — correct, since it processes the entire corpus each run, whereasrefresh-slice.sqlscopes torefresh_batch_bidders(derived once, shared by thebiddersINSERT and the UPDATE subquery).MIN(contractor_name)pattern. Name-keyed bidders have identical names by construction (the key is the normalized name); only ЕИК-keyed bidders with multiple raw name variants share the latent issue. Noted as a separate follow-up rather than widening this PR.Review
All review threads from both rounds are addressed and resolved. The two most recent (2026-07-13) were confirmations answered with evidence, requiring no code change:
935d59f:FETCH_MULTI_EIK_EXCLUDED(scripts/anomaly-report.mjs:176) counts distinct excluded lists + raw rows acrossraw_contracts/raw_tenders, surfaced atanomaly-report.mjs:251. It readsraw_*post-ETL, so it covers both ETL paths.contract_number— enforced at ingest, not in SQL:packages/ingest/src/base.ts:156drops null rows before staging (30/156 of a real day's EOP contracts are dropped there). The nullablecontract_numberatocds.ts:320is theocds:path only, whose dedup branch deliberately keeps NULL rows. No composite fallback key needed.