Takvim ve randevu verisi modellemek: tekrar kuralları ve istisnalar
Tekrar eden randevuyu RRULE, yerel duvar saati ve orijinal başlangıca bağlı istisnalarla saklamak; bu ve sonrakiler için seri bölme ve PostgreSQL EXCLUDE ile çakışma kontrolü.
Randevu tablosu ilk gün masum görünür: başlangıç, bitiş, kaynak. Sorun ilk tekrar eden kayıtla başlar: "her perşembe 10:00, bayram haftası hariç, kasımdan itibaren 11:00". Bu not tek bir soruya bakıyor: tekrar eden randevuyu nasıl saklamalı ki istisnalar veriyi dağıtmasın?
İki yol var, ikisi de tek başına eksik
Her tekrarı satır olarak üretmek. Sorgular basit, çakışma kontrolü veritabanında yapılabilir. Ama sonu olmayan bir seriyi üretemezsiniz, kural değişince yüzlerce satırı güncellersiniz, "bundan sonrakileri değiştir" işlemi toplu silme ve yeniden yazmaya döner.
Yalnızca kuralı saklamak. Kayıt küçük, değişiklik tek satır. Ama "perşembe 14:00'te hangi personel boş" sorusu artık SQL ile cevaplanamaz; her okumada kuralı genişletmeniz gerekir.
Pratikte işe yarayan karma: kural doğruluk kaynağıdır, sınırlı bir pencere (örneğin önümüzdeki birkaç ay) sorgu ve çakışma için satıra dökülür. Pencere bir zamanlanmış görevle ileri kaydırılır.
Kural dilini yeniden icat etmeyin
RFC 5545 (iCalendar) bu işi zaten çözmüş. FREQ=WEEKLY;BYDAY=TH gibi bir RRULE, kendi uyduracağınız tekrar_tipi + gun_maskesi kolonlarından daha ifade gücü yüksek ve her takvim istemcisine dışa aktarılabilir.
DTSTART;TZID=Europe/Istanbul:20260917T100000
RRULE:FREQ=WEEKLY;BYDAY=TH
EXDATE;TZID=Europe/Istanbul:20261029T100000
29 Ekim 2026 perşembeye denk geliyor; seri o gün atlanıyor. Kütüphane seçerken RRULE ayrıştırması hazır olanı alın: Python'da dateutil.rrule, JavaScript'te rrule paketi.
Şema: istisnanın anahtarı orijinal başlangıçtır
CREATE TABLE seri (
id uuid PRIMARY KEY,
kaynak_id uuid NOT NULL,
dtstart_yerel timestamp NOT NULL, -- saat dilimsiz duvar saati
tzid text NOT NULL DEFAULT 'Europe/Istanbul',
sure interval NOT NULL,
rrule text NOT NULL, -- UNTIL/COUNT içermez
bitis_yerel timestamp -- NULL = sonsuz
);
CREATE TABLE seri_istisna (
seri_id uuid REFERENCES seri(id) ON DELETE CASCADE,
orijinal_baslangic timestamp NOT NULL, -- iCalendar RECURRENCE-ID karşılığı
tur text NOT NULL CHECK (tur IN ('iptal', 'tasindi')),
yeni_baslangic timestamptz,
yeni_sure interval,
PRIMARY KEY (seri_id, orijinal_baslangic)
);
İki karar burada kritik:
- İstisna "3. tekrar" diye sıra numarasıyla tutulmaz. Kural değişince sıra kayar, istisna yanlış güne yapışır. Anahtar, tekrarın kural değişmeden önceki yerel başlangıç zamanıdır.
- Bitiş tarihi RRULE metninin içine
UNTILolarak yazılmaz, ayrı kolonda durur.dateutil, saat dilimsiz birdtstartileZekli UTCUNTILdeğerini birlikte verdiğinizdeValueErrorfırlatıyor. Bitişi ayrı tutmak hem bu hatayı hem seri bölmeyi kolaylaştırır.
Saat dilimi: duvar saatini saklayın
Tekrar eden randevu "her perşembe 10:00" demektir, "her perşembe 07:00 UTC" değil. Başlangıcı UTC'ye çevirip saklarsanız yaz saati geçişinde seri bir saat kayar. Türkiye 2016'dan beri yaz saati uygulamadığı için bu hata Europe/Istanbul ile test ederken görünmez. Aynı kod Berlin'deki bir şubeye açıldığında mart ve ekim sonunda patlar. Ülkelerin saat kuralı da değişebilir; tz veritabanı güncellendiğinde yerel saat saklayan sistem doğru kalır, UTC saklayan sistem geçmişe dönük düzeltme ister.
Kural: seri için yerel saat + tzid, tek seferlik ve taşınmış kayıt için timestamptz.
Genişletme
from datetime import datetime
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
def tekrarlar(seri, istisnalar, bas: datetime, bit: datetime):
tz = ZoneInfo(seri.tzid)
kural = rrulestr(seri.rrule, dtstart=seri.dtstart_yerel)
ist = {i.orijinal_baslangic: i for i in istisnalar}
bas_y = bas.astimezone(tz).replace(tzinfo=None)
bit_y = bit.astimezone(tz).replace(tzinfo=None)
if seri.bitis_yerel:
bit_y = min(bit_y, seri.bitis_yerel)
for t in kural.between(bas_y, bit_y, inc=True):
i = ist.get(t)
if i is None:
yield t.replace(tzinfo=tz), seri.sure
elif i.tur == "tasindi":
yield i.yeni_baslangic, i.yeni_sure or seri.sure
# iptal: hiçbir şey üretme
Bu kodda bilinçli bırakılmış bir delik var: orijinali pencerenin dışında olup pencerenin içine taşınmış bir tekrar kaçar. Çözümü ayrı sorgudur:
SELECT * FROM seri_istisna
WHERE tur = 'tasindi'
AND yeni_baslangic < :bit AND yeni_baslangic >= :bas;
İki sonucu birleştirip orijinal anahtara göre tekilleştirin.
"Bu ve sonrakiler" bir güncelleme değil, bölmedir
Kullanıcı kasımdan itibaren saati 11:00'e çektiğinde eski seriyi değiştirmezsiniz. Eski seriyi keser, yeni seri açarsınız:
BEGIN;
UPDATE seri SET bitis_yerel = :bolme_ani WHERE id = :eski;
INSERT INTO seri (id, kaynak_id, dtstart_yerel, tzid, sure, rrule)
SELECT :yeni, kaynak_id, :yeni_dtstart, tzid, sure, rrule
FROM seri WHERE id = :eski;
DELETE FROM seri_istisna
WHERE seri_id = :eski AND orijinal_baslangic >= :bolme_ani;
COMMIT;
Silinen istisnaların yeni seriye taşınıp taşınmayacağı ürün kararıdır. Saat değiştiyse eski istisnaların orijinal anahtarı yeni kuralla eşleşmez; genelde kullanıcıya sormak en az şaşırtan yoldur.
Çakışma kontrolünü veritabanına bırakın
Satıra dökülmüş pencerede PostgreSQL bu işi uygulama kodundan daha güvenilir yapar:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE randevu_ornek (
kaynak_id uuid NOT NULL,
aralik tstzrange NOT NULL,
seri_id uuid,
EXCLUDE USING gist (kaynak_id WITH =, aralik WITH &&)
);
İki istek aynı saniyede aynı personeli aldığında biri kısıt hatası alır. Uygulamada bu hatayı yakalayıp "bu saat az önce doldu" demek, önce kontrol edip sonra yazmaktan daha sağlamdır.
Hazır sistemler aynı sorunu nasıl kesiyor
Mevcut ürünlere bakmak, hangi derinlikte model gerektiğini ölçmek için iyi bir yol. Odoo'nun takvim modülü tekrar bilgisini etkinlikten ayrı bir recurrence kaydında tutup örnekleri oradan üretiyor. Frappe tabanlı ERPNext'in Event belgesi ise tekrarı birkaç sabit seçenek ve gün kutucuğuyla ifade ediyor; RRULE'un tüm ifade gücünü sunmuyor. Salon ve klinik randevusuna odaklı Labyra gibi dar kapsamlı yeni sistemlerde tablo başka: kullanan işletme sayısı henüz az, bu yüzden istisna ve saat dilimi davranışı sahada uzun süre sınanmış sayılmaz; üzerine entegrasyon yazacaksanız davranışı belgeden değil kendi deneme verinizle doğrulamanız gerekir.
Kısa kontrol listesi
- Kural: RRULE metni + yerel
dtstart+tzid. Bitiş ayrı kolonda. - İstisna anahtarı: orijinal yerel başlangıç, sıra numarası değil.
- Okuma: sınırlı pencere + pencereye taşınmış istisnalar için ayrı sorgu.
- Düzenleme: "bu ve sonrakiler" = seri bölme.
- Çakışma:
EXCLUDE USING gist, uygulama kodunda kontrol değil.
- veri modelleme
- RRULE
- PostgreSQL
- saat dilimi