Büyük bir konut projesinin satış ofisinde en sık duyduğum cümlelerden biri şudur: "Bu daire satılmış mıydı, bir kontrol edeyim." Danışman telefonu kapatır, Excel dosyasını açar, sonra bir başka danışmana WhatsApp'tan sorar. Bu arada alıcı adayı aynı daireyi web sitesindeki etkileşimli haritada "müsait" olarak görmektedir. Ben Koray Yalçın; lead yönetimi, CRM mimarisi, dijital pazarlama ve yapay zekâ destekli otomasyon üzerine, ağırlıklı olarak gayrimenkul ve PropTech projelerinde çalışıyorum. Gördüğüm en pahalı hatalardan biri, aynı dairenin iki farklı alıcıya aynı gün "sizin için ayırdık" denerek sunulmasıdır. Bu, teknik bir sorundan çok daha fazlasıdır: güven kaybıdır ve çoğu zaman bir indirimle kapatılır.

Bu yazıda ünite stok haritasını (unit availability map) bir geliştirici gözüyle ele alıyorum. Görselin nasıl çizileceği değil, arkasındaki verinin nasıl doğru tutulacağı konusu: ünite durumlarını bir durum makinesiyle modellemek, süreli opsiyonları güvenle yönetmek, iki danışmanın aynı anda aynı daireyi ayırmasını engellemek ve haritayı CRM ile web sitesinde aynı anda güncel tutmak. Örnekler PostgreSQL ve Python üzerinden.

Stok haritası aslında bir görsel değil, bir veri sözleşmesidir

Çoğu projede stok haritası bir tasarım işi olarak başlar. Bloklar çizilir, katlar renklendirilir, her daireye bir tıklama alanı eklenir. Renkler ise elle güncellenen bir tablodan beslenir. Sorun da tam burada başlar: harita, gerçeğin bir kopyasını gösterir ve o kopya her zaman biraz geridedir.

Doğru yaklaşım, haritayı yalnızca bir görüntüleyici olarak görmektir. Gerçeğin tek kaynağı, CRM ya da satış sisteminin içindeki ünite tablosudur. Harita, web sitesi, danışman paneli ve fiyat listesi PDF'i bu tablodan okur; hiçbiri kendi başına durum yazmaz. Bu ilkeyi kodda zorlamazsanız, altı ay sonra üç farklı "doğru" ile karşılaşırsınız.

CREATE TABLE unit ( id bigserial PRIMARY KEY, project_id bigint NOT NULL, block_code text NOT NULL, floor_no int NOT NULL, unit_no text NOT NULL, layout text NOT NULL, -- örn. '2+1' net_m2 numeric(7,2) NOT NULL, status text NOT NULL DEFAULT 'available', status_version int NOT NULL DEFAULT 0, hold_id bigint, updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (project_id, block_code, unit_no), CHECK (status IN ('blocked','available','on_hold','reserved','sold','delivered')) ); 

Burada status_version alanı önemli. Her durum değişikliğinde bir artar ve hem iyimser kilitleme hem de haritadaki güncellemelerin sırasını korumak için kullanılır.

Durumları bir durum makinesiyle sınırlamak

Ünitenin durumu serbest bir metin alanı olursa, bir gün birisi satılmış bir daireyi doğrudan "müsait" yapar ve kimse nedenini bilmez. Bu yüzden geçişleri açıkça tanımlamak gerekir. Tipik bir konut projesinde şu durumlar yeterlidir:

  • blocked: Satışa kapalı. Yatırımcıya ayrılmış, teknik sorunlu ya da henüz fiyatlanmamış üniteler.
  • available: Satışa açık.
  • on_hold: Bir danışmanın bir alıcı adayı için kısa süreli ayırdığı, yani opsiyonlu ünite.
  • reserved: Kapora alınmış, sözleşme bekleyen ünite.
  • sold: Sözleşmesi imzalanmış ünite.
  • delivered: Teslim edilmiş ünite.

İzin verilen geçişleri kodda tek bir yerde tutmak, hem testleri kolaylaştırır hem de yeni bir geliştiricinin kuralları okumasını sağlar.

ALLOWED = { "blocked": {"available"}, "available": {"on_hold", "reserved", "blocked"}, "on_hold": {"available", "reserved"}, "reserved": {"sold", "available"}, # kapora iadesi ile geri dönüş  "sold": {"delivered", "reserved"}, # sözleşme feshi istisnası, onaylı  "delivered": set(), } def assert_transition(current: str, target: str, has_approval: bool) -> None: if target not in ALLOWED[current]: raise ValueError(f"Geçersiz geçiş: {current} -> {target}") if current == "sold" and not has_approval: raise PermissionError("Satılmış üniteyi geri almak yönetici onayı ister") 

"Satılmıştan geri dönüş" gibi istisnaları tamamen yasaklamak gerçekçi değildir; sözleşme feshi olur. Ama bu geçişin onay gerektirmesi ve kayıt altına alınması, kazara yapılan değişiklikleri neredeyse sıfıra indirir.

Süreli opsiyon: Ayırmak kolay, bırakmak zor

Satış ofislerinde opsiyon, danışmanın "bu daireyi müşterim için yarın akşama kadar tut" demesidir. Opsiyonun olmadığı projelerde danışmanlar bunu gayriresmî yollarla yapar ve daireler haftalarca "konuşuluyor" durumunda kalır. Opsiyonu sisteme taşımanın asıl değeri, süresinin dolmasını otomatik hale getirmektir.

Opsiyonu ünite tablosunda bir alan olarak değil, ayrı bir kayıt olarak tutmayı öneririm. Böylece kim, ne zaman, hangi lead için, ne kadar süreyle ayırdı sorularının cevabı kaybolmaz.

CREATE TABLE unit_hold ( id bigserial PRIMARY KEY, unit_id bigint NOT NULL REFERENCES unit(id), lead_id bigint NOT NULL, agent_id bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, released_at timestamptz, release_reason text -- 'expired', 'converted', 'cancelled' ); -- Bir ünitede aynı anda yalnızca bir aktif opsiyon olabilir CREATE UNIQUE INDEX one_active_hold_per_unit ON unit_hold (unit_id) WHERE released_at IS NULL; 

Kısmi benzersiz indeks, uygulama kodunda bir hata olsa bile veritabanının iki aktif opsiyona izin vermemesini sağlar. Bu, "kodda kontrol ediyoruz" demekten çok daha güçlü bir garantidir.

Süre dolumunu ise bir zamanlayıcı işçi yapar. Önemli olan, işçinin birden fazla kopyası çalışsa bile aynı opsiyonu iki kez işlememesidir:

WITH due AS ( SELECT h.id, h.unit_id FROM unit_hold h WHERE h.released_at IS NULL AND h.expires_at <= now() ORDER BY h.expires_at LIMIT 100 FOR UPDATE SKIP LOCKED ) UPDATE unit_hold h SET released_at = now(), release_reason = 'expired' FROM due WHERE h.id = due.id RETURNING h.unit_id; 

Dönen her ünite için durum on_hold iken available yapılır ve sürüm artırılır. Pratikte faydalı bir ek: süre dolmadan bir saat önce danışmana bir bildirim göndermek. Böylece opsiyon sessizce düşmez; danışman ya uzatma ister ya da müşterisini arar. Uzatma sayısını sınırlamak da (örneğin en fazla bir kez) dairelerin sonsuza kadar tutulmasını engeller.

İki danışman aynı anda aynı daireyi ayırırsa

Lansman günlerinde aynı dairenin birkaç saniye arayla iki kez ayrılmaya çalışılması sık görülür. Tipik hata, önce durumu okuyup sonra yazmaktır: iki istek de "müsait" görür, ikisi de yazar ve son yazan kazanır. Bunu engellemenin iki temiz yolu var.

Birincisi, durumu koşullu güncellemektir. Güncelleme yalnızca beklenen durum ve sürüm hâlâ geçerliyse başarılı olur:

def place_hold(conn, unit_id, expected_version, lead_id, agent_id, hours=24): with conn.transaction(): row = conn.execute(""" UPDATE unit SET status = 'on_hold', status_version = status_version + 1, updated_at = now() WHERE id = %s AND status = 'available' AND status_version = %s RETURNING status_version """, (unit_id, expected_version)).fetchone() if row is None: raise Conflict("Ünite bu arada başka biri tarafından ayrıldı") hold_id = conn.execute(""" INSERT INTO unit_hold (unit_id, lead_id, agent_id, expires_at) VALUES (%s, %s, %s, now() + make_interval(hours => %s)) RETURNING id """, (unit_id, lead_id, agent_id, hours)).fetchone()[0] conn.execute("UPDATE unit SET hold_id = %s WHERE id = %s", (hold_id, unit_id)) return hold_id, row[0] 

İkincisi, kısmi benzersiz indeksin son savunma hattı olmasıdır. İlk yöntem atlansa bile veritabanı ikinci aktif opsiyonu reddeder. Danışmana gösterilen mesaj da önemlidir: "Hata oluştu" yerine "Bu daire 14:02'de başka bir danışman tarafından opsiyonlandı" demek, ofis içi tartışmaları azaltır.

Kapora aşamasında da aynı mantık geçerlidir. Opsiyondan rezervasyona geçiş, opsiyonun sahibi olan danışman tarafından ve opsiyon hâlâ aktifken yapılmalıdır. Opsiyon süresi dolmuşsa rezervasyon isteği reddedilir ve ünite yeniden müsait listeye düşer.

Her geçişi olay olarak kaydetmek

Ünite tablosu "şu an ne durumda" sorusunu cevaplar. "Bu daire son bir ayda neler yaşadı" sorusu için ayrı bir olay tablosu gerekir:

CREATE TABLE unit_event ( id bigserial PRIMARY KEY, unit_id bigint NOT NULL, from_status text, to_status text NOT NULL, version int NOT NULL, actor_id bigint, lead_id bigint, reason text, occurred_at timestamptz NOT NULL DEFAULT now() ); 

Bu tablo üç iş görür. Birincisi, anlaşmazlık anında kimin ne yaptığını gösterir. İkincisi, satış analizine veri sağlar: bir ünite tipinin ortalama kaç opsiyondan sonra satıldığı, opsiyonların yüzde kaçının rezervasyona döndüğü gibi metrikler buradan çıkar. Üçüncüsü, haritayı güncelleyen yayın mekanizmasının kaynağıdır. Olay kaydını durum güncellemesiyle aynı işlem (transaction) içinde yazmak, ikisinin birbirinden kopmamasını garanti eder; bu, outbox deseninin küçük bir uygulamasıdır.

Haritayı canlı tutmak: Sürüm numarasıyla sıralı güncelleme

Web sitesindeki harita ve danışman paneli, ünite değişikliklerini mümkün olduğunca hızlı görmelidir. Her birkaç saniyede bir tüm projeyi yeniden çekmek basit ama israflıdır; yüzlerce ünitelik bir projede gereksiz yük oluşturur. Daha iyi bir yol, olay tablosundan beslenen bir yayın kanalıdır. Sunucu tarafından gönderilen olaylar (Server-Sent Events) bu iş için çoğu zaman WebSocket'ten daha basittir, çünkü akış tek yönlüdür.

İstemci tarafında kritik kural şudur: bir ünite için gelen güncelleme, elindeki sürümden küçük ya da eşitse yok sayılır. Ağ gecikmesi yüzünden eski bir olay yeni bir olaydan sonra gelirse harita geriye gitmez.

const units = new Map(); // unitId -> { status, version } function applyUpdate(evt) { const current = units.get(evt.unitId); if (current && evt.version <= current.version) return; // eski olay units.set(evt.unitId, { status: evt.status, version: evt.version }); paintUnit(evt.unitId, evt.status); } const source = new EventSource("/api/projects/42/unit-stream"); source.onmessage = (m) => applyUpdate(JSON.parse(m.data)); 

Bağlantı koptuğunda istemci son gördüğü olay numarasını göndererek kaldığı yerden devam eder. Çok uzun süre kopuk kaldıysa tam bir anlık görüntü çekip akışa yeniden bağlanmak daha güvenlidir.

Herkese aynı bilgiyi göstermemek

Stok haritası, kimin baktığına göre farklı ayrıntı göstermelidir. Danışman panelinde opsiyonu kimin koyduğu ve ne zaman biteceği görünür. Kamuya açık web sitesinde ise yalnızca "müsait", "opsiyonlu" ve "satıldı" gibi genel durumlar yer alır; alıcı adı, lead bilgisi ve iç notlar asla bu uca çıkmaz. Bazı projeler opsiyonlu üniteleri dışarıda da "müsait" göstermeyi tercih eder, çünkü opsiyonların bir kısmı düşer. Bu bir iş kararıdır; ama teknik olarak bunun API katmanında, açıkça adlandırılmış bir eşleme ile yapılması gerekir, tasarımcının renk dosyasında değil.

PUBLIC_STATUS = { "blocked": None, # haritada hiç gösterme  "available": "available", "on_hold": "on_hold", # proje tercihine göre "available" yapılabilir  "reserved": "sold", "sold": "sold", "delivered": "sold", } 

Fiyat bilgisi için de benzer bir ayrım gerekir. Haritada gösterilen fiyat, o günkü geçerli fiyat listesi sürümünden gelmeli ve her ünite kaydı, rezervasyon anında hangi liste sürümüyle satıldığını saklamalıdır. Aksi halde fiyat güncellemesinden sonra eski bir teklifin hangi rakamla verildiği tartışma konusu olur.

Sahada sık yapılan hatalar

Yıllar içinde gördüğüm hataların çoğu birkaç başlıkta toplanıyor:

  • Excel'i kaynak olarak bırakmak. Sistem kurulur ama satış müdürü "son hali benim dosyamda" der. Geçiş döneminde tek kaynağı açıkça ilan etmek ve dosyayı yalnızca okunur bir dışa aktarım haline getirmek gerekir.
  • Süresiz opsiyon. Süresi olmayan opsiyon, aslında gizli bir rezervasyondur. Her opsiyonun bir bitiş zamanı olmalı.
  • Durumu metin olarak serbest bırakmak. "Satıldı?", "kapora bekleniyor" gibi el yazısı durumlar raporlamayı imkânsız kılar.
  • Haritayı önbellekte unutmak. CDN önbelleği uzun tutulan bir harita, satılmış daireleri saatlerce müsait gösterebilir. Durum verisini statik sayfadan ayırıp kısa ömürlü bir uç noktadan sunmak bu sorunu çözer.
  • Lead ile üniteyi bağlamamak. Opsiyon bir lead kaydına bağlı değilse, CRM tarafında hangi müşterinin hangi daireyle ilgilendiği görünmez ve takip hatırlatmaları kurulamaz.

Sıkça Sorulan Sorular (SSS)

Ünite stok haritası için ayrı bir yazılım mı gerekir, CRM yeterli mi?
Çoğu durumda CRM'in içinde ya da yanında duran küçük bir ünite modülü yeterlidir. Önemli olan, durumun tek bir yerde tutulması ve haritanın bu kaynağı okumasıdır. Görsel katman ayrı bir araçla yapılabilir.

Opsiyon süresi ne kadar olmalı?
Bu, projenin satış temposuna bağlı bir iş kararıdır. Teknik olarak önemli olan, sürenin proje bazında ayarlanabilir olması, uzatma sayısının sınırlanması ve süre dolmadan danışmana hatırlatma gitmesidir.

Eşzamanlılık için veritabanı kilidi mi, iyimser kilitleme mi kullanmalıyım?
Ünite ayırma gibi kısa ve tek satırlık işlemlerde koşullu güncelleme ile iyimser kilitleme genellikle yeterli ve daha ölçeklenebilirdir. Kısmi benzersiz indeksi ise her durumda ek bir güvence olarak eklemenizi öneririm.

Harita için WebSocket mi, SSE mi?
Akış yalnızca sunucudan istemciye doğruysa SSE daha basittir; HTTP üzerinde çalışır ve yeniden bağlanmayı tarayıcı kendisi yönetir. İstemcinin de sık sık sunucuya veri göndermesi gerekiyorsa WebSocket düşünülebilir.

Satılmış bir daire sözleşme feshiyle geri dönerse ne olur?
Bu geçiş durum makinesinde ayrı bir istisna olarak tanımlanmalı, yönetici onayı istemeli ve olay tablosuna gerekçesiyle yazılmalıdır. Ünite önce rezervasyon durumuna, iade tamamlanınca müsait duruma döner.

Kamuya açık haritada opsiyonlu daireleri göstermeli miyim?
Bu bir pazarlama kararıdır. Göstermek kıtlık algısı yaratabilir, göstermemek ise düşen opsiyonlar için ilgiyi canlı tutar. Hangisini seçerseniz seçin, eşlemeyi API katmanında açıkça tanımlayın.

Kapanış

Ünite stok haritası, alıcı adayının gördüğü en somut dijital yüzeylerden biridir ve güvenilirliği doğrudan satış ofisinin güvenilirliği olarak algılanır. Durum makinesi, süreli ve kayıtlı opsiyonlar, koşullu güncelleme ile korunan eşzamanlılık, olay tablosu ve sürüm numarasıyla sıralanan canlı güncellemeler; bunlar büyük yatırımlar değildir, ama birlikte "bu daire satılmış mıydı" sorusunu ofisten tamamen kaldırır. Benim deneyimimde bu altyapı kurulduğunda danışmanlar haritaya güvenmeye başlıyor, alıcılar ise aynı daireyi iki kez "son fırsat" olarak duymuyor.

Koray Yalçın — lead yönetimi, CRM mimarisi ve PropTech sistemleri