Bir CRM'in en çok okunan ekranı genelde lead kartının altındaki zaman çizelgesidir: kim aradı, hangi mesaj gitti, toplantı ne zaman yapıldı, sıradaki adım ne. Satış müdürü bir fırsatı incelerken ilk oraya bakar, danışman bir müşteriyi devraldığında ilk oraya bakar. Buna rağmen birçok CRM'de bu ekranın arkasındaki veri modeli aceleyle kurulur: her kanal için ayrı bir tablo, birbirini tutmayan tarih alanları, elle girilmiş ve sonradan düzeltilmiş kayıtlar. Sonuç, "bu lead'i en son kim aradı?" gibi basit bir sorunun bile güvenilir cevabının olmamasıdır.

Ben Koray Yalçın; uzun süredir lead yönetimi, CRM kurulumu ve gayrimenkul satış operasyonları üzerine çalışıyorum. Bu yazıda CRM'deki activity kavramını, yani temsilcilerin ve sistemlerin bir kayıt üzerinde yaptığı arama, mesaj, toplantı, e-posta ve görev gibi aksiyonların bütününü, bir geliştiricinin gözünden ele alıyorum: tek bir aktivite tablosu nasıl tasarlanır, telefon santrali ve WhatsApp gibi dış kaynaklardan gelen kayıtlar nasıl tekilleştirilir, planlanmış ve gerçekleşmiş aktivite nasıl ayrılır, "son temas" ve "sıradaki adım" gibi türetilmiş alanlar nasıl tutarlı tutulur ve tüm bunlar nasıl raporlanır.

Aktivite neden ayrı bir kavram olarak modellenmeli?

Aktiviteyi hafife almanın tipik görüntüsü şudur: leads tablosunda last_call_at, last_whatsapp_at, notes gibi kolonlar vardır; arama kayıtları santral panelinde, mesajlar WhatsApp sağlayıcısında, toplantılar takvimde durur. Her yeni kanal, ana tabloya yeni bir kolon ve yeni bir entegrasyon kodu demektir.

Bu yaklaşımın üç somut sorunu var. Birincisi, geçmiş kaybolur: last_call_at sadece en son aramayı tutar, önceki beş aramanın sonucunu değil. İkincisi, kanallar arası sıralama yapılamaz: "önce mesaj mı gitti, arama mı yapıldı?" sorusu üç ayrı sisteme bakmadan cevaplanamaz. Üçüncüsü, ölçüm imkânsızlaşır: bir danışmanın günlük iş yükünü veya bir lead'e ilk dokunuşun ne kadar sürdüğünü hesaplamak için önce veriyi bir araya getirmeniz gerekir.

Çözüm, aktiviteyi kendi başına bir varlık olarak modellemek ve her kanalı bu ortak modele eşlemektir. Lead, kişi, fırsat (deal) ve şirket kayıtları aktivitelerin "üzerinde gerçekleştiği" şeylerdir; aktivitenin kendisi ise ayrı bir olay kaydıdır.

Tek tablo, ortak çekirdek, türe özel ek alanlar

Benim tercih ettiğim yapı, tüm aktivite türleri için ortak bir çekirdek tablo ve türe özel ayrıntılar için yapılandırılmış bir ek alandır.

CREATE TABLE activity ( id BIGSERIAL PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ('call','message','email','meeting','task','note','site_visit')), direction TEXT CHECK (direction IN ('inbound','outbound','internal')), status TEXT NOT NULL CHECK (status IN ('planned','done','cancelled','missed')), owner_id INTEGER REFERENCES app_user(id), occurred_at TIMESTAMPTZ, -- gerçekleştiği an (done/missed için) due_at TIMESTAMPTZ, -- planlandığı an (planned için) duration_sec INTEGER, outcome TEXT, -- 'reached','no_answer','busy','interested','not_interested'... source TEXT NOT NULL, -- 'manual','pbx','whatsapp','calendar','email_sync','automation' source_ref TEXT, -- dış sistemdeki kimlik details JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (source, source_ref) ); CREATE TABLE activity_link ( activity_id BIGINT REFERENCES activity(id) ON DELETE CASCADE, entity TEXT NOT NULL CHECK (entity IN ('lead','contact','deal','company','unit')), entity_id BIGINT NOT NULL, is_primary BOOLEAN NOT NULL DEFAULT false, PRIMARY KEY (activity_id, entity, entity_id) ); CREATE INDEX ON activity_link (entity, entity_id); 

Bu şemada üç karar bilinçli.

İlki, occurred_at ile created_at ayrımı. Bir danışman akşam saatinde, öğleden sonra yaptığı üç görüşmeyi CRM'e girebilir. Zaman çizelgesi ve raporlar occurred_at'e göre, denetim ise created_at'e göre çalışmalıdır. İkisini tek alanda birleştirirseniz ya sıralama bozulur ya da "kayıt ne zaman girildi?" bilgisi kaybolur.

İkincisi, ilişkinin ayrı bir activity_link tablosunda tutulması. Gayrimenkulde bir site ziyareti aynı anda bir kişiye, o kişinin fırsatına ve gezilen daireye (unit) bağlıdır. Tek bir lead_id kolonu bu gerçeği taşıyamaz. Çoklu bağlantı sayesinde aynı aktivite hem kişinin hem fırsatın zaman çizelgesinde görünür; is_primary ise raporlamada aktivitenin hangi kayda "sayılacağını" belirler.

Üçüncüsü, details alanının JSONB olması ama serbest bırakılmaması. Arama için kayıt dosyası kimliği ve santral dahilisi, mesaj için şablon adı ve teslim durumu, toplantı için katılımcılar gibi alanlar türe göre değişir. Bunları ana tabloya kolon olarak eklemek tabloyu şişirir; tamamen serbest bırakmak ise her geliştiricinin farklı anahtar kullanmasına yol açar. Uygulama katmanında tür başına bir şema (JSON Schema veya Pydantic modeli) tanımlayıp yazmadan önce doğrulamak, iki dünyanın iyi tarafını birleştirir.

Planlanmış ve gerçekleşmiş aktivite: aynı kayıt, iki hal

Satış ekiplerinin en sık karıştırdığı şey, görevle yapılan işin farkıdır. "Yarın 14:00'te ara" bir plandır; ertesi gün yapılan arama ise bir olaydır. Bunları iki ayrı tabloda tutmak cazip görünür ama planın gerçekleşmeye dönüştüğü anı izlemeyi zorlaştırır.

Ben aynı kaydın durum değiştirmesini tercih ediyorum: planned olarak oluşturulan aktivite, yapıldığında done, ulaşılamadığında missed, vazgeçildiğinde cancelled olur. Geçişleri uygulama katmanında açıkça tanımlamak gerekir:

ALLOWED = { "planned": {"done", "missed", "cancelled"}, "missed": {"done"}, # geç de olsa ulaşıldıysa "done": set(), # gerçekleşmiş olay değiştirilmez "cancelled": set(), } def transition(activity, new_status, occurred_at=None, outcome=None): if new_status not in ALLOWED[activity.status]: raise InvalidTransition(activity.status, new_status) if new_status in ("done", "missed") and occurred_at is None: raise ValueError("gerçekleşen aktivite için occurred_at zorunlu") activity.status = new_status activity.occurred_at = occurred_at activity.outcome = outcome record_audit(activity, new_status) 

done durumundaki bir kaydın değiştirilememesi önemli bir kural. Gerçekleşmiş bir aramanın sonucu yanlış girildiyse, kaydı sessizce düzeltmek yerine bir düzeltme notu veya denetim satırı eklenmelidir. Aksi halde "bu fırsat neden kaybedildi?" sorusunu sorduğunuzda geçmiş, olduğu gibi değil, sonradan düzenlendiği haliyle karşınıza çıkar.

Gecikmiş görevler de bu modelde doğal olarak hesaplanır: status = 'planned' AND due_at < now() olan her kayıt gecikmiştir. Ayrı bir "overdue" bayrağı tutmaya gerek yoktur; tutarsanız mutlaka bir gün gerçeklikten kopar.

Dış kaynaklardan gelen aktiviteler: tekilleştirme ve eşleme

Aktivitelerin çoğu artık elle girilmez. Bulut santral her aramayı webhook ile bildirir, WhatsApp Cloud API mesaj durumlarını gönderir, takvim entegrasyonu toplantıları senkronlar. Bu kaynakların ortak özelliği, aynı olayı birden fazla kez gönderebilmeleridir: yeniden deneme, durum güncellemesi veya sıra dışı teslim.

Şemadaki UNIQUE (source, source_ref) kısıtı bu yüzden var. Her kaynak kendi olay kimliğini taşır ve kayıt "ekle ya da güncelle" mantığıyla yazılır:

INSERT INTO activity (type, direction, status, occurred_at, duration_sec, outcome, source, source_ref, details) VALUES ('call', 'outbound', 'done', $1, $2, $3, 'pbx', $4, $5) ON CONFLICT (source, source_ref) DO UPDATE SET duration_sec = EXCLUDED.duration_sec, outcome = COALESCE(EXCLUDED.outcome, activity.outcome), details = activity.details || EXCLUDED.details WHERE activity.status <> 'cancelled'; 

Asıl zor kısım tekilleştirme değil, eşlemedir: santralden gelen bir arama hangi lead'e aittir? Elinizde genelde yalnızca bir telefon numarası vardır. Burada daha önce lead deduplication için kullandığımız normalizasyonun aynısını kullanmak şart. Santral numarayı 05xx... biçiminde, form +90 5xx... biçiminde, WhatsApp ise 905xx... biçiminde gönderebilir. Hepsini E.164 biçimine çevirip normalize edilmiş numara üzerinden arama yapmazsanız, aynı müşterinin aramaları üç farklı "bilinmeyen numara" kaydına dağılır.

Eşleme belirsiz olduğunda (aynı numara iki farklı kişide kayıtlıysa veya hiç eşleşme yoksa) aktiviteyi atmamak, activity_link olmadan bir "eşleşmemiş aktiviteler" kuyruğunda tutmak gerekir. Bu kuyruk hem veri kalitesi sorunlarını görünür kılar hem de yeni bir lead'in ilk temasının kaybolmasını önler.

Türetilmiş alanlar: son temas, sıradaki adım, ilk dokunuş

Zaman çizelgesi her açıldığında tüm aktiviteleri taramak küçük veriyle sorun değildir, ama liste ekranlarında yüzlerce lead için "son temas" ve "sıradaki adım" göstermek gerekir. Bu yüzden türetilmiş alanları bir özet tablosunda tutmak mantıklıdır:

CREATE TABLE entity_activity_summary ( entity TEXT NOT NULL, entity_id BIGINT NOT NULL, first_touch_at TIMESTAMPTZ, last_touch_at TIMESTAMPTZ, last_touch_type TEXT, next_due_at TIMESTAMPTZ, next_activity_id BIGINT, done_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (entity, entity_id) ); 

Kritik nokta, "temas"ın tanımını tek bir yerde yazmaktır. Bir not eklemek temas mıdır? Teslim edilmemiş bir WhatsApp mesajı temas mıdır? Cevapsız bir arama? Benim kullandığım tanım şu: müşteriye yönelik, gerçekleşmiş (done) ve direction değeri inbound ya da outbound olan aktiviteler temastır; iç notlar ve iptal edilmiş görevler değildir. Cevapsız arama "deneme" olarak ayrıca sayılır ama son temas tarihini güncellemez. Bu tanımı bir SQL görünümü veya tek bir fonksiyon olarak yazar, özet tablosunu yalnızca oradan güncellersiniz.

Özet tablosunu tetikleyiciyle (trigger) güncellemek mümkün ama dış kaynaklardan yoğun olay geldiğinde kilitlenme yaratabilir. Ben genelde aktivite yazıldığında etkilenen kayıtları bir kuyruğa atıp kısa aralıklarla yeniden hesaplayan bir iş kullanıyorum. Yeniden hesaplama her zaman ham aktivite tablosundan yapıldığı için özet tablo bozulsa bile silip baştan üretilebilir. Bu, özet tablonun bir "gerçek kaynağı" değil, bir önbellek olduğunu hatırlatan iyi bir disiplin.

Otomasyonların ürettiği aktiviteler

Otomatik karşılama mesajı, hatırlatma e-postası veya randevu teyidi gibi sistem tarafından yapılan işler de aktivitedir ve zaman çizelgesinde görünmelidir. Aksi halde danışman, müşterinin zaten üç mesaj aldığını bilmeden dördüncüyü gönderir.

Ancak otomasyon aktivitelerini insan aktiviteleriyle aynı şekilde saymak raporları yanıltır. "Danışman başına günlük temas" metriğine otomatik mesajlar girerse, en çok otomasyonu tetikleyen danışman en çalışkan görünür. Bu yüzden source = 'automation' olan kayıtları ve owner_id değeri boş olan kayıtları performans raporlarından ayırmak, ama müşteri zaman çizelgesinde göstermek gerekir. İlk dokunuş süresi gibi ölçümlerde de hangi tanımı kullandığınızı açıkça belirtin: otomatik karşılama mesajı ilk dokunuş sayılıyorsa bu süre her zaman çok kısa çıkar ve gerçek insan yanıt süresini gizler.

Raporlama: aktivite verisinden anlamlı soru üretmek

İyi modellenmiş aktivite verisi, satış yönetiminin sorduğu birçok soruya doğrudan cevap verir. Örneğin her lead için ilk insan temasına kadar geçen süreyi şöyle hesaplayabilirsiniz:

SELECT l.id, l.created_at, MIN(a.occurred_at) AS first_human_touch, MIN(a.occurred_at) - l.created_at AS time_to_first_touch FROM lead l JOIN activity_link al ON al.entity = 'lead' AND al.entity_id = l.id JOIN activity a ON a.id = al.activity_id WHERE a.status = 'done' AND a.direction = 'outbound' AND a.source <> 'automation' GROUP BY l.id, l.created_at; 

Aynı yapı üzerinden aşamalar arası aktivite yoğunluğu (bir fırsat kapanana kadar kaç temas gerekti), danışman başına gecikmiş görev sayısı veya kanal bazında ulaşılabilirlik oranı da hesaplanabilir. Burada rakamın kendisinden önce tanımın tutarlılığı önemlidir. Aynı metriği iki farklı ekranda iki farklı sorguyla hesaplarsanız, toplantıda rakamları değil sorguları tartışırsınız. Metrik tanımlarını görünüm olarak veritabanında tutmak bu tartışmayı baştan bitirir.

Kişisel veri ve saklama

Aktivite tablosu bir CRM'deki en yoğun kişisel veri kaynaklarından biridir: arama kayıtları, mesaj içerikleri, serbest metin notlar. Bu yüzden saklama ve maskeleme kurallarının aktivite tablosunu da kapsaması gerekir. details alanındaki mesaj gövdesi veya kayıt dosyası bağlantısı, lead anonimleştirildiğinde ayrıca temizlenmelidir; aktivitenin türü, tarihi, süresi ve sonucu ise istatistik için kalabilir. Hangi alanın ne kadar tutulacağı hukuk ekibinizin kararıdır, bu yazı hukuki görüş değildir; ama şemayı baştan bu ayrıma uygun kurmak, o karar geldiğinde uygulamayı kolaylaştırır.

Sık yapılan hatalar

Sahada en sık gördüğüm hataları kısaca toplarsam:

  • Her kanal için ayrı tablo açmak ve zaman çizelgesini uygulama katmanında birleştirmeye çalışmak.
  • occurred_at yerine created_at ile sıralamak; sonradan girilen kayıtların çizelgeyi karıştırması.
  • Gerçekleşmiş aktiviteleri sessizce düzenlemeye izin vermek.
  • Telefon numarasını normalize etmeden eşleme yapmak.
  • Otomasyon aktivitelerini insan performansına saymak.
  • "Son temas" tanımını her raporda farklı yazmak.

SSS

CRM'de activity ile task arasındaki fark nedir?
Task, planlanmış bir aktivitedir. Aynı kayıt planned olarak oluşturulur ve yapıldığında done, ulaşılamadığında missed olur. İkisini aynı tabloda tutmak planla gerçekleşme arasındaki geçişi izlemeyi kolaylaştırır.

Her kanal için ayrı tablo tutmak hiç mantıklı değil mi?
Kanala özel büyük veriler (örneğin mesaj gövdeleri veya kayıt dosyası meta verisi) ayrı tablolarda durabilir. Ama zaman çizelgesi, sıralama ve raporlama için ortak bir çekirdek aktivite kaydı olmalı; kanal tabloları bu kayda bağlanmalı.

Aynı arama santralden iki kez gelirse ne olur?
source ve source_ref üzerindeki tekil kısıt sayesinde ikinci olay yeni kayıt açmaz, mevcut kaydı günceller. Güncellemenin hangi alanlara izin verdiğini açıkça yazmak gerekir.

Eşleşmeyen aktiviteler ne yapılmalı?
Atılmamalı. Bağlantısız olarak saklanıp bir kuyrukta gösterilmeli; yeni bir müşterinin ilk teması çoğu zaman tam da böyle bir kayıttır.

Son temas tarihini tetikleyiciyle mi güncellemeliyim?
Düşük hacimde tetikleyici yeterlidir. Dış kaynaklardan yoğun olay geliyorsa, etkilenen kayıtları kuyruğa atıp ham tablodan yeniden hesaplayan bir iş daha güvenlidir ve özet tablo bozulduğunda baştan üretilebilir.

Otomatik mesajlar zaman çizelgesinde görünmeli mi?
Evet, müşterinin ne aldığını danışmanın görmesi gerekir. Ancak performans ve ilk yanıt süresi gibi metriklerde insan aktivitelerinden ayrı tutulmalıdır.

Son söz

Aktivite modeli, bir CRM'in "ne oldu?" sorusuna verdiği cevaptır. Lead, fırsat ve aşama tabloları bir kaydın şu anki halini anlatır; aktivite tablosu ise oraya nasıl gelindiğini. Bu tabloyu ortak bir çekirdekle, açık durum geçişleriyle, kaynak bazında tekilleştirmeyle ve tek yerde tanımlanmış türetilmiş alanlarla kurduğunuzda, satış ekibinin güvendiği bir zaman çizelgesi ve yönetimin tartışmadan kullanabildiği raporlar elde edersiniz. Benim lead yönetimi ve CRM projelerinde gördüğüm, sorunların büyük kısmının bu tabloyu baştan doğru kurmamaktan kaynaklandığı.