Ana içeriğe geç

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üzeyYetkiKim erişir?
Mahsuplaşma — okumaread:nettingadmin + operatör + görüntüleyici
Mahsuplaşma — yazmamanage:nettingyalnız admin
Rapor import (dormant)import:settlement_reports(PR-2+ reconciliation router'ı kullanacak)
Regüle tarife (okuma+yazma)require_superusersü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ılmazservices/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:

  1. hiç blocking-FAIL yok, VE
  2. hiç blocking-not_checked yok, VE
  3. 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:

  1. FOR UPDATE — grup satırı SELECT ... FOR UPDATE (tenant-scoped WHERE) ile kilitlenir → aynı grup için eşzamanlı evaluate'ler serileşir.
  2. 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).
  3. Tek-tx append — motor sonuçları mahsuplasma_eligibility_results'a APPEND INSERT edilir (satır-satır autocommit yasak). evaluated_at DB DEFAULT now()'dur ve PostgreSQL'de transaction-sabittir → aynı run'ın N satırı birebir eşit evaluated_at alır. Bu, ayrı bir run_id kolonu olmadan "en son run" ayrımının temelidir.
  4. Agrega status UPDATEeligibility_status + eligibility_notes aynı 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.

#KaynakDurumAçıklama
1lum_official_report🔩 İskelet (PR-8)Resmî LÜM verisi — import mekanizması yok
2gts_report🔩 İskelet (PR-8)GTS raporu
3pys_import🔩 İskelet (PR-8)PYS / uzlaştırma içe aktarımı
4osos_official_meterAktifosos_meter_readings HAM interval SUM
5zeus_meterAktifenergy_hourly CAGG + LAG delta
6customer_declared🔩 İskeletMüşteri beyan tablosu kod tabanında yok
7profiled_estimate🔩 İskelet (PR-6+)Md.9(2)(a) profilleme motoru yok
8missing✅ FallbackHiç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ğildircumulative_counter'ın kanal-bazlı alt-durumudur.

SemantikDönüşümNot
cumulative_counterSaatlik 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_deltaSaatlik kWh = SUM(delta)Yalnız OSOS HAM yolunda doğru. Zeus'ta DESTEKLENMEZ (aşağıya bakın)
power_integratedSaatlik kWh = ∫P dt ≈ avg_power × 1hYalnız enerji kanalı yoksa; kalite bir kademe düşer → en fazla partial
unknownHesaba 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çin sample_count / (3600 / poll_interval_s) × 100 (tavan 100); OSOS için 100 (satır var) / 0 (yok).
  • Eşikler: ≥95 → complete · 50–95 → partial · &lt;50partial + 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).
  • SourceUnavailable normal 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_failed beat görev özetinde raporlanır. On-demand rebuild endpoint yalnız hours_processed + rows_upserted döner.

Arka plan zamanlaması (Celery beat)

TaskCronNe yapar
mahsuplasma.build_hourly_energy10 * * * * (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_backfill30 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):

ÖncelikBayrakCalculatorDurum
1has_special_m51_cedillam5_1_cedilla🔩 İskelet
2has_special_m51_dm5_1_d🔩 İskelet
3has_osb_eb_caseosb_eb🔩 İskelet
4is_mesken_groupresidential_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 kuralmatched 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:

  1. Grup satırı FOR UPDATE (hesap/evaluate serileşir) + etkilenen bedelli_limit_accounts id-sıralı FOR UPDATE (H1 — deadlock önleme; bakiye kilit altında bedelli_limit_balances VIEW'inden okunur).
  2. Yeni run INSERT (running; parent_run_id = süpersede edilen run).
  3. Eski hourly satırları is_current=false flip (pencere bazlı); VM/tutar satırları run bazlı toplu flip (WHERE calculation_run_id = :old_run).
  4. Yeni satırlar INSERT (is_current=true) — flip önce, insert sonra (partial-unique ihlali olmasın).
  5. Ledger: önce correction_reversal, sonra yeni kullanım (aşağıda).
  6. Eski run'lar is_current=false; yeni run success/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ıt cascade_incomplete=true + failed_period taşı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 warnings taşır.
  • M3/M4 — üyelik geriye kapatma: groups.update_member üyelik dönemini geçmişe kapattığında handle_member_retroactive_close etkilenen ilk dönemden itibaren cascade'i kendiliğinden koşar (üyelik kümesi input_hash'e dahildir → değişiklik deterministik yeni run üretir); oluşan uyarılar PATCH yanıtının yeni warnings alanı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)

TaskCronGerekçe
mahsuplasma.evaluate_groups0 */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_preliminary15 * * * *Önceki TR saatinin ilk hesabı — build_hourly_energy :10'dan sonra (fact taze)
mahsuplasma.calc_prev_hour_corrected45 * * * *Aynı saatin geç-veri hesabı — input_hash değişmediyse NO-OP (yeni run açılmaz)
mahsuplasma.calc_daily_backfill10 3 * * *Son 7 günü yeniden hesap — build_hourly_backfill 02:30'dan sonra; süpersede + ledger reversal zinciri
mahsuplasma.monthly_close30 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_scan0 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:

DesenDavranış
TGT cacheTGT (Ticket Granting Ticket) 2 saat cache'lenir (her istekte yeniden login yok)
406 → yenile + tek-retryEPİAŞ TGT süresi dolduğunda 406 döner → TGT bir kez yenilenir ve istek tek sefer yeniden denenir (sonsuz döngü yok)
Circuit breakerArdışı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-openFiyat 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.

TaskCron (TR)Gerekçe / davranış
epias.pull_ptf_tentative35 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_final5 14 * * * (14:05)Kesin PTF + SMF. SMF penceresi 4 saat geriden çekilir — SMF ~4 saat gecikmeli kesinleşir → price_status='final'
epias.pull_market_backfill15 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