Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/adr/0030-legacy-fx-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# ADR-0030 — Насочен backfill на заварените NULL amount_eur (следствие от #158)

- **Статус:** Прието
- **Дата:** 2026-07-22
- **Обхват:** `scripts/backfill-fx.mjs`, `scripts/import.mjs` (slice FX gate), served D1

## Контекст

Преди ADR-0029 cron derive-ът вървеше с празна `fx_rates`: договорите в чужда валута от този
прозорец седят в served D1 с `amount_eur = NULL` (извън всички суми) и — по-коварно — с грешно
решени fx-зависими клонове на `value_flag` (`eff_eur` беше NULL, затова `value_suspect`/`review`
пропадаха до `ok`). Cron поправката спира нови случаи, но не лекува заварените: те са извън
21-дневния й прозорец.

Алтернативи: (а) пълен CLI re-derive — пълна коректност, но изисква целия raw корпус, повторен
ingest и часове работа за шепа редове; (б) насочена поправка на място — минути, без staging, но
дублира derive формули. Избираме (б) с **емпирична гаранция срещу drift**: equivalence тест, който
derive-ва един и същ корпус С курсове (еталон) и БЕЗ курсове + backfill, и изисква байтова
идентичност на всяка EUR колона, флаг и rollup таблица.

## Решение

`scripts/backfill-fx.mjs` (report по подразбиране, `--apply` за поправка; същите цели като
`load-fx.mjs`: `--work-db` / локална / `--remote` D1):

1. **Щета — предикат по коренната причина.** `currency NOT IN ('BGN','EUR') AND fx_rate IS NULL AND
value_flag <> 'value_suspect'`: чужд договор без `fx_rate` е точно този, който прозорецът остави
безкурсов. `fx_rate IS NULL` е коренът; `amount_eur IS NULL` беше само най-видимият **симптом** и
изпуска един клас — чужд договор, чиято водеща стойност е анекс в **EUR** (`current_value_currency
= 'EUR'`, #245): `amount_eur` му е коректно попълнено по номинал, но `fx_rate` / `signing_value_eur`
са NULL. Предикатът по `fx_rate` лови и него. Плюс **само-флагов** клас: `value_flag` зависи и от
валутата на **тендерната оценка** — договор в BGN/EUR с чужда валута на оценката е с коректно
`amount_eur`, но с грешно пропаднали `value_suspect`/`review` клонове; открива се чрез
преизчисляване на флага с налични курсове и сравнение. Офлайн report-ът брои непроверимите
кандидати (`flagUnverified`) и излиза с код 1.
2. **Курсове:** липсващото покритие (валути на договорите + чужди валути на тендерните оценки,
по дата на подписване) се тегли през споделената FX логика от ADR-0029 (`fx.ts`: lookback, URL,
валидация, host-pinning) и се upsert-ва в `fx_rates`. Fetch грешка при оставаща дупка → изключение
(fail loudly); валута извън ECB → остава NULL, отчита се.
3. **Поправка на редовете:** `fx_rate` от курса към датата на подписване; преизчисляване на
fx-зависимите флагове (`value_suspect` печели над всеки текущ флаг, `review` само от `ok` —
същият приоритет като derive-а); `value_suspect` се ремонтира от тендерната оценка (както в
derive-а). EUR колоните се преизчисляват през **същата стълбица като derive-а** (`toEur`), а не
с наивно `amount × fx_rate`: всяка родна стойност се конвертира по **своята** валута — `amount`
по `trusted_currency`, `current_value` по `current_value_currency` (миграция 0002 — EUR анексът
по номинал, а не по курса на подписване, #245), `signing_value` по валутата на договора; чуждият
клон ползва курса на договора, EUR/BGN клоновете — номинал/фиксинг. `annex_suspect` остава с
потиснато `current_value_eur`. BGN/EUR редовете конвергират към номинал/фиксинг (идемпотентно).
4. **Rollups:** поправените редове пълнят `refresh_touched_*` и се пускат собствените батчове на
`refresh-slice.sql` (company/authority totals, flow-pairs, search index, globals, cleanup) —
нула дублиран rollup SQL. Поправка + rollups излизат като **един** exec (една
wrangler/sqlite3 инвокация), а прекъснат пуск се лекува: останалите `refresh_touched_*`
таблици (cleanup ги трие последен) са трайното доказателство кои редове са с изостанали
rollups — следващият пуск ги засича в самото начало (преди early return и преди какъвто и да е
fetch) и препуска rollup батчовете върху тях. Report режимът също ги отчита и излиза с код 1.
5. **Gate parity:** `runSliceDerive` в `import.mjs` вече също минава `assertFxPopulated` — и то
на същото място като full derive-а: след derive групите и **преди** rollup групите (сплит по
споделения `REFRESH_SLICE_ROLLUP_GROUPS`, с drift-тест). Гейт след rollup-ите би гърмял
шумно, но върху вече записани повредени суми.

## Последствия

- Заварените редове се лекуват за минути, без raw корпус; идемпотентно (второ пускане: нула
fetch-ове, нула промени).
- **Остатъци (документирани):** (1) клонът на `value_low` „< 1000 € подписана И < 5% от оценката"
чете **собствената** оценка на договора (`estimated_value` / `procurement_currency` от raw), която
не оцелява в served `contracts` — затова ремонтиран ред, който би бил `value_low`, остава `ok`.
За **сумарните** rollup-и това е безобидно (`value_low` се сумира като `ok` — една и съща
`trusted_native` формула), **но НЕ и за `cpv_division_stats`**, който филтрира `value_flag = 'ok'`:
такъв ред замърсява процентилите на своята CPV дивизия. Backfill-ът брои горната граница на класа
(`summary.valueLowUnverifiable` — ремонтиран `ok` ред с чужда валута и подписана стойност < 1000 €,
чийто 5%-клон е неразрешим офлайн) и я отчита в summary-то; **пълният CLI re-derive е пътят към
абсолютна коректност на процентилите**. (2) ред, който би станал `value_suspect` без тендерна
оценка, при derive би отпаднал изцяло (`display_native IS NULL` филтър) — backfill-ът не трие
редове; (3) валута извън ECB остава непоправима и се отчита (`remaining` / `flagUnverified`).
- Equivalence тестът е и drift-аларма: промяна във формулите на `refresh-slice.sql` (напр. #245/
#261 по валутата на анексите) ще го счупи шумно и backfill-ът трябва да се пренастрои съзнателно.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
| [0005](0005-blue-green-d1-rollback.md) | Blue/green D1 слотове за rollback на refresh | Прието |
| [0006](0006-eop-wins-dedup.md) | Dedup на два източника: EOP печели по `contract_number` | Прието |
| [0029](0029-worker-native-fx-load.md) | Worker-native FX зареждане в cron refresh-а и режим на отказ | Прието |
| [0030](0030-legacy-fx-backfill.md) | Насочен backfill на заварените NULL `amount_eur` | Прието |
Loading