diff --git a/apps/web/app/components/RiskIndicators.tsx b/apps/web/app/components/RiskIndicators.tsx
index ff19a23d..59e1568a 100644
--- a/apps/web/app/components/RiskIndicators.tsx
+++ b/apps/web/app/components/RiskIndicators.tsx
@@ -34,14 +34,16 @@ export function RiskIndicators({ contract }: { contract: ContractDetail }) {
if (flag.type === 'eu_no_competition') {
return (
- Риск при Еврофондове: Проектът е финансиран с европейски средства, но е възложен без реална конкуренция (повишен риск според стандартите на ОЛАФ).
+ Риск при Еврофондове: Проектът е финансиран с европейски средства,
+ но е възложен без реална конкуренция (повишен риск според стандартите на ОЛАФ).
);
}
if (flag.type === 'no_competition') {
return (
- Липса на конкуренция: Този договор е сключен след допускане на само една оферта.
+ Липса на конкуренция: Този договор е сключен след допускане на само
+ една оферта.
);
}
@@ -56,7 +58,8 @@ export function RiskIndicators({ contract }: { contract: ContractDetail }) {
if (flag.type === 'anomalies') {
return (
- Аномалии в данните: Стойността на договора (или някои от датите) е извън обичайния диапазон и подлежи на допълнителна проверка.
+ Аномалии в данните: Стойността на договора (или някои от датите) е
+ извън обичайния диапазон и подлежи на допълнителна проверка.
);
}
diff --git a/apps/web/app/lib/riskLogic.test.ts b/apps/web/app/lib/riskLogic.test.ts
index 640b954e..11d51292 100644
--- a/apps/web/app/lib/riskLogic.test.ts
+++ b/apps/web/app/lib/riskLogic.test.ts
@@ -50,10 +50,14 @@ describe('evaluateRiskIndicators', () => {
});
it('does not trigger HIGH_MARKUP when deltaPct is exactly 20% or less', () => {
- const contract1 = buildContract({ value: { deltaPct: 0.20, suspect: false } });
+ const contract1 = buildContract({ value: { deltaPct: 0.2, suspect: false } });
const contract2 = buildContract({ value: { deltaPct: 0.19, suspect: false } });
- expect(evaluateRiskIndicators(contract1)).not.toContainEqual(expect.objectContaining({ type: 'high_markup' }));
- expect(evaluateRiskIndicators(contract2)).not.toContainEqual(expect.objectContaining({ type: 'high_markup' }));
+ expect(evaluateRiskIndicators(contract1)).not.toContainEqual(
+ expect.objectContaining({ type: 'high_markup' }),
+ );
+ expect(evaluateRiskIndicators(contract2)).not.toContainEqual(
+ expect.objectContaining({ type: 'high_markup' }),
+ );
});
});
diff --git a/docs/etl-architecture.md b/docs/etl-architecture.md
new file mode 100644
index 00000000..df75bcc0
--- /dev/null
+++ b/docs/etl-architecture.md
@@ -0,0 +1,135 @@
+# Sigma ETL — целева архитектура и план
+
+> Статус: предложение (RFC) за обсъждане. Описва целевото състояние на ETL pipeline-а и реда на изпълнение. Свързано с #139, #154, #158, #160, #163 и #103.
+>
+> Ревизия v2: вградени бележките от прегледа на Stefan Angelov (#174) — коригирана класификация на инвариантите, преразгледан механизъм за атомарна промоция, поправен ред FX↔gate, добавени edge cache и singleton lock.
+
+## Контекст
+
+Деплойнатият `sigma-etl` cron (на всеки 6 часа) пише директно в served D1 през `refresh-slice.sql` — без integrity проверка и без FX зареждане. Всеки друг derive път минава през `assertIntegrity`, но автономният cron — точно онзи без надзор — е единственият без предпазна мрежа. Подробна карта на текущото състояние: [`docs/etl-pipeline-state.md`](etl-pipeline-state.md). Този документ описва къде искаме да стигнем.
+
+## Водещи принципи
+
+1. **Correctness пред свежест.** Served зоната никога не показва грешни числа. По-добре данните да липсват (и това да е ясно отбелязано), отколкото да са подвеждащи — заради репутацията на проекта.
+2. **Retry само за преходни грешки.** Мрежа / timeout / частичен fetch се повтарят с backoff и таван (за да не натоварим източника). Грешки в данните или в логиката **не** се повтарят — детерминистични са (същите данни → същата грешка) и отиват към аларма.
+3. **Без дубликати при повторен опит.** Ingest-ът е идемпотентен. Засега идемпотентността идва от изтриване по партиция (`DELETE WHERE source = ?` + `INSERT`, `packages/ingest/src/staging.ts`), **не** от row-level уникален ключ — на raw таблиците няма `UNIQUE` constraint. За персистентна RAW зона това трябва да се затвърди (виж „Отворени въпроси по RAW").
+
+## Архитектура — три зони
+
+```mermaid
+flowchart TD
+ SRC["storage.eop.bg / OCDS източник"]
+ SRC -->|fetch| ING{"Ingest cron"}
+ ING -->|"networking / timeout"| RETRY["Backoff + retry, с таван"]
+ RETRY --> ING
+ ING -->|"не може да завърши деня"| AL1["Discord аларма → ръчно"]
+ ING -->|"идемпотентен запис по партиция"| RAW[("RAW зона
нова D1 база")]
+
+ RAW --> DERIVE["Derive в STAGING
refresh-slice логика, не в live"]
+ DERIVE --> FX{"FX покритие
за периода?"}
+ FX -->|"липсва курс"| QFX["Карантина на валутните записи + аларма"]
+ FX -->|"ок"| GATE
+ QFX --> GATE
+
+ GATE{"Integrity gate
глобална композитна assertIntegrity"}
+ GATE -->|"structural fail"| REJ["Reject цялата партида
served остава на последните валидни данни + аларма"]
+ GATE -->|"row-attributable (WARN)"| QUAR["Карантина на ниво entity
маркирай 'в процес на проверка' + аларма"]
+ GATE -->|"pass"| PROM["Промоция към SERVED
table-swap RENAME / version-pointer flip"]
+ QUAR --> PROM
+
+ PROM --> SWAP["Invalidate edge cache (purge)"]
+ SWAP --> SERVED[("SERVED зона
Sigma Web чете оттук")]
+
+ REJ -.-> AL2["#139 канал: Discord + имейл"]
+ QUAR -.-> AL2
+ QFX -.-> AL2
+ AL1 -.-> AL2
+```
+
+- **RAW зона** (нова D1 база): cron-ът сваля данните тук. Изолира преходния мрежов retry от детерминистичния derive. Важно уточнение: текущата „raw" схема е нормализирана (~60 колони), не raw-JSON архив — затова „преизчисли от RAW" на практика значи „пресвали от EOP", докато не добавим истински raw store.
+- **STAGING зона**: derive логиката (днешният `refresh-slice`) изчислява следващото състояние от RAW в staging таблици — без да пипа live.
+- **SERVED зона**: Sigma Web чете само оттук. Промоцията от staging към served става единствено след успешна проверка.
+
+### Механизъм на промоцията (преразгледан)
+
+Първоначалното предложение беше „приложи цялата мутация като една атомарна `batch()`". **Това не е изпълнимо** и заменям препоръката:
+
+- `refresh-slice` е нарочно разделен на ~22 отделни `batch()` групи (`packages/ingest/src/refresh.ts:55-58`, `apps/etl/src/index.ts:112`), защото една batch надхвърля Workers CPU/size бюджета. Пълен refresh на ~194k договора не може да е една batch → multi-batch промоцията **не е атомарна** (провал между batch N и N+1 оставя SERVED полу-промотирана).
+- Ако RAW/STAGING е отделна D1 база, атомарна `batch()` между D1 инстанции е невъзможна.
+- **Реалният примитив** е shadow-table `RENAME` swap (сам по себе си евтин blue-green) или `served_version` pointer flip — единичната, наистина атомарна операция, която сменя видимото състояние наведнъж.
+- „Single writer" не е даденост: `scheduled()` вика `env.REFRESH.create()` безусловно на всеки 6 часа без singleton lock (`apps/etl/src/index.ts:140-142`), а CLI може да пише едновременно. Това трябва да се наложи (lock / singleton), иначе TOCTOU между проверка и swap е реален.
+- **Edge cache:** Web чете същата D1 и `apps/web/app/lib/cache.ts` сервира `s-maxage` + `stale-while-revalidate` **без purge hook**. Гаранцията „SERVED никога не показва грешни числа" изтича на edge слоя — лоша партида, ако някога стигне до served, остава кеширана до изтичане на SWR прозореца. Промоцията трябва да включва invalidate на edge cache.
+
+## Поведение при грешка
+
+| Тип грешка | Пример | Реакция |
+|------------|--------|---------|
+| **Преходна** (мрежа/timeout) | прекъснат fetch, частичен отговор | retry с backoff + таван; при изчерпване → аларма, денят не продължава |
+| **Structural** (бъг в derive) | rollup не се сходи, orphan редове, празен корпус, `inserted > candidates`, sign-flip при `value_flag='ok'`, нарушен EIK инвариант | reject на цялата партида; served остава на последните валидни данни; аларма. Причината е системна → човек поправя кода |
+| **Row-attributable** (лоши данни от източника) | липсващ FX курс → `amount_eur=NULL`; негатив без `ok` флаг (само WARN) | виж раздела по-долу |
+
+## Гранулярност при проблемен ден
+
+Ключов въпрос: при лоши данни изключваме ли **целия ден**, или **само засегнатата фирма/запис** (карантина)?
+
+**Това не е въпрос за correctness.** И двата варианта са еднакво коректни — карантината изключва лошия запис изцяло (не влиза нито в `contracts`, нито в rollup-ите), тоест served зоната не показва грешни числа в нито един от случаите. Изборът е за **свежест и обхват на щетата срещу сложност на имплементацията**. Нещо повече: „цял ден да го няма" е по-лошото лекарство — за да скриеш един проблемен договор на фирма X, изтриваш и изрядните договори на фирми Y и Z за същия ден.
+
+**Единствената реална тънкост — непълни тотали на фирма.** Ако фирма X има 7 договора и 1 е в карантина, тоталът ѝ показва €6M вместо €7M. Решението е **маркер за непълнота на ниво entity**: тоталът се отбелязва „в процес на проверка". Внимание: този маркер не бива да се обърква със съществуващия `suspect` брояч (`CompanyDetail.suspect` брои `value_suspect` договори) — трябва отделно поле.
+
+**Препоръка — класификация по причината:**
+
+| Причина | Пример | Реакция |
+|---------|--------|---------|
+| Изолиран факт от източника | липсващ FX курс | **изключи само записа** + маркер за непълнота на entity-то. Очевидно коректно — записът не може да се представи (няма как да сметнеш EUR без курс) |
+| Подозрение за наш бъг | sign-flip при `value_flag='ok'`, нарушен EIK инвариант, `inserted > candidates` | **structural → reject на цялата партида**. Ако нашата логика е сгрешила, най-вероятно е засегнала и други записи |
+| Глобална | rollup не се сходи, orphan редове, празен корпус | **reject на цялата партида** |
+
+> ⚠️ `Inv 4` (date sanity) **винаги връща `ok: true`** (`scripts/integrity-checks.mjs:265`) — той е чисто WARN. Затова „невъзможна дата → карантина" е **мъртъв тригер** спрямо текущата проверка; не залагай routing върху него, преди инвариантът да бъде направен да връща нещо.
+
+## Класификация на инвариантите (Фаза 0)
+
+Прегледът коригира първоначалната таблица. Точното разпределение:
+
+| Инвариант | Клас | Реакция |
+|-----------|------|---------|
+| Inv 0 (non-empty corpus) | structural | reject |
+| Inv 1 (rollup reconciliation) | structural | reject |
+| Inv 2 — клон `value_flag='ok' AND amount_eur<0` | **structural** (sign-flip в derive) | reject |
+| Inv 2 — клон без `ok` флаг | row-attributable | само **WARN** |
+| Inv 3 (EIK validity) | **structural** — валидира гаранцията на самата Sigma при нормализация (`eik_valid=1 ⇒ eik_normalized` числов) | reject |
+| Inv 4 (date sanity) | WARN, винаги `ok: true` | не може да гейтва/карантинира |
+| Inv 5 (staging→domain) | structural; **self-skip-ва** при липсващ/остарял `pipeline_stats` — нужда от преразглеждане при изпълнение pre-promotion срещу staging | reject |
+
+## План по фази
+
+| Фаза | Какво | Issue | Защо този ред |
+|------|-------|-------|---------------|
+| **0** | Класифицирай инвариантите (горната таблица): structural = Inv 0/1/3/5 + `ok`-клон на Inv 2; row-attributable (WARN) = не-`ok` Inv 2; Inv 4 е инертен | — | Дизайнерска основа; кодът вече съществува в `scripts/integrity-checks.mjs` |
+| **1** | Поправи stale rollup при re-attribution (touched = old ∪ new) | #160 | Евтино, независимо; прави scoped преизчислението коректно — предпоставка за надеждна реконсилиация |
+| **2** | **RAW зона + идемпотентен ingest + retry политика.** Дефинирай уникалния ключ; отдели ingest-а от derive-а; backoff с таван; Discord аларма при незавършен ден | нов | Изолира преходните грешки от детерминистичните |
+| **3** | **FX в pipeline-а** — гарантирай, че `fx_rates` покрива периода **преди** gate-а | #158 | **Трябва да предхожда gate-а** (виж бележката за реда по-долу) |
+| **4** | **Граница на публикуване:** derive в staging → глобална композитна `assertIntegrity` → промоция чрез table-swap/version-pointer + edge purge. Structural→reject, WARN→entity-карантина | #163 | Ядрото на correctness-first; зависи от 1, 2 и 3 |
+| **—** | **Observability — Discord + имейл аларма.** Co-requisite на Фаза 4, не следваща фаза | #139 | Freeze-on-failure е безполезен, ако алармата не стига до човек — gate-ът не бива да тръгва без канала |
+| **5** | Golden totals / per-grain проверка за in-window total-preserving mis-attribution | #99 | Остатъчният blind spot, който глобалната сума структурно не лови |
+
+**Критичен път:** #160 → RAW зона → **#158 (FX) → #163 (gate)**, с #139 включен заедно с #163.
+
+### Бележка за реда FX ↔ gate (висок приоритет)
+
+`Inv 1` сумира само редове с `amount_eur IS NOT NULL`. Ако gate-ът тръгне **преди** FX да зарежда в cron-а, всеки валутен договор тихо става `NULL`, изпада от всички rollup-и, а gate-ът **минава зелено върху непълни данни**. Затова #158 трябва да е преди #163 — или инвариантът на Фаза 4 да се scope-не до BGN/EUR, докато FX дойде.
+
+## Какво остава нерешено
+
+- **In-window total-preserving mis-attribution** — договор, приписан на грешна фирма, когато общата сума се запази. Само per-grain проверката (#99) го хваща; глобалната сума структурно не може.
+- **Атомарност на промоцията** — изборът между shadow-table `RENAME` swap и `served_version` pointer трябва да се специфицира преди #163 (multi-batch не е атомарен; cross-D1 batch е невъзможен).
+- **`pipeline_stats` self-skip** на Inv 5 при изпълнение срещу staging.
+- **Edge cache invalidation** — promotion-ът се нуждае от purge hook, иначе гаранцията изтича на edge до края на SWR прозореца.
+- **OCDS releases-vs-records и амендменти** — правилото за предимство (`(source_day, ocid, release.id)` срещу `(ocid, contract.id)`) и пътят за амендментите не са дефинирани в cron пътя.
+
+## Връзка между принципите и трите въпроса
+
+Архитектурата отговаря директно на трите въпроса, поставени в дискусията:
+
+1. *Как се държим при непълно вкаран ден?* → correctness-first; SERVED зоната получава данни само като цяло, минало проверка (раздел „Гранулярност").
+2. *Кои грешки се ретрайват?* → само преходните (мрежа/connectivity), с backoff и идемпотентност, за да няма дубликати (RAW зона, принцип 2 и 3).
+3. *До каква степен гоним correctness?* → глобална композитна проверка преди публикуване, не slice-local (раздел „Архитектура", Фаза 4).