Mahsuplaşma Modül Mimarisi
Bu sayfa, mahsuplaşma (netting) modülünün teknik iç yapısını anlatır: feature paketleri, yetki yüzeyleri, uygunluk motoru, tarife çözümleyici, IntegrityError ayrıştırma deseni, state-machine'ler ve evaluate transaction modeli. Kullanıcı-yönlü anlatım için Kullanım Kılavuzu — Mahsuplaşma bölümüne bakın.
Feature paketleri
Modül iki ayrı backend feature paketine bölünür:
backend/app/features/
├── mahsuplasma/ # tenant-scoped (firma verisi)
│ ├── router.py # /api/mahsuplasma/* (39 endpoint)
│ ├── schemas.py
│ └── services/
│ ├── scopes.py # kapsam CRUD + declare (F4 provenance guard)
│ ├── facilities.py # resmî tesis CRUD
│ ├── organizations.py # kurum kataloğu CRUD (kendi tenant + sistem-geneli)
│ ├── meters.py # ölçüm noktası + sayaç eşleme (EXCLUDE)
│ ├── inverter_backfill.py # inverter_type öneri/uygula
│ ├── groups.py # grup + üye + evaluate (FOR UPDATE + tek-tx)
│ ├── eligibility.py # SAF kural motoru (R001–R013)
│ ├── limits.py # bedelli limit ledger (salt-append)
│ ├── resolver.py # MeasurementResolver — saatlik fact üretimi (PR-5)
│ ├── hourly_energy.py # fact okuma + on-demand rebuild (PR-5)
│ ├── facts_reader.py # fact → en-iyi-kaynak saat serisi (K2; PR-6)
│ ├── calculation_runs.py # run yaşam döngüsü + pencere orkestrasyonu (PR-6)
│ ├── calculators/ # dispatch + 5 hesap varyantı (PR-6)
│ ├── limit_allocation.py # Md.7(3) limit dağıtımı + ledger yazımı (PR-6)
│ ├── virtual_meters.py # sanal sayaç satır üretimi — Md.10 (PR-6)
│ ├── amount_engine.py # tutar motoru — Md.11-12 (PR-6)
│ ├── correction_engine.py # Md.15 düzeltme + cascade (PR-6)
│ ├── tariff_engine.py # regüle tarife fiyat çözümleyici (PR-6'da aktif)
│ └── _integrity.py # IntegrityError → HTTP ayrıştırma (F3 helper)
└── regulated_tariffs/ # ULUSAL veri (superadmin)
├── router.py # /api/v1/admin/regulated-tariffs/*
├── schemas.py
└── service.py # onay state-machine + CSV import + immutability
Bu ayrımın gerekçesi: mahsuplaşma her firmanın kendi verisidir (tenant izolasyonu), regüle tarife ise tüm firmalar için aynı olan ulusal veridir (tenant izolasyonu uygulanmaz). İki farklı veri sahipliği → iki farklı paket.
İki yüzey RBAC
| Yüzey | Yetki | Kim erişir? |
|---|---|---|
| Mahsuplaşma — okuma | read:netting | admin + operatör + görüntüleyici |
| Mahsuplaşma — yazma | manage:netting | yalnız admin |
| Rapor import (dormant) | import:settlement_reports | (PR-2+ reconciliation router'ı kullanacak) |
| Regüle tarife (okuma+yazma) | require_superuser | süper yönetici (ayrı izin yok) |
İzinler 0091_backfill_netting_permissions migration'ı ile dormant olarak
DB'ye yazıldı (PR-1). Bu sıralama önemli: izinler PR-1'de girmezse, PR-2..PR-8
router'ları require_permissions(...) ile açıldığında herkese 403 kalırdı.
assert_permissions_wired (non-production boot) router-izin kablolamasını
doğrular.
Tenant izolasyonu servis katmanında zorunludur: her servis fonksiyonu
tenant_id alır ve sorguyu inline filtreler; router yalnız
current_user.tenant_id geçirir. Path {id} ile erişilen kayıt yok veya
cross-tenant ise 404 döner (403 değil — enumeration koruması). BODY FK
hedefleri (scope/facility/kurum vb.) tenant-scoped ön-doğrulanır:
görünmez referans → 422 (IntegrityError'a düşmeden).
Uygunluk motoru (EligibilityRuleEngine)
Motor (services/eligibility.py) SAF'tır (pure): girdisi bir
GroupEvaluationInput (grup + üyeler + tesisler + ana-sayaç rolleri + limit
hesabı kümesi); çıktısı bir RuleResult listesi + agrega eligibility_status.
DB yazma motorda yapılmaz — services/groups.evaluate_group sonuçları tek
transaction'da INSERT eder ve agrega status'u gruba yazar.
Capability-gating deseni
13 kuralın (R001–R013) hepsi register edilir, ancak bazıları gerekli veri
kaynağı henüz bağlanmadığı için not_checked döner. Kritik olan: kuralın
sözleşmesi (kod + severity) değişmez — kural, ilgili faz geldikçe PASS/FAIL
üretmeye başlar. Örneğin R007 PR-4'te aktifleşti; girdi olarak
limit_account_facility_years kümesi eklenince statik not_checked'ten gerçek
PASS/FAIL'e geçti.
Kritik invariant
eligibility_status='valid' YALNIZCA:
- hiç blocking-FAIL yok, VE
- hiç blocking-
not_checkedyok, VE - en az bir anlamlı (substantif) blocking kural fiilen değerlendirildi (pass/fail).
Öncelik: blocked > not_checked > valid_with_warnings > valid.
Üçüncü koşul (M1 fix) önemlidir: "blocking-fail yokluğu" ile "anlamlı
geçerlilik" aynı şey değildir. Üyesiz grup / tüm blocking'ler not_applicable
senaryosunda sonuç valid değil, not_checked olur. R013 bir agregat/
türev kuraldır (önceki blocking-fail'lerin türevi); pass dönmesi tek başına
blocking_meaningful'ı tetiklemez (aksi halde üyesiz grup R013-pass ile
yanlışlıkla valid olurdu).
Severity tek-kaynak
Her kural kodunun severity'si tek bir module-sabitinde (_RULE_SEVERITY)
tutulur; bir kuralın severity'si bu harita değişmeden değiştirilemez (tek
nokta — drift önleme). Alt kodlar (R003M ana-sayaç, R00T tarife, R00L limit) da
burada tanımlıdır.
Tarife çözümleyici (tariff_engine) — PR-6'da aktif
services/tariff_engine.py PR-4'te tek-fiyat lookup iskeleti olarak girdi;
PR-6 ile aktifleşti: tutar motoru (amount_engine — Md.11 üç dallı surplus
- SKB + SKTT + mesken) ve R00T eligibility kuralı (cari dönem × abone
grubu için approved fiyat var mı) bu modülü kullanır. Fiyat çözümleri hesap
penceresi boyunca dönem bazlı cache'lenir (aynı dönemde tekrar sorgu yok);
approved set kimliği
input_hash'e girer (tarife onayı/değişimi sonrası corrected beat no-op kalmaz — H2).
Çözümleme, bir dönem için en son approved price_set üzerinden yapılır:
WHERE billing_period=:p AND approval_status='approved' ORDER BY approved_at DESC, id DESC LIMIT 1. Ulusal veri olduğu için tenant filtresi yoktur.
Dönüş sözleşmesi: PriceResolution(price=..., ...) (eşleşme) veya
PriceResolution(price=None, reason=...) (anlamlı sebep: approved set yok /
satır yok / belirsiz çoklu eşleşme). Sessiz "ilk-al" yasaktır — observability
korunur.
IntegrityError ayrıştırma (F3 helper)
services/_integrity.py, DB'den gelen IntegrityError'ları anlamlı HTTP
yanıtlarına çevirir. Her servis, kendi constraint'lerine özel bir
IntegrityErrorContext tanımlar:
- UNIQUE ihlali → 409 (constraint adına göre net mesaj),
- EXCLUDE ihlali → 409 (dönem çakışması / tek-grup),
- FK ihlali → 422 veya "eş-zamanlı silinmiş olabilir",
- CHECK / NOT NULL → 422.
Amaç: veri sızıntısı olmadan, kullanıcıya eyleme dönüştürülebilir hata mesajı vermek. BODY FK hedefleri mümkün olduğunca ön-doğrulanır (IntegrityError'a düşmeden), böylece cross-tenant referanslar sessizce IntegrityError üretmez.
State-machine'ler
Modülde üç ayrı durum makinesi vardır:
1. group_status (grup yaşam döngüsü)
customer_defined → ek1_submitted → grid_operator_confirmed → lum_reported
(her durumdan → inactive; inactive terminal)
services/groups.py'deki _GROUP_STATUS_TRANSITIONS haritası geçerli geçişleri
tanımlar. PATCH'te group_status değişiyorsa guard uygulanır (geri düşme/
atlama → 409); değişmiyorsa guard atlanır (idempotent PATCH).
2. approval_status (regüle tarife paketi)
draft → approved → archived
draft→approved yalnız approve action'ıyla; approved→archived yalnız PATCH
ile. Onay sonrası fiyatlar immutable (INSERT/CSV/PATCH → 409). Silme yalnız
draft.
3. F4 provenance guard (declaration_source)
Beyan kaynağı bir güçlülük sıralaması taşır:
customer_declaration < official_document < lum_report/gts_report (son ikisi
eş-düzey). Daha güçlü bir kaynakla beyan edilmiş kapsam, daha zayıf bir kaynakla
tekrar-declare edilemez (409). Aynı grup üyesi ekleme akışında da beyan
kimliği (declared_by_user_id) her zaman token kullanıcısıdır (spoof koruması).
Evaluate transaction modeli
groups.evaluate_group yarış-güvenli bir desen izler:
- FOR UPDATE — grup satırı
SELECT ... FOR UPDATE(tenant-scoped WHERE) ile kilitlenir → aynı grup için eşzamanlıevaluate'ler serileşir. - Girdi toplama (N+1 yasak) — üyeler + facility'ler (
id.in_()) + ana ölçüm rolleri (DISTINCT) + limit hesabı kümesi toplu sorgularla toplanır. TR-yerel bugün DB round-trip'i kilitten önce alınır (kilit penceresi kısa). - Tek-tx append — motor sonuçları
mahsuplasma_eligibility_results'a APPEND INSERT edilir (satır-satır autocommit yasak).evaluated_atDB DEFAULTnow()'dur ve PostgreSQL'de transaction-sabittir → aynı run'ın N satırı birebir eşitevaluated_atalır. Bu, ayrı birrun_idkolonu olmadan "en son run" ayrımının temelidir. - Agrega status UPDATE —
eligibility_status+eligibility_notesaynı transaction'da gruba yazılır → tek commit.
"En son run" sorgusu: evaluated_at = max(evaluated_at WHERE group_id=?)
(ix_mer_group_evaluated composite index karşılar).
Saatlik enerji fact üretimi (MeasurementResolver, PR-5)
services/resolver.py, mahsuplaşma saatlik enerji fact üretiminin çekirdeğidir.
Bir SAAT bucket'ı için tenant'ın dönem-geçerli, faturaya-esas, tesise-bağlı ölçüm
atamalarını (subregion_meter_assignments) çözer ve fatura-kalitesinde (Decimal —
float yasak) facility_hourly_energy hypertable'ına ON CONFLICT (hour, facility_id, role, source) DO UPDATE ile UPSERT eder. Okuma/rebuild servisi
services/hourly_energy.py; Celery görevleri tasks/mahsuplasma_tasks.py.
subregion_meter_assignments (dönem-geçerli atamalar)
│
▼
MeasurementResolver ── her ATAMA için ──► Kaynak öncelik zinciri (8 basamak)
│ │
│ ilk KULLANILABİLİR kaynak
│ │
▼ ▼
scale/role eşleme (APP-SIDE tek yer) _HourlyReading (ham, ölçeklenmemiş)
│
▼
facility_hourly_energy ── ON CONFLICT (hour, facility_id, role, source) UPSERT
Kaynak öncelik zinciri (8 basamak)
Her atama için en yüksek öncelikli kullanılabilir kaynak seçilir. Kaynağı
olmayan basamak (SourceUnavailable) sessizce bir alt basamağa düşer.
| # | Kaynak | Durum | Açıklama |
|---|---|---|---|
| 1 | lum_official_report | 🔩 İskelet (PR-8) | Resmî LÜM verisi — import mekanizması yok |
| 2 | gts_report | 🔩 İskelet (PR-8) | GTS raporu |
| 3 | pys_import | 🔩 İskelet (PR-8) | PYS / uzlaştırma içe aktarımı |
| 4 | osos_official_meter | ✅ Aktif | osos_meter_readings HAM interval SUM |
| 5 | zeus_meter | ✅ Aktif | energy_hourly CAGG + LAG delta |
| 6 | customer_declared | 🔩 İskelet | Müşteri beyan tablosu kod tabanında yok |
| 7 | profiled_estimate | 🔩 İskelet (PR-6+) | Md.9(2)(a) profilleme motoru yok |
| 8 | missing | ✅ Fallback | Hiçbir gerçek ölçüm yok → açık missing fact yazılır |
Kaynak-başına ayrı satır — veri kaybı yok: aynı saat/tesis/rol için farklı
atamalar farklı kaynak üretebilir (biri OSOS, biri Zeus). Bunlar PK'nin source
bileşeni sayesinde ayrı satır olarak yaşar. Hangi kaynağın kullanılacağı
(öncelik/toplama) hesap/okuma katmanının işidir (PR-6 calculator) — resolver
hiçbir veriyi düşürmez. missing fallback, sessiz boşluk yerine forensic/operatör
görünürlüğü için açık bir satır yazar.
Semantik dönüşümleri (energy_semantics)
energy_semantics, ham ölçümün saatlik kWh'a nasıl çevrileceğini belirler. DB
CHECK 4 değeri kabul eder; daily_reset_delta ayrı enum değildir —
cumulative_counter'ın kanal-bazlı alt-durumudur.
| Semantik | Dönüşüm | Not |
|---|---|---|
cumulative_counter | Saatlik kWh = LAST(counter) − LAG(counter) | Negatif delta (sayaç reset/rollover) → 0'a clamp + suspect |
daily_reset_delta (alt-durum) | İnvertör energy_daily_* kanalları | TR 00:00 gün-başı reset beklenen → suspect DEĞİL; gün-içi = LAG delta |
interval_delta | Saatlik kWh = SUM(delta) | Yalnız OSOS HAM yolunda doğru. Zeus'ta DESTEKLENMEZ (aşağıya bakın) |
power_integrated | Saatlik kWh = ∫P dt ≈ avg_power × 1h | Yalnız enerji kanalı yoksa; kalite bir kademe düşer → en fazla partial |
unknown | — | Hesaba girmez (fact yazılmaz; atama atlanır) |
interval_delta Zeus yolunda kısıtı (M4)Zeus'un energy_hourly CAGG'i enerji kolonlarını LAST() ile (kümülatif)
saklar, SUM(delta) değil. Bir atama Zeus cihazına interval_delta derse gerçek
interval kanalı CAGG'de kaybolur → resolver sessizce yanlış kWh üretmek yerine
WARN log basar ve SourceUnavailable fırlatır (zincir bir alt basamağa düşer — açık
eksik). OSOS HAM yolu ETKİLENMEZ (orada tablodan SUM doğrudur). Zeus'ta
interval kanalı gerekirse cumulative_counter'a dönüştürülmelidir (cihaz zaten
kümülatif sayar).
Ölçek / rol eşleme — tek yer: scale_factor + meter_role → (fact.role, hangi
kWh kolonuna yazılır) dönüşümü yalnızca _map_assignment_to_fact
fonksiyonundadır (çift-ölçekleme yasak). Kanal-adı → CAGG-kolon eşlemesi sabit
whitelist sözlüğüdür (istemci kanal-adı asla SQL'e gömülmez → SQL injection
imkânsız). sign_convention bu fazda uygulanmaz (yön kanal seçiminden gelir;
net-ayrım PR-6 calculator'ında).
İç tüketim (Md.9(1)): meter_role='internal' atamalar tüketim olarak değil,
ayrı internal_consumption_kwh kolonuna yazılır (fact rolü üretim rolüyle tutulur;
grup tüketimine dahil kararı PR-6 calculator'ında).
Veri kalitesi modeli
Her fact satırı data_quality + completeness_pct taşır:
- Tamlık (
completeness_pct): Zeus içinsample_count / (3600 / poll_interval_s) × 100(tavan 100); OSOS için 100 (satır var) / 0 (yok). - Eşikler: ≥95 →
complete· 50–95 →partial·<50→partial+ WARN log (operatör düşük-tamlık saatini görür). - Özel durumlar: veri yok →
missing; sayaç reset →suspect; backfill/rebuild değişimi →corrected; profilleme (gelecek) →estimated_profiled.
Best-effort dayanıklılık (H-B)
Her atama kendi savepoint'inde (begin_nested) çözülür:
- Bir atama beklenmedik şekilde patlarsa (DB/veri hatası) o savepoint geri alınır +
WARN log (
assignment_id+facility_id+ hata sınıfı; hassas değer loglanmaz) ve diğer atamalar işlenmeye devam eder — tek atama görevi bozmaz. Patlayan atamanın o saati missing/fact-yok kalır (bir sonraki backfill telafi eder). SourceUnavailablenormal kontrol-akışıdır (zincir alt basamağa düşer) — savepoint hatası değil.- Sistemik hata (DB kopması / atama-yükleme sorgusu / batch-upsert) hâlâ RAISE
eder →
task_runs'ta FAIL görünür (sessiz stall alarmlanabilir). - Atlanan atama sayacı
assignments_failedbeat görev özetinde raporlanır. On-demand rebuild endpoint yalnızhours_processed+rows_upserteddöner.
Arka plan zamanlaması (Celery beat)
| Task | Cron | Ne yapar |
|---|---|---|
mahsuplasma.build_hourly_energy | 10 * * * * (her saat :10) | Önceki TR saatini işler (tüm tenant'lar). :10 = önceki saat bitti + Zeus CAGG materialize + OSOS saatlik sync yazma marjı |
mahsuplasma.build_hourly_backfill | 30 2 * * * (her gün 02:30) | Son 72 saati yeniden üretir; değişen satırlar corrected. Geç-gelen veri en geç ertesi gece kapanır |
Her iki task default queue'da çalışır; run_async (process-level shared loop)
deseniyle async çekirdeği çalıştırır; TaskRun kaydı task_run_signals ile otomatik
düşer. On-demand yol (wizard önizlemesi / manuel düzeltme) POST .../hourly-energy/rebuild endpoint'idir (tenant-scoped; aralık ≤ 7 gün).
Hesap çekirdeği (PR-6) — run yaşam döngüsü, calculator dispatch, düzeltme
Hesap çekirdeği (services/calculation_runs.py orkestrasyonu + yardımcı
motorlar) facility_hourly_energy fact'lerinden saatlik grup hesabı,
sanal sayaç satırları, tutar satırları ve aylık özet üretir
(migration 0098 tabloları). Tüm sonuç satırları bir run
(mahsuplasma_calculation_runs) kaydına bağlıdır (izlenebilirlik + düzeltme
zincirinin kökleri).
Kaynak-öncelik seçimi (facts_reader — K2 kararı)
facts_reader.read_group_hour_slices, fact tablosundan grup-toplam saat
dilimleri üretirken kaynak seçimini satır bazında yapar: her
(tesis × rol × saat) hücresi için en yüksek öncelikli mevcut kaynak
seçilir. Tek otorite MEASUREMENT_SOURCES tuple sırasıdır (models.py —
düşük index = yüksek öncelik; missing en düşük → gerçek veri her zaman
kazanır). Dönem/saat kilidi yoktur — aynı saatin farklı hücreleri farklı
kaynaktan gelebilir; saat-içi kaynak karışımı warning_flags['mixed_sources']
ile tesis bazında işaretlenir (forensic). Seçilen kaynağın kalitesi
partial/suspect ise saat statüsü partial'a taşınır. Kritik: input_hash
kaynak seçimini de içerir — aynı ham pencere farklı kaynak seçimiyle farklı
hash üretir (corrected beat no-op kalmaz).
Calculator dispatch (K1 — 5 varyant)
calculators/select_calculator(group) grup bayraklarından hesap varyantını
deterministik seçer (özel hüküm genel hükmü ezer):
| Öncelik | Bayrak | Calculator | Durum |
|---|---|---|---|
| 1 | has_special_m51_cedilla | m5_1_cedilla | 🔩 İskelet |
| 2 | has_special_m51_d | m5_1_d | 🔩 İskelet |
| 3 | has_osb_eb_case | osb_eb | 🔩 İskelet |
| 4 | is_mesken_group | residential_monthly | ✅ TAM (aylık resmî sonuç) |
| 5 | (varsayılan) | standard_hourly | ✅ TAM (Md.9(2)) |
İskelet varyantlarda orkestrasyon run'ı partial +
special_calc_not_implemented uyarısıyla kapatır; limit/VM/tutar satırı
üretmez (veri uydurma yok). standard_hourly SAF'tır: matched = min(P, T); surplus'un bedelli/SKB ayrımı limit_before'a göre yapılır;
Md.7(5) merkez kural — matched hiçbir zaman kırpılmaz, limit yalnız
surplus ayrımını belirler (limit bitince limit_exhausted_skb uyarı bayrağı).
Mesken grubunda saatlik satırlar compute_standard_hour(limit_before=None) ile
limit'siz üretilir + mesken_informational bayrağı; resmî sonuç
residential_monthly aylık çıktısıdır.
Tek dönem = tek TX (K3 sırası)
Pencere saat sınırlarına kırpılır, şimdiki saate clamp'lenir (H3(a) —
gelecek saatler hiç işlenmez; geleceğe missing_data satırı üretilmez) ve TR
fatura dönemlerine bölünür; her dönem-dilimi kendi transaction'ında koşar:
- Grup satırı
FOR UPDATE(hesap/evaluate serileşir) + etkilenenbedelli_limit_accountsid-sıralıFOR UPDATE(H1 — deadlock önleme; bakiye kilit altındabedelli_limit_balancesVIEW'inden okunur). - Yeni run INSERT (
running;parent_run_id= süpersede edilen run). - Eski hourly satırları
is_current=falseflip (pencere bazlı); VM/tutar satırları run bazlı toplu flip (WHERE calculation_run_id = :old_run). - Yeni satırlar INSERT (
is_current=true) — flip önce, insert sonra (partial-unique ihlali olmasın). - Ledger: önce
correction_reversal, sonra yeni kullanım (aşağıda). - Eski run'lar
is_current=false; yeni runsuccess/partial→ COMMIT.
Pencere genişletmesi (fixpoint): pencereyle kesişen current run'ların saat kapsamları pencereye katılır (dönem sınırına kırpılarak; fixpoint yakınsamazsa fail-loud). Böylece run bazlı flip ve run-net reversal hiçbir satırı yetim bırakmaz. Pencerenin sonundaki hiç-gerçek-verisi-olmayan kuyruk saatler işlenmez (H3(b) — eski run kapsamındaki saatler istisna).
NO-OP (K4): pencereyi tam örten tek current run varsa ve input_hash
değişmemişse yeni run açılmaz (correction modu hariç) — corrected beat'in
boş run üretmemesinin temelidir.
Ledger modeli — run-net reversal (EFEKTİF KULLANIM, C1 fix)
Bir run süpersede edilirken ledger'a hesap-başına tek agregat
correction_reversal satırı yazılır. Tersine çevrilen toplam eski run'ın
efektif kullanımıdır: SUM(amount_kwh WHERE calculation_run_id = eski AND movement_type != 'correction_reversal') — eski run'ın kendi reversal
satırları (bir önceki run'ı nötrleyen pozitif hareketler) toplama girmez;
yalnız o run'ın eklediği kullanım (doğal hareketler + correction_reapply)
tersine çevrilir. Aksi halde zincirleme süpersede'de önceki run'ın nötrlenmiş
kullanımı bakiyeye tekrar enjekte olurdu. Cebir (initial=100):
A: kullanım -10 → bakiye 90
B sup A: rev(A) +10, kullanım -12 → bakiye 88
C sup B: EFEKTİF(B) = -12 (kendi rev'i hariç) → rev +12, kullanım -11 → 89 ✓
D sup C: EFEKTİF(C) = -11 → rev +11, kullanım -9 → 91 ✓
Normal modda yeni kullanım saat bazlı doğal hareketlerle
(mahsuplasan_consumption / bedelli_ihtiyac_fazlasi), correction modunda
hesap-başına tek correction_reapply agregatıyla yazılır. Yuvarlama tek
noktadadır: hourly NUMERIC(16,6) → ledger NUMERIC(16,3) dönüşümü yalnız
limit_allocation.quantize_ledger_kwh (ROUND_HALF_EVEN); dağıtım payları
largest-share kalan-düzeltmesiyle toplam kullanıma birebir eşitlenir (bakiye
drift'i yok). limit_ratio_basis NULL → 'remaining' mirası serviste
çözülür (DB default bilinçli yok).
CorrectionEngine — Md.15 düzeltme + cascade
correction_engine.run_correction verilen dönemi correction modunda yeniden
hesaplar, ardından Zeus'ta hesaplanmış tüm takip eden dönemleri kronolojik
artan sırayla, dönem başına ayrı TX + ayrı run
(triggered_by='correction_cascade') cascade eder (Md.15(4) — yıl sınırında
durmaz; üst sınır = en son hesaplanmış dönem). Dönemde current aylık özet varsa
düzeltme sonrası aynı source ile yeniden koşulur (bayat mesken sonucu
kalmaz; aylık koşum hatası zinciri bozmaz → warning).
- Cascade ortada durursa: DB'ye
status='failed'marker run bırakılır (fact satırı taşımaz — süpersede zincirini etkilemez) + structlog error; yanıtcascade_incomplete=true+failed_periodtaşır. Retry aynı endpoint'le kaldığı dönemden devam eder (tamamlanan dönemler idempotent süpersede olur). - DUY 133/5: dönem bugünden (TR) 12 aydan eskiyse resmî düzeltme olamaz —
istek reddedilmez, yanıt
warningstaşır. - M3/M4 — üyelik geriye kapatma:
groups.update_memberüyelik dönemini geçmişe kapattığındahandle_member_retroactive_closeetkilenen ilk dönemden itibaren cascade'i kendiliğinden koşar (üyelik kümesiinput_hash'e dahildir → değişiklik deterministik yeni run üretir); oluşan uyarılar PATCH yanıtının yeniwarningsalanında döner. - Sıkıştırılmış chunk (500+ gün): düzeltme penceresi sıkıştırılmış
TimescaleDB chunk'ına dokunursa kod otomatik decompress yapmaz — 409 +
runbook yönlendirmesi (
docs/runbook/0098_mahsuplasma_calculation_core.md).
AmountEngine (Md.11-12) ve VirtualMeterEngine (Md.10)
amount_engine plan §10 birebir: tedarikçi ödemesi (matched'in oransal
dağıtımı — tüketim tesisi bazında), üretici bedelli surplus ödemesi üç
dallı (28/11 mesken AG → yüksek kademe; 28/12 aynı kademeli AG →
kademe-miktar dağılımlı iki fiyat, kademe durumu üretilemiyorsa muhafazakâr
yüksek-kademe fallback + warning; 28/13 karışık → en düşük ilgili tarife), SKB
miktar KPI + tahmini sistem kullanım bedeli (bölge bazında), SKTT (Md.12) ve
mesken aylık tutarı. Fiyat çözülemezse tutar uydurulmaz: supplier/producer
satırı hiç yazılmaz; SKB/risk satırı quantity-only kalır (unit_price/amount
NULL) — her iki durumda warning. virtual_meters satırları
uq_vm_hourly_dims boyutlarında (ts × grup × run × tip × şebeke işletmecisi ×
kaynak türü) AGGREGATE yazar; facility kolonları yalnız tekil-tesis
izlenebilirlik anotasyonudur. Kanonik reason_code üreticisi tek noktadır
(standard_matched · standard_bedelli_surplus · limit_exceeded ·
regulatory_violation · missing_consumption_data ·
group_conditions_not_met · mesken_monthly · osb_eb).
Celery beat kadansı (PR-6 — 6 yeni görev)
| Task | Cron | Gerekçe |
|---|---|---|
mahsuplasma.evaluate_groups | 0 */6 * * * | Aktif grupların eligibility güncellemesi; grup başına bağımsız commit (best-effort — tek grup görevi bozmaz, groups_failed sayacı) |
mahsuplasma.calc_prev_hour_preliminary | 15 * * * * | Önceki TR saatinin ilk hesabı — build_hourly_energy :10'dan sonra (fact taze) |
mahsuplasma.calc_prev_hour_corrected | 45 * * * * | Aynı saatin geç-veri hesabı — input_hash değişmediyse NO-OP (yeni run açılmaz) |
mahsuplasma.calc_daily_backfill | 10 3 * * * | Son 7 günü yeniden hesap — build_hourly_backfill 02:30'dan sonra; süpersede + ledger reversal zinciri |
mahsuplasma.monthly_close | 30 1 1 * * | Geçen ayın Zeus-local ön-kapanışı (mesken resmî aylık sonuç + standart roll-up; resmî-sonrası ikinci kapanış PR-8) |
mahsuplasma.tariff_missing_scan | 0 7 * * * | Cari + gelecek dönem approved tarife eksiği taraması — eksikler mahsuplasma_tariff_missing WARN log'u (alarm yüzeyi) |
Hesap beat'lerinde her grup kendi transaction zincirinde koşar
(calculate_group_window dönem başına commit eder); tek bozuk grup görevi
bozmaz (WARN + groups_failed), sistemik hata RAISE eder (task_runs FAIL).
Tümü default queue'da; TaskRun kaydı task_run_signals ile otomatik düşer.
EPİAŞ Şeffaflık entegrasyonu (PR-8a) — PTF/SMF fiyat foundation
PR-8a, EPİAŞ Şeffaflık Platformu'ndan ulusal piyasa fiyatlarını (PTF/SMF, gaz) çeken fiyat foundation katmanını kurar. Kritik ürün kuralı: PTF/SMF piyasa görünümü/advisory'dir; mahsuplaşma tutarının ANA girdisi DEĞİLDİR (Yönetmelik Md.4-l — tutar regüle EPDK tarifesinden hesaplanır; tarife fiyatı ≠ piyasa fiyatı). Fiyatlar ulusaldır → tenant filtresi yok.
Feature paketi
backend/app/features/epias_transparency/
├── client.py # EPİAŞ HTTP client — dayanıklılık katmanı (aşağıda)
├── api_portal.py # whitelist_status durum makinesi (9 adım)
├── service.py # hesap CRUD (Fernet) + test-call + fiyat upsert/okuma
├── router.py # /api/energy-market/prices + /api/v1/admin/epias-transparency/*
├── schemas.py
└── models.py
Bu paket regulated_tariffs gibi ULUSAL veri sahipliğindedir (tenant
izolasyonu uygulanmaz — fiyat tüm firmalar için aynıdır). API Portal hesap
kataloğu ise sistem-geneli VEYA tenant-scoped olabilir (tenant_id NULL =
sistem-geneli hesap).
Client dayanıklılık katmanı (client.py)
EPİAŞ CAS/TGT tabanlı kimlik doğrulaması dış servise bağımlıdır; client aşağıdaki dayanıklılık desenlerini uygular:
| Desen | Davranış |
|---|---|
| TGT cache | TGT (Ticket Granting Ticket) 2 saat cache'lenir (her istekte yeniden login yok) |
| 406 → yenile + tek-retry | EPİAŞ TGT süresi dolduğunda 406 döner → TGT bir kez yenilenir ve istek tek sefer yeniden denenir (sonsuz döngü yok) |
| Circuit breaker | Ardışık hata eşiği aşılınca devre açılır — geçici olarak istek atılmaz (dış servisi ve kendimizi korur) |
| Rate limit | İstek hızı sınırlanır (EPİAŞ kota ihlali önlenir) |
| Fail-open | Fiyat verisi çekilemezse mahsuplaşma akışı bloklanmaz — fiyat advisory'dir; eksikse alarm üretilir, hesap kendi (tarife) girdisiyle devam eder |
Kimlik hataları (TGT/CAS/406 kalıcı) → epias_api_auth_failed; veri çekişi
kalıcı hatası → epias_api_fetch_failed (bkz. sistem alarmları).
market_prices hypertable
Fiyatlar market_prices hypertable'ında saklanır:
- PK:
(ts, market, price_status)— aynı saatin farklı olgunluk seviyeleri (tentative/final/corrected) ayrı satır olarak yaşar (denetim izi). - Kapsam: PTF, SMF, AOF + gaz fiyatları; her satır bir
market+ değer +price_status(tentative/final/corrected/unknown). - Compression: 180 gün.
- RETENTION YOK — bilinçli: fiyat verisi mutabakat/itiraz kaynağıdır; DROP politikası yasal-mutabakat kanıtını yok eder (mahsuplaşma fact'leriyle aynı ilke). Retention eklenmesi runbook'ta işaretlenir.
Upsert deseni: aynı (ts, market, price_status) için gelen değer güncellenir;
final bir tentative'i silmez (ayrı satır). Kesin/düzeltilmiş değerler
sonradan gelen çekişlerde eklenir.
Dark-launch — EPIAS_ENABLED (default False)
Tüm çekiş yolu bir güvenlik anahtarı arkasındadır: EPIAS_ENABLED=False
(varsayılan) iken hiçbir beat canlı EPİAŞ çekişi yapmaz (görevler no-op /
skip). Anahtar yalnız IP beyaz-liste onayı + hesap aktivasyonu sonrası
true yapılır (plan §16 açılış koşulu). Böylece kod canlıya çıkar ama gerçek
dış istek, EPİAŞ tarafı hazır olana kadar atılmaz (dark-launch). Yeni env
değişkeni: EPIAS_ENABLED.
API Portal durum makinesi (api_portal.py)
epias_api_portal_accounts.whitelist_status 9 adımlı IP beyaz-liste
sürecini takip eder: draft → specification_pending → signature_pending → application_submitted → ip_registration_pending → subscription_pending → verification_pending → active (+ suspended). Geçişler ileri doğrudur;
geçersiz geçiş → 409. POST /accounts/{id}/test-call kimlik bilgileriyle
küçük bir bağlantı denemesi yapar ve asla 500 üretmez (sonuç: başarılı /
kimlik hatası / erişim engeli / zaman aşımı).
Kimlik bilgileri (portal_password, subscription_key) DB'de Fernet
şifreli saklanır; API yanıtlarında maskelenir. Şifreleme yapılandırılmamışsa
hesap kaydetme/güncelleme 503 ile reddedilir (açık metin saklanmaz).
EPİAŞ beat kadansı (PR-8a — 3 yeni görev)
Tümü TR-lokal zamanlı, default queue'da; EPIAS_ENABLED=False iken canlı
çekiş yapmaz.
| Task | Cron (TR) | Gerekçe / davranış |
|---|---|---|
epias.pull_ptf_tentative | 35 13 * * * (13:35) | K.PTF / kesinleşmemiş PTF → price_status='tentative'. Gün öncesi sonuçları yayınlandıktan sonra ilk çekiş |
epias.pull_ptf_final | 5 14 * * * (14:05) | Kesin PTF + SMF. SMF penceresi 4 saat geriden çekilir — SMF ~4 saat gecikmeli kesinleşir → price_status='final' |
epias.pull_market_backfill | 15 2 * * * (02:15) | Son 7 gün PTF+SMF düzeltme taraması → EPİAŞ düzeltmesi varsa price_status='corrected' yeni satır. Geç-gelen düzeltmeler kapanır |
Beat'ler run_async (process-level shared loop) deseniyle async client'ı
çalıştırır; TaskRun kaydı otomatik düşer. Çekiş kalıcı başarısızsa alarm
üreticileri (aktif mahsuplaşma grubu olan tenant'lara) tetikler.
Ana sayaç rolü (R003M) sızıntı düzeltmesi
R003M girdisi olan main_meter_roles, grubun kendi ana sayacını
yansıtmalıdır. İki meşru bağ vardır: (a) assignment.facility_id dolu ve grup
üyelerinden biri; (b) assignment.facility_id NULL ve subregion_id grup
üyelerinin subregion'larında (subregion-scoped ana sayaç; EXCLUDE ile tek).
Eski OR subregion_id IN (...) koşulu, aynı subregion'daki grup-dışı bir
tesise ait dolu-facility_id atamalarını kümeye sızdırıyordu (R003M yanlış PASS).
Subregion dalı artık yalnız facility_id IS NULL atamaları kapsar.
İlgili sayfalar
- Veri Modeli — mahsuplaşma tabloları
- Kullanım Kılavuzu — Uygunluk Kuralları
- Kullanım Kılavuzu — Sayaç Atama ve Kanal Eşleme
- Kullanım Kılavuzu — Hesap Çekirdeği
- Kullanım Kılavuzu — EPİAŞ Piyasa Fiyatları
- API Referansı — Mahsuplaşma / Enerji Piyasası
- Operasyonel runbook:
docs/runbook/0098_mahsuplasma_calculation_core.md·docs/runbook/0100_0101_mahsuplasma_system_alarms.md·docs/runbook/0102_epias_transparency.md