Mertcan Yüksel içinde ara

Aynı isim, aynı kişi mi? Müşteri verisini tekilleştirmenin uçtan uca kurgusu

Mü­ker­rer müş­te­ri ka­yıt­la­rı­nı, sa­ha­da­ki eki­be for­mat yükü bin­dir­me­den te­miz­le­me­nin uç­tan uca kur­gu­su: kim­lik ve ver­gi nu­ma­ra­sıy­la doğ­ru ta­nı­mak, Türk­çe ka­rak­ter tu­za­ğın­dan ka­çın­mak, res­mi kay­nak­lar­la zen­gin­leş­tir­mek, ka­rar­sız ka­yıt­la­rı in­sa­na sor­mak ve hep­si­ni her gece Pre­fect ile ça­lış­tır­mak.

Paylaş

İçindekiler

Bir sa­tış tem­sil­ci­si CRM’i açar, en önem­li müş­te­ri­si­ni arar ve üç ka­yıt bu­lur: biri şir­ke­tin eski un­va­nıy­la, biri bü­yük harf­ler­le ve Türk­çe ka­rak­ter­siz ya­zıl­mış, biri de iki yıl önce ay­rıl­mış bir yet­ki­liy­le. CRM pro­je­le­ri ya­zı­sın­da bu anın bir CRM’in ka­de­ri­ni na­sıl be­lir­le­di­ği­ni an­lat­mış­tım. Bu yazı o anın tek­nik ta­ra­fı: mü­ker­rer müş­te­ri ka­yıt­la­rı­nı bul­mak, aynı olan­la­rı bir­leş­tir­mek, aynı gö­rü­nüp fark­lı olan­la­rı bir­leş­tir­me­mek ve bu­nun için ka­lı­cı bir sü­reç kur­mak.

Ya­zı­nın tezi tek cüm­le: Saha ser­best­çe gi­rer, sis­tem ge­ce­le­ri stan­dar­da çe­ker. Veri ka­li­te­si sa­ha­da­ki eki­bin for­mat­la bo­ğuş­ma­sıy­la de­ğil; ya­zı­lı bir veri stan­dar­dı, gü­ve­ni­lir kim­lik nu­ma­ra­la­rı, res­mi kay­nak­lar ve bun­la­rı her gece uy­gu­la­yan bir sü­reç­le sağ­la­nır. Ör­nek­ler Python, PostgreSQL ve Pre­fect ile; ama il­ke­ler han­gi araç­la ku­rar­sa­nız ku­run ge­çer­li.

Mükerrer kayıt müşteriye nasıl yansır?

Mü­ker­rer ka­yıt bir veri so­ru­nu gibi gö­rü­nür; ama be­de­li­ni müş­te­ri öder:

  • Aynı müş­te­ri aynı haf­ta iki ayrı tem­sil­ci ta­ra­fın­dan ara­nır.
  • Kam­pan­ya e-pos­ta­sı aynı ki­şi­ye üç kez ge­lir; üçün­cü­sün­de kişi e-pos­ta lis­te­sin­den çı­kar.
  • Müş­te­ri hiz­met­le­ri müş­te­ri­nin geç­mi­şi­nin sa­de­ce bir kıs­mı­nı gö­rür ve bir şi­ka­ye­ti ilk kez du­yu­yor­muş gibi dav­ra­nır.
  • Fa­tu­ra eski ad­re­se gi­der, tah­si­lat ge­ci­kir.

Ters yön daha teh­li­ke­li­dir: Yan­lış bir­leş­tir­me, mü­ker­rer ka­yıt­tan daha pa­ha­lı­dır. İki fark­lı ki­şi­nin ka­yıt­la­rı bir­le­şir­se bi­ri­nin fa­tu­ra­sı di­ğe­ri­ne gi­der, bi­ri­nin ver­di­ği ile­ti­şim izni di­ğe­ri­ne uy­gu­la­nır, bi­ri­nin bor­cu di­ğe­ri­nin he­sa­bın­da gö­rü­nür. Mü­ker­rer ka­yıt müş­te­ri­yi ra­hat­sız eder; yan­lış bir­leş­tir­me hu­ku­ki bir so­ru­na dö­nü­şe­bi­lir. Bu ya­zı­da­ki tem­kin­li ta­sa­rı­mın, yani ke­sin ku­ral­la­rın, eşik­le­rin ve in­san ona­yı­nın ne­de­ni bu.

İlke: Sahaya format yükü değil, arka planda standart

Veri ka­li­te­si so­ru­nu­na ve­ri­len ilk tep­ki ge­nel­de for­mu sı­kı­laş­tır­mak­tır: te­le­fon için bi­çim mas­ke­si, on­lar­ca zo­run­lu alan, “un­va­nı res­mi ha­liy­le ya­zın” uya­rı­la­rı. Bun­la­rın her biri sa­ha­da­ki eki­bi ya­vaş­la­tır ve onu CRM’den uzak­laş­tı­rır. Tem­sil­ci müş­te­ri­yi na­sıl bi­li­yor­sa öyle ya­zar: “Ör­nek Ya­zı­lım”, “0532 123 45 67”, “Ali.Kaya@Ornek.com.TR”. Stan­dar­dı bil­mek ve uy­gu­la­mak sis­te­min işi­dir. Bu­nun için iki şey ge­re­kir.

Ya­zı­lı bir veri stan­dar­dı. Her ala­nın han­gi bi­çi­me dö­nü­şe­ce­ği­ni an­la­tan, iş ta­ra­fıy­la bir­lik­te ya­zıl­mış bir tab­lo. Pa­zar­te­si ra­po­ru ya­zı­sın­da­ki KPI söz­lü­ğü­nün müş­te­ri ve­ri­sin­de­ki kar­şı­lı­ğı­dır:

AlanSaha nasıl girebilirStandart biçim
Telefon0532 123 45 67, (532) 1234567+905321234567
UnvanÖRNEK YAZILIM SAN. VE TİC. A.Ş.Eşleştirme anahtarı: ornek yazilim
E-postaAli.Kaya@Ornek.com.TRali.kaya@ornek.com.tr; alan adı: ornek.com.tr
Ad ve soyadAYŞE yılmaz kayaEşleştirme anahtarı: ayse / yilmaz kaya
Vergi numarası10 hane, boşluklu ya da boşluksuz10 hane, kontrol hanesi doğrulanmış
Kimlik numarası11 hane, boşluklu ya da boşluksuzAnahtarlı özet; numaranın kendisi sınırlı erişimde

Ham ve­ri­yi ez­me­yin. Tem­sil­ci­nin yaz­dı­ğı de­ğer ol­du­ğu gibi ka­lır; stan­dart de­ğer ve res­mi kay­nak­tan ge­len de­ğer ya­nın­da, ayrı sü­tun­lar­da tu­tu­lur:

AlanHam (sahanın yazdığı)Standart (eşleştirme için)Resmi kaynak
UnvanÖrnek Yazılımornek yazilimÖRNEK YAZILIM SANAYİ VE TİCARET ANONİM ŞİRKETİ

Bu­nun üç ne­de­ni var. Ku­ral­lar za­man­la iyi­le­şir; ham de­ğer dur­du­ğu sü­re­ce stan­dart de­ğer­ler her gece baş­tan he­sap­la­na­bi­lir. Bir de­ğe­rin ne­re­den gel­di­ği her za­man iz­le­ne­bi­lir. Ve ek­ran­da, fa­tu­ra­da her za­man bir in­sa­nın yaz­dı­ğı ya da res­mi kay­nak­tan ge­len de­ğer gö­rü­nür; eş­leş­tir­me anah­ta­rı hiç­bir za­man müş­te­ri­nin kar­şı­sı­na çık­maz.

Kimlik: Kimi neyle tanırız?

Te­kil­leş­tir­me­nin ilk so­ru­su al­go­rit­ma de­ğil, kim­lik­tir: Bir müş­te­ri­yi di­ğe­rin­den ayı­ran ne­dir?

Tü­zel kişi: ver­gi nu­ma­ra­sı. Bir fir­ma için en güç­lü ta­nım­la­yı­cı ver­gi nu­ma­ra­sı­dır. Aynı ge­çer­li ver­gi nu­ma­ra­sı­na sa­hip iki ka­yıt aynı fir­ma­dır; bir is­tis­nay­la: şu­be­ler. Aynı fir­ma­nın şu­be­le­ri aynı ver­gi nu­ma­ra­sı­nı ta­şır ama fark­lı ad­res­le­re, fark­lı yet­ki­li­le­re, ba­zen fark­lı fi­yat an­laş­ma­la­rı­na sa­hip­tir. On­la­rı bir­leş­tir­me­yin; bir “üst müş­te­ri” iliş­ki­siy­le grup­la­yın.

Ger­çek kişi: T.C. kim­lik nu­ma­ra­sı. Türkiye’de bir ki­şi­yi ta­nı­ma­nın en gü­ve­ni­lir yolu kim­lik nu­ma­ra­sı­dır. Aynı za­man­da en has­sas ki­şi­sel ve­ri­ler­den bi­ri­dir; iş­le­mek için KVKK kap­sa­mın­da açık bir hu­ku­ki da­ya­na­ğı­nız ol­ma­lı. Pra­tik­te nu­ma­ra­yı şif­re­li sak­la­yın, eri­şi­mi­ni sı­nır­la­yın, ek­ran­lar­da mas­ke­le­yin. Eş­leş­tir­me için de nu­ma­ra­nın ken­di­si­ni de­ğil, anah­tar­lı öze­ti­ni kul­la­nın:

import hashlib
import hmac


def tckn_ozeti(tckn: str, anahtar: bytes) -> str:
    """Eşleştirme için kimlik numarasının anahtarlı özeti.

    Düz SHA-256 yetmez: Geçerli kimlik numarası sayısı sınırlıdır ve hepsi
    denenerek özet geri çözülebilir. Anahtar şifre yöneticisinde durur.
    """
    return hmac.new(anahtar, tckn.encode(), hashlib.sha256).hexdigest()

Eş­leş­tir­me kodu böy­le­ce kim­lik nu­ma­ra­la­rı­nı hiç gör­me­den “bu iki ka­yıt aynı ki­şi­ye mi ait?” so­ru­su­nu ce­vap­la­ya­bi­lir. Anah­tar, Pre­fect ya­zı­sın­da an­lat­tı­ğım giz­li ayar­lar gibi bir şif­re yö­ne­ti­ci­sin­de du­rur.

Kimlik numarası yoksa: Ad, soyad ve iletişim bilgisi neden yetmez?

Ka­yıt­la­rın bir kıs­mın­da kim­lik nu­ma­ra­sı hiç ol­maz. İlk akla ge­len, ad, so­yad, te­le­fon ve e-pos­ta­yı bir­lik­te kul­lan­mak­tır. Türkiye’de bu dört alan tek ba­şı­na yet­mez:

  • Baba ve oğul. Bir baba oğ­lu­na ken­di adı­nı ver­miş, oğ­lu­nun kay­dı­na da ken­di te­le­fo­nu­nu ve e-pos­ta­sı­nı gir­miş ola­bi­lir. Ad, so­yad, te­le­fon ve e-pos­ta bi­re­bir ay­nı­dır; ama iki ayrı müş­te­ri var­dır.
  • Or­tak ile­ti­şim bil­gi­si. Bir ai­le­nin or­tak te­le­fo­nu, bir şir­ke­tin sant­ral nu­ma­ra­sı ya da info@ ad­re­si ki­şi­ye de­ğil ha­ne­ye ya da ku­ru­ma ait­tir.
  • So­ya­dı tu­za­ğı. Ev­li­lik­le so­ya­dı de­ği­şir; bazı ka­dın­lar kız­lık so­ya­dı­nı ko­ru­ya­rak iki so­ya­dı kul­la­nır (“Ayşe Yıl­maz Kaya”). So­ya­dın­da bi­re­bir eş­leş­me ara­mak aynı ki­şi­yi ka­çı­rır. So­ya­dı­nı ke­li­me ke­li­me kar­şı­laş­tı­rın; so­ya­dı­nın fark­lı ol­ma­sı tek ba­şı­na “fark­lı kişi” ka­nı­tı sa­yıl­maz.

Bu yüz­den ad, so­yad ve ile­ti­şim bil­gi­si hiç­bir za­man oto­ma­tik bir­leş­tir­me­ye yet­mez; en faz­la bir aday üre­tir ve in­sa­na so­rar. Ayırt et­mek için do­ğum ta­ri­hi gibi bir alan ya da kim­lik nu­ma­ra­sı ge­re­kir. Sin­yal­le­rin tek ba­şı­na ne söy­le­di­ği­ni bir tab­lo­da top­la­mak bü­tün ta­sa­rı­mın te­me­li­dir:

SinyalTek başına ne söyler?
Aynı geçerli kimlik numarası ya da vergi no.Kesin aynı (vergi numarasında şubeler hariç)
Farklı geçerli kimlik numaralarıKesin farklı; ad, soyad ve e-posta aynı olsa bile
Farklı doğum tarihleriKesin farklı
Resmi kaynakta aynı unvanÇok güçlü
Aynı ad ve aynı doğum tarihiGüçlü
Aynı telefon ya da e-postaZayıf; haneye ya da kuruma ait olabilir
Benzer ad ya da unvanZayıf; tek başına sadece aday üretir
Farklı soyadıHiçbir şey; evlilikle değişmiş olabilir

İkin­ci sa­tır bu ya­zı­nın en önem­li ku­ra­lı­dır: Fark­lı kim­lik nu­ma­ra­la­rı, geri ka­lan her şe­yin aynı ol­ma­sın­dan daha güç­lü bir ka­nıt­tır. Baba ile oğul so­ru­nu­nu bu ku­ral çö­zer.

Türkçe karakter tuzağı

Te­kil­leş­tir­me ya­zı­la­rı­nın çoğu İn­gi­liz­ce ve­riy­le ya­zı­lır ve Türk­çe ve­ri­de ses­siz­ce çö­ker. Aşa­ğı­da­ki tab­lo­da­ki her sa­tır­da göz aynı şeyi gö­rür, bil­gi­sa­yar fark­lı:

DurumGöz ne görürBilgisayar ne yapar
Python’da "İSTANBUL".lower()istanbul"i̇stanbul": görünmez bir nokta işaretiyle dokuz karakter; “istanbul”a eşit değil
Python’da "şirket".upper()ŞİRKET"ŞIRKET": noktasız I
Python’da "IŞIK".lower()ışık"işik"
Türkçe klavyesiz girişŞükrü Öztürk = Sukru Ozturkİki farklı isim
Eski sistemden UTF-8 dosyayı Windows-1252 diye okumakŞükrü Öztürk"Şükrü Öztürk"
Windows-1254 dosyayı UTF-8 diye okumakŞükrü"��kr�": harfler geri dönülmez biçimde kaybolur

Bu­nun ne­de­ni, bü­yük-kü­çük harf dö­nü­şü­mü­nün dile bağ­lı ol­ma­sı­dır. Türk­çe­de i’nin bü­yü­ğü İ, I’nın kü­çü­ğü ı’dır; Python’un ve çoğu ve­ri­ta­ba­nı­nın var­sa­yı­lan ku­ral­la­rı ise İn­gi­liz­ce­ye gö­re­dir. Ve­ri­ta­ba­nın­da aynı kar­şı­laş­tır­ma, kul­la­nı­lan collation’a göre fark­lı so­nuç ve­re­bi­lir. So­ru­nu üç ku­ral­la çö­zer­si­niz.

1. Eş­leş­tir­me anah­ta­rın­da dile bağ­lı lower() ve upper() kul­lan­ma­yın. İ, I, ı ve i’yi önce tek bir har­fe in­di­rin, ar­dın­dan bü­tün Türk­çe harf­le­ri sa­bit bir tab­loy­la ASCII kar­şı­lı­ğı­na kat­la­yın. So­nuç bil­gi­sa­ya­rın dil aya­rın­dan ve ve­ri­ta­ba­nı­nın collation’ın­dan ba­ğım­sız olur; “Şük­rü Öz­türk”, “ŞÜKRÜ ÖZTÜRK” ve “Suk­ru Oz­turk” aynı anah­ta­rı ve­rir:

import re
import unicodedata

# İ, I, ı ve i önce tek bir harfe iner, ardından Türkçe harfler ASCII'ye katlanır.
# Sonuç bilgisayarın dil ayarından ve veritabanının collation'ından bağımsızdır.
_TR = str.maketrans("İIıiŞşĞğÜüÖöÇç", "iiiissgguuoocc")


def katla(metin: str) -> str:
    """Eşleştirme anahtarı: harf büyüklüğü ve Türkçe karakter farkı kalmaz."""
    metin = unicodedata.normalize("NFC", metin).translate(_TR)
    # Kalan aksanları ve "i̇"deki görünmez noktayı at.
    metin = "".join(c for c in unicodedata.normalize("NFKD", metin) if not unicodedata.combining(c))
    return re.sub(r"\s+", " ", metin.lower()).strip()

Aynı kat­la­ma­yı ve­ri­ta­ba­nın­da da ya­pa­bi­lir­si­niz. PostgreSQL’in translate fonk­si­yo­nu, kar­şı­lı­ğı ol­ma­yan ka­rak­te­ri si­ler; gö­rün­mez nok­ta (chr(775)) böy­le­ce te­miz­le­nir:

SELECT lower(translate(unvan, 'İIıiŞşĞğÜüÖöÇç' || chr(775), 'iiiissgguuoocc')) AS anahtar
FROM musteri;

2. Kod­la­ma­yı içe­ri alır­ken çö­zün. Eski sis­tem­ler­den ge­len dos­ya­la­rın kod­la­ma­sı­nı ak­ta­rım anın­da kont­rol edin. UTF-8 bir met­nin yan­lış okun­du­ğu­nu gös­te­ren iz­ler bel­li­dir (“Ô, “Å” gibi) ve çoğu za­man geri çev­ri­le­bi­lir:

_BOZUK = ("Ã", "Å", "Ä", "�")


def bozuk_mu(metin: str) -> bool:
    """UTF-8 metnin yanlış kodlamayla okunduğunu gösteren izler."""
    return any(iz in metin for iz in _BOZUK)


def onar(metin: str) -> str:
    """Windows-1252 diye okunmuş UTF-8 metni geri çevirir; çeviremezse dokunmaz."""
    try:
        return metin.encode("cp1252").decode("utf-8")
    except (UnicodeEncodeError, UnicodeDecodeError):
        return metin

Bo­zuk ka­yıt­la­rın ora­nı­nı her ak­ta­rım­da öl­çün ve bir eşi­ği aşar­sa ak­ta­rı­mı dur­du­run. ”�” işa­re­ti çık­mış­sa veri za­ten kay­bol­muş­tur; onu dü­zelt­me­nin tek yolu kay­nak­tan doğ­ru kod­la­may­la ye­ni­den oku­mak­tır.

3. Kat­lan­mış anah­tar sa­de­ce eş­leş­tir­me için­dir. Ek­ran­da, fa­tu­ra­da ve e-pos­ta­da her za­man ori­ji­nal ya­zım gö­rü­nür. Şük­rü Bey’e “Sa­yın Suk­ru Oz­turk” diye hi­tap eden bir sis­tem, te­kil­leş­tir­me­de ka­zan­dı­ğı gü­ve­ni ilk e-pos­ta­da kay­be­der.

Standartlaştırma kuralları

Ka­rak­ter kat­la­ma te­me­li­nin üze­ri­ne her alan için kü­çük, test edi­le­bi­lir bi­rer fonk­si­yon ku­ru­lur. Hep­si­nin or­tak özel­li­ği şu: Stan­dar­da çe­ke­me­di­ği de­ğe­ri boz­maz, None dö­ner; ham de­ğer ye­rin­de ka­lır ve ka­yıt “stan­dar­da çe­ki­le­me­di” ola­rak işa­ret­le­nir.

Un­van. Hu­ku­ki ve ka­lıp ifa­de­ler fir­ma­yı bir­bi­rin­den ayır­maz; “A.Ş.” ile “Ano­nim Şir­ke­ti” aynı şey­dir. Bun­la­rı atın­ca ge­ri­ye fir­ma­nın asıl adı ka­lır:

# Unvanda firmayı ayırt etmeyen hukuki ve kalıp ifadeler (katlanmış halleriyle).
_UNVAN_KALIPLARI = {
    "anonim", "as", "sirketi", "limited", "ltd", "sti",
    "sanayi", "san", "ticaret", "tic", "ve",
}


def unvan_anahtari(unvan: str) -> str:
    """'ÖRNEK YAZILIM SAN. VE TİC. A.Ş.' ve 'Ornek Yazilim AS' aynı anahtarı verir."""
    metin = katla(unvan).replace(".", "")
    kelimeler = re.sub(r"[^\w\s]", " ", metin).split()
    return " ".join(k for k in kelimeler if k not in _UNVAN_KALIPLARI)

Ka­lıp lis­te­si­ni ken­di ve­ri­ni­ze göre bü­yü­tün; ama sa­de­ce ger­çek­ten ayırt edi­ci ol­ma­yan ifa­de­le­ri ek­le­yin. “Ya­zı­lım” ya da “Lo­jis­tik” gibi fa­ali­yet alan­la­rı ka­lıp de­ğil­dir; iki fark­lı fir­ma­yı ayı­ran tek ke­li­me ola­bi­lir­ler.

Te­le­fon. Sa­ha­da her bi­çim gö­rü­lür; stan­dart tek­tir:

def telefon_standart(ham: str) -> str | None:
    """'0532 123 45 67', '(532) 1234567', '+90 532...' → '+905321234567'."""
    rakamlar = re.sub(r"\D", "", ham)
    if rakamlar.startswith("0090"):
        rakamlar = rakamlar[4:]
    elif rakamlar.startswith("90") and len(rakamlar) == 12:
        rakamlar = rakamlar[2:]
    elif rakamlar.startswith("0") and len(rakamlar) == 11:
        rakamlar = rakamlar[1:]
    if len(rakamlar) != 10 or rakamlar[0] not in "23458":
        return None  # standarda çekilemedi; ham değer yerinde kalır
    return "+90" + rakamlar

Yurt dışı nu­ma­ra­la­rı­nız var­sa bu fonk­si­yo­nu ülke ko­du­na göre ge­niş­let­mek ye­ri­ne bu iş için ya­zıl­mış bir kütüphane kul­la­nın; nu­ma­ra plan­la­rı ül­ke­den ül­ke­ye çok de­ği­şir.

E-pos­ta. Kü­çük har­fe çev­ri­lir; alan adı ise baş­lı ba­şı­na bir ipu­cu­dur. Aynı şir­ket alan adı­na sa­hip iki ka­yıt bü­yük ola­sı­lık­la aynı fir­ma­nın ça­lı­şan­la­rı­dır; ge­nel e-pos­ta ser­vis­le­ri ise hiç­bir şey söy­le­mez:

_GENEL_ALAN_ADLARI = {
    "gmail.com", "hotmail.com", "hotmail.com.tr", "outlook.com",
    "yahoo.com", "yandex.com", "icloud.com", "live.com",
}


def eposta_standart(ham: str) -> str | None:
    eposta = ham.strip().lower()
    return eposta if re.fullmatch(r"[^@\s]+@[^@\s]+\.[a-z]{2,}", eposta) else None


def kurumsal_alan_adi(eposta: str) -> str | None:
    """Şirket alan adı bir ipucudur; genel e-posta servisleri değildir."""
    alan = eposta.split("@", 1)[1]
    return None if alan in _GENEL_ALAN_ADLARI else alan

Ad ve so­ya­dı. CRM’iniz­de ad ve so­ya­dı ayrı alan­lar­day­sa iki­si­ni ayrı kat­la­yın. Tek alan­da tu­tu­lu­yor­sa han­gi ke­li­me­nin so­ya­dı ol­du­ğu­nu tah­min et­me­ye ça­lış­ma­yın; Türkiye’de iki adlı ve iki so­yad­lı ki­şi­ler yay­gın­dır. Ke­li­me­le­ri bir küme ola­rak kar­şı­laş­tır­mak tah­min et­mek­ten daha gü­ven­li­dir.

Ver­gi ve kim­lik nu­ma­ra­sı. İki­si­nin de son ha­ne­le­ri bir kont­rol ha­ne­si­dir; yan­lış ya­zıl­mış bir nu­ma­ra­yı daha içe­ri gi­rer­ken ya­ka­lar­sı­nız:

def tckn_gecerli_mi(tckn: str) -> bool:
    if not re.fullmatch(r"[1-9]\d{10}", tckn):
        return False
    d = [int(c) for c in tckn]
    onuncu = ((d[0] + d[2] + d[4] + d[6] + d[8]) * 7 - (d[1] + d[3] + d[5] + d[7])) % 10
    return d[9] == onuncu and d[10] == sum(d[:10]) % 10


def vkn_gecerli_mi(vkn: str) -> bool:
    if not re.fullmatch(r"\d{10}", vkn):
        return False
    d = [int(c) for c in vkn]
    toplam = 0
    for i in range(9):
        ara = (d[i] + 9 - i) % 10
        deger = (ara * 2 ** (9 - i)) % 9
        if ara != 0 and deger == 0:
            deger = 9
        toplam += deger
    return d[9] == (10 - toplam % 10) % 10

Kont­rol ha­ne­si tut­ma­yan nu­ma­ra si­lin­mez, “doğ­ru­la­na­ma­dı” diye işa­ret­le­nir. Kont­rol ha­ne­si­nin tut­ma­sı ise nu­ma­ra­nın ger­çek­ten o ki­şi­ye ya da fir­ma­ya ait ol­du­ğu­nu ka­nıt­la­maz; sa­de­ce ya­zım ha­ta­sı ol­ma­dı­ğı­nı gös­te­rir. Bir adım öte­si res­mi kay­nak­lar­dır.

Resmi kaynaklarla zenginleştirmek

Sa­ha­da­ki eki­bin “Ör­nek Ya­zı­lım” diye yaz­dı­ğı fir­ma, res­mi ka­yıt­ta “ÖRNEK YAZILIM SANAYİ VE TİCARET ANONİM ŞİRKETİ”dir. Ver­gi nu­ma­ra­sı gi­ril­miş bir fir­ma­nın res­mi bil­gi­si­ni bir kez ge­tir­di­ği­niz­de eş­leş­tir­me ar­tık ser­best me­tin­le de­ğil, res­mi un­van­la ya­pı­lır. Bu, te­kil­leş­tir­me­nin en ucuz ve en gü­ve­ni­lir ka­zan­cı­dır.

  • Ver­gi nu­ma­ra­sın­dan res­mi un­va­na. E-fa­tu­ra en­teg­ra­tör­le­ri­nin çoğu, ver­gi nu­ma­ra­sıy­la fir­ma­nın res­mi un­va­nı­nı ve e-fa­tu­ra kul­la­nı­cı­sı olup ol­ma­dı­ğı­nı dö­nen bir mü­kel­lef sor­gu­la­ma ser­vi­si su­nar. Ge­lir İda­re­si de e-Fa­tu­ra ka­yıt­lı kul­la­nı­cı­la­rı­nın lis­te­si­ni ya­yım­lar. Han­gi­si­ni kul­la­na­ca­ğı­nı­zı en­teg­ra­tö­rü­nü­zün sun­du­ğu im­kan­lar be­lir­ler.
  • Kim­lik nu­ma­ra­sın­dan doğ­ru­la­ma­ya. Kim­lik nu­ma­ra­sı­nı ad, so­yad ve do­ğum yı­lıy­la doğ­ru­la­yan bir kamu ser­vi­si de var. Ama ki­şi­sel ve­riy­le ça­lış­tı­ğı için kul­lan­ma­dan önce hu­ku­ki da­ya­na­ğı­nı­zı ve ser­vi­sin kul­la­nım ko­şul­la­rı­nı hu­kuk bi­ri­miy­le net­leş­ti­rin.

Res­mi kay­nak­tan ge­len de­ğer de ham de­ğe­ri ez­mez; ken­di sü­tu­nun­da du­rur. İki­si ara­sın­da­ki fark baş­lı ba­şı­na bir bul­gu­dur: Ka­yıt­ta “Ör­nek Ya­zı­lım” ya­zan bir ver­gi nu­ma­ra­sı res­mi kay­nak­ta “De­niz Lo­jis­tik” çı­kı­yor­sa ya nu­ma­ra yan­lış gi­ril­miş­tir ya da yan­lış fir­ma­nın nu­ma­ra­sı kop­ya­lan­mış­tır. Bu ka­yıt­lar bir­leş­tir­me­ye de­ğil, dü­zelt­me lis­te­si­ne gi­der.

Res­mi kay­nak­lar ge­nel­de üc­ret­li ya da ko­ta­lı­dır; her gece bin­ler­ce kay­dı baş­tan sor­gu­la­mak hem ko­ta­yı hem büt­çe­yi tü­ke­tir. Bir nu­ma­ra­nın res­mi un­va­nı her gün de­ğiş­mez. Aşa­ğı­da­ki Pre­fect akı­şın­da bu adım bir hız li­mi­tiy­le sı­nır­la­nır, ge­çi­ci ha­ta­lar­da bek­le­ye­rek ye­ni­den de­ne­nir ve aynı nu­ma­ra otuz gün için­de ikin­ci kez sor­gu­lan­maz.

Eşleştirme ve üç eşik

Önce ke­sin eş­leş­me­ler. Aynı kim­lik öze­ti­ne ya da aynı ge­çer­li ver­gi nu­ma­ra­sı­na sa­hip ka­yıt­la­rı bul­mak için al­go­rit­ma­ya ge­rek yok­tur, SQL ye­ter:

SELECT tckn_ozeti, array_agg(id ORDER BY id) AS kayitlar
FROM musteri
WHERE tckn_ozeti IS NOT NULL
  AND birlestirildigi_id IS NULL
GROUP BY tckn_ozeti
HAVING count(*) > 1;

Son­ra grup­la­ma. Bir mil­yon kay­dı bir­bi­riy­le kar­şı­laş­tır­mak ya­rım tril­yon çift de­mek­tir. Ka­yıt­la­rı önce kaba bir anah­tar­la grup­la­yın ve sa­de­ce aynı grup­ta­ki­le­ri kar­şı­laş­tı­rın: fir­ma­lar­da un­van anah­ta­rı­nın ilk ke­li­me­si, ki­şi­ler­de adın ilk harf­le­ri ve var­sa do­ğum yılı. Grup­la­ma anah­ta­rı kat­lan­mış de­ğer­ler­den üre­til­me­li­dir; yok­sa “Şük­rü” ile “Suk­ru” fark­lı grup­la­ra dü­şer ve hiç kar­şı­laş­tı­rıl­maz.

Son­ra ka­rar. Her aday çift için bir ka­rar fonk­si­yo­nu ça­lı­şır. Önce ke­sin ku­ral­la­ra ba­kar, son­ra sin­yal­le­ri pu­an­lar. Aşa­ğı­da­ki fonk­si­yon ki­şi­ler için­dir; fir­ma­lar için aynı yapı ver­gi nu­ma­ra­sı, res­mi un­van ve ku­rum­sal alan adıy­la ku­ru­lur:

from dataclasses import dataclass
from difflib import SequenceMatcher


@dataclass
class Kisi:
    ad: str
    soyad: str
    tckn_ozeti: str | None = None
    dogum_tarihi: str | None = None  # "1985-04-12"
    telefon: str | None = None       # standart biçimde
    eposta: str | None = None        # standart biçimde


def soyad_uyumu(a: str, b: str) -> str:
    """Soyadı kelime kelime karşılaştırılır: 'Yılmaz Kaya' ile 'Kaya' ortaktır."""
    ka, kb = set(katla(a).split()), set(katla(b).split())
    if not ka or not kb:
        return "bilinmiyor"
    if ka == kb:
        return "ayni"
    return "ortak" if ka & kb else "farkli"


OTOMATIK, INCELEME = 85, 50


def karar_ver(a: Kisi, b: Kisi) -> tuple[str, int, list[str]]:
    """('birlestir' | 'incele' | 'ayri', puan, gerekçeler)"""
    # 1. Kesin kurallar: kimlik numarası ve doğum tarihi tartışmaya açık değildir.
    if a.tckn_ozeti and b.tckn_ozeti:
        if a.tckn_ozeti == b.tckn_ozeti:
            return "birlestir", 100, ["Kimlik numarası aynı"]
        return "ayri", 0, ["Kimlik numaraları farklı"]
    if a.dogum_tarihi and b.dogum_tarihi and a.dogum_tarihi != b.dogum_tarihi:
        return "ayri", 0, ["Doğum tarihleri farklı"]

    # 2. Puanlı sinyaller: hiçbiri tek başına yetmez.
    puan, gerekce = 0, []
    if SequenceMatcher(None, katla(a.ad), katla(b.ad)).ratio() >= 0.9:
        puan += 30
        gerekce.append("Ad aynı ya da çok benzer")
    soyad = soyad_uyumu(a.soyad, b.soyad)
    if soyad == "ayni":
        puan += 20
        gerekce.append("Soyadı aynı")
    elif soyad == "ortak":
        puan += 15
        gerekce.append("Soyadında ortak kelime var")
    elif soyad == "farkli":
        gerekce.append("Soyadı farklı (evlilikle değişmiş olabilir)")
    if a.dogum_tarihi and a.dogum_tarihi == b.dogum_tarihi:
        puan += 40
        gerekce.append("Doğum tarihi aynı")
    if a.telefon and a.telefon == b.telefon:
        puan += 10
        gerekce.append("Telefon aynı (aileye ait olabilir)")
    if a.eposta and a.eposta == b.eposta:
        puan += 10
        gerekce.append("E-posta aynı (aileye ait olabilir)")

    if puan >= OTOMATIK:
        return "birlestir", puan, gerekce
    return ("incele" if puan >= INCELEME else "ayri"), puan, gerekce

Pu­an­lar key­fi de­ğil, sin­yal tab­lo­su­nun koda dö­kül­müş ha­li­dir. Fonk­si­yo­nun bazı se­nar­yo­lar­da­ki dav­ra­nı­şı şöy­le:

SenaryoPuanKarar
Baba ve oğul: ad, soyad, telefon, e-posta aynı; kimlik yok70İncele
Baba ve oğul: kimlik numaraları farklı—Ayrı
Baba ve oğul: doğum tarihleri farklı—Ayrı
Evlilikle soyadı değişmiş: ad, doğum tarihi, telefon aynı80İncele
”Ayşe Kaya” ve “Ayşe Yılmaz Kaya”: ad, doğum tarihi aynı85Birleştir
Aynı kimlik numarası, farklı soyadı100Birleştir

Dik­kat edin: Ad, so­yad, te­le­fon ve e-pos­ta bi­re­bir aynı ol­du­ğu hal­de baba ve oğul oto­ma­tik bir­leş­mi­yor; in­sa­na so­ru­lu­yor. So­ya­dı de­ğiş­miş ka­dın da bir­leş­ti­ril­mi­yor ama ka­çı­rıl­mı­yor; o da in­sa­na so­ru­lu­yor.

Eşik­le­ri tah­min­le koy­ma­yın. Bir­kaç yüz aday çif­ti iş ta­ra­fıy­la bir­lik­te elle eti­ket­le­yin (“aynı” / “fark­lı”) ve eşik­le­ri bu ör­ne­ğe göre ayar­la­yın. Oto­ma­tik eşi­ğin he­men üs­tün­de­ki çift­ler­de tek bir yan­lış bir­leş­tir­me gö­rü­yor­sa­nız eşik çok dü­şük­tür. Daha son­ra, in­san kont­ro­lün­den ge­len ka­rar­lar bu ör­ne­ği her haf­ta bü­yü­tür.

Bu ör­nek­te ad ben­zer­li­ği için Python’un stan­dart kütüphanesindeki difflib ye­ter­li. Mil­yon­lar­ca çift­te daha hız­lı bir ben­zer­lik kütüphanesine ge­çe­bi­lir ya da PostgreSQL’in pg_trgm ek­len­ti­siy­le ben­zer­li­ği doğ­ru­dan ve­ri­ta­ba­nın­da he­sap­la­ya­bi­lir­si­niz. Ka­rar man­tı­ğı aynı ka­lır.

Kararsız kayıtlar: insan gözüyle kontrol

“İn­ce­le” ka­ra­rı ve­ren çift­ler bir in­ce­le­me kuy­ru­ğu­na gi­der. Bu kuy­ruk te­kil­leş­tir­me­nin en az ko­nu­şu­lan ama en çok emek is­te­yen par­ça­sı­dır; iyi ta­sar­lan­maz­sa in­san­la­rın her gün aynı so­ru­yu tek­rar tek­rar ce­vap­la­dı­ğı bir yere dö­nü­şür.

Kim ba­kar? Sa­ha­da­ki ekip de­ğil, müş­te­ri ve­ri­si­nin sa­hi­bi. İn­ce­le­me kuy­ru­ğu onun iş lis­te­si­dir; kuy­ru­ğun yaşı da onun ta­kip et­ti­ği bir met­rik­tir.

Ek­ran­da ne gö­rür? İki ka­yıt yan yana, fark­lı olan alan­lar vur­gu­lu ve en önem­li­si, ka­ra­rın ge­rek­çe­si. Ka­rar ve­ren ki­şi­nin bir pu­anı de­ğil, ne­de­ni gör­me­si ge­re­kir:

Kayıt AKayıt B
Ad soyadMehmet KayaMehmet Kaya
Kimlik numarasıyokyok
Doğum tarihiyokyok
Telefon+905321234567+905321234567
E-postamehmet.kaya@gmail.commehmet.kaya@gmail.com
İlk kayıt20162025

Ne­den in­ce­le­me­ye gel­di? Ad, so­yad, te­le­fon ve e-pos­ta aynı; ama kim­lik nu­ma­ra­sı da do­ğum ta­ri­hi de yok. Te­le­fon ve e-pos­ta ai­le­ye ait ola­bi­lir. Puan 70; oto­ma­tik bir­leş­tir­me eşi­ği­nin al­tın­da.

Han­gi ka­rar­la­rı ve­re­bi­lir?

  • Aynı kişi: Çift bir son­ra­ki gece bir­leş­ti­ri­lir.
  • Fark­lı kişi: Çift bir daha öne­ril­mez.
  • İliş­ki­li: Baba ve oğul ya da fir­ma ve şu­be­si gibi; bir­leş­ti­ril­mez, bir iliş­kiy­le bağ­la­nır.
  • Emin de­ği­lim: Ka­yıt­la­rın sa­hi­bi olan tem­sil­ci­ye tek bir soru ola­rak gi­der: “Bu iki ka­yıt aynı kişi mi?”

Kuy­ruk ve ka­rar­lar aynı tab­lo­da du­rur. Çift her za­man kü­çük nu­ma­ra önde ya­zı­lır; böy­le­ce aynı çift iki fark­lı sı­ray­la iki kez ek­le­ne­mez:

CREATE TABLE inceleme_kuyrugu (
    musteri_a    bigint      NOT NULL,
    musteri_b    bigint      NOT NULL,
    puan         int         NOT NULL,
    gerekce      text        NOT NULL,
    eklendi      timestamptz NOT NULL DEFAULT now(),
    karar        text CHECK (karar IN ('ayni', 'farkli', 'iliskili', 'emin_degilim')),
    karar_veren  text,
    karar_zamani timestamptz,
    PRIMARY KEY (musteri_a, musteri_b),
    CHECK (musteri_a < musteri_b)
);

-- Gece akışı yeni adayları ekler; daha önce sorulmuş bir çift tekrar eklenmez.
INSERT INTO inceleme_kuyrugu (musteri_a, musteri_b, puan, gerekce)
VALUES (%(a)s, %(b)s, %(puan)s, %(gerekce)s)
ON CONFLICT (musteri_a, musteri_b) DO NOTHING;

Bu ta­sa­rı­mın iki so­nu­cu var:

  • “Fark­lı” ka­ra­rı ka­lı­cı­dır. Veri sa­hi­bi “bun­lar fark­lı kişi” de­di­ği bir çif­ti er­te­si gece tek­rar gör­mez. Bunu yap­ma­yan bir kuy­ruk, bir­kaç haf­ta için­de kim­se­nin aç­ma­dı­ğı bir lis­te­ye dö­nü­şür.
  • Ka­rar­lar eşik­le­ri ka­lib­re eder. Her ka­rar, eş­leş­tir­me­nin doğ­ru­lu­ğu­nu öl­çen eti­ket­li bir ör­nek­tir. Oto­ma­tik eşi­ğin he­men al­tın­da­ki çift­le­rin ne­re­dey­se hep­si “aynı” çı­kı­yor­sa eşik gü­ven­le aşa­ğı çe­ki­le­bi­lir; “fark­lı” çı­kan­lar ço­ğa­lı­yor­sa bir sin­ya­lin ağır­lı­ğı faz­la­dır.

Birleştirme ve ana kayıt

İki kay­dın aynı ol­du­ğu­na ka­rar ve­ril­dik­ten son­ra iki soru ka­lır: Han­gi bil­gi ka­la­cak ve bir­leş­tir­me na­sıl ya­pı­la­cak?

Han­gi bil­gi ka­la­cak? Alan ba­zın­da bir ön­ce­lik sı­ra­sı be­lir­le­yin: önce res­mi kay­nak, son­ra en gün­cel ka­yıt, son­ra en ek­sik­siz ka­yıt. Un­van res­mi kay­nak­tan, ad­res en son gün­cel­le­nen ka­yıt­tan, te­le­fon dolu olan ka­yıt­tan ge­lir. Bu ku­ral­la­rı bir­leş­tir­me anın­da ka­yıt­la­rın üze­ri­ne ya­za­rak de­ğil, bir­bi­ri­ne bağ­lan­mış ka­yıt­lar üze­rin­den he­sap­la­ya­rak uy­gu­la­yın. Bir­le­şen kay­dın de­ğer­le­ri si­lin­me­di­ği sü­re­ce bir­leş­tir­me her za­man geri alı­na­bi­lir.

Bir­leş­tir­me na­sıl ya­pı­la­cak? Mü­ker­rer ka­yıt si­lin­mez; “şu kay­da bir­leş­ti­ril­di” diye işa­ret­le­nir. Her bir­leş­tir­me, geri alı­na­bil­me­si için ge­re­ken bil­giy­le bir­lik­te bir bir­leş­tir­me kay­dı­na ya­zı­lır:

ALTER TABLE musteri ADD COLUMN birlestirildigi_id bigint REFERENCES musteri (id);

CREATE TABLE birlestirme_kaydi (
    id                bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kalan_id          bigint      NOT NULL REFERENCES musteri (id),
    birlesen_id       bigint      NOT NULL REFERENCES musteri (id),
    karar_veren       text        NOT NULL,  -- 'otomatik' ya da kişi
    gerekce           text        NOT NULL,
    tasinan_teklifler bigint[]    NOT NULL,  -- geri almak için
    zaman             timestamptz NOT NULL DEFAULT now(),
    geri_alindi       timestamptz
);

-- Bir kayıt aynı anda yalnızca bir kez birleşmiş olabilir.
CREATE UNIQUE INDEX birlestirme_tek ON birlestirme_kaydi (birlesen_id)
    WHERE geri_alindi IS NULL;

Bir­leş­tir­me­nin bü­tün adım­la­rı tek bir ve­ri­ta­ba­nı iş­le­min­de ya­pı­lır: ya hep­si olur ya hiç­bi­ri. Ya­rı­da ke­si­len bir bir­leş­tir­me, tek­lif­le­ri ta­şın­mış ama ken­di­si işa­ret­len­me­miş bir müş­te­ri bı­ra­kır; bu, mü­ker­rer ka­yıt­tan daha kötü bir du­rum­dur:

def birlestir(conn, kalan: int, birlesen: int, karar_veren: str, gerekce: str) -> bool:
    p = {"kalan": kalan, "birlesen": birlesen, "karar_veren": karar_veren, "gerekce": gerekce}
    with conn.transaction():
        aktif = conn.execute(
            """SELECT id FROM musteri
               WHERE id IN (%(kalan)s, %(birlesen)s) AND birlestirildigi_id IS NULL
               FOR UPDATE""",
            p,
        ).fetchall()
        if len(aktif) != 2:
            return False  # biri zaten birleşmiş; dokunma
        conn.execute(
            """INSERT INTO birlestirme_kaydi
                   (kalan_id, birlesen_id, karar_veren, gerekce, tasinan_teklifler)
               SELECT %(kalan)s, %(birlesen)s, %(karar_veren)s, %(gerekce)s,
                      coalesce(array_agg(id), '{}')
               FROM teklif WHERE musteri_id = %(birlesen)s""",
            p,
        )
        conn.execute("UPDATE teklif SET musteri_id = %(kalan)s WHERE musteri_id = %(birlesen)s", p)
        conn.execute("UPDATE musteri SET birlestirildigi_id = %(kalan)s WHERE id = %(birlesen)s", p)
    return True

FOR UPDATE iki kay­dı iş­lem bo­yun­ca ki­lit­ler. Aynı bir­leş­tir­me ikin­ci kez de­ne­nir­se fonk­si­yon hiç­bir şeye do­kun­ma­dan False dö­ner; bir şe­kil­de bu kont­rol at­lan­sa bile ben­zer­siz in­deks ikin­ci kay­dı red­de­der ve bü­tün iş­lem geri alı­nır. Ör­nek­te bağ­lı tab­lo ola­rak sa­de­ce tek­lif­ler var; si­pa­riş­ler, fa­tu­ra­lar, gö­rüş­me not­la­rı için aynı adı­mı tek­rar­la­yın.

Yan­lış bir bir­leş­tir­me­yi geri al­mak da tek bir iş­lem­dir. Bir­leş­tir­me kay­dı ne­yin ta­şın­dı­ğı­nı bil­di­ği için, bir­leş­tir­me­den son­ra açıl­mış yeni tek­lif­le­re do­kun­ma­dan sa­de­ce ta­şı­nan­la­rı geri gö­tü­rür:

-- %(kayit)s: geri alınacak birleştirme kaydının numarası. Üç ifade tek bir işlemde çalışır.
UPDATE teklif t SET musteri_id = k.birlesen_id
FROM birlestirme_kaydi k
WHERE k.id = %(kayit)s AND k.geri_alindi IS NULL AND t.id = ANY (k.tasinan_teklifler);

UPDATE musteri m SET birlestirildigi_id = NULL
FROM birlestirme_kaydi k
WHERE k.id = %(kayit)s AND k.geri_alindi IS NULL AND m.id = k.birlesen_id;

UPDATE birlestirme_kaydi SET geri_alindi = now()
WHERE id = %(kayit)s AND geri_alindi IS NULL;

Ben­zer­siz in­deks sa­de­ce geri alın­ma­mış bir­leş­tir­me­le­re uy­gu­lan­dı­ğı için, geri alın­mış bir çift daha son­ra doğ­ru ka­rar­la ye­ni­den bir­leş­ti­ri­le­bi­lir.

Her şey bir­leş­ti­ril­mez. Fir­ma ve şu­be­le­ri bir üst müş­te­ri iliş­ki­siy­le, aynı ha­ne­nin bi­rey­le­ri is­ter­se­niz bir hane iliş­ki­siy­le bağ­la­nır. Böy­le­ce “bu ai­le­nin bü­tün ka­yıt­la­rı” so­ru­su ce­vap­la­na­bi­lir, ama kim­se­nin fa­tu­ra­sı baş­ka­sı­na git­mez.

Prefect ile gece döngüsü

Bu­ra­ya ka­dar an­la­tı­lan her par­ça tek ba­şı­na ça­lı­şan bir fonk­si­yon. On­la­rı her gece aynı sı­ray­la, iz­le­ne­bi­lir ve hata du­ru­mun­da ha­ber ve­ren bir sü­re­ce bağ­la­yan şey or­kest­ras­yon:

yeni ve değişen kayıtlar → standartlaştır → resmi kaynaklarla zenginleştir
    → eşleştir → [onay kapısı] → birleştir → raporla
                      │
                      └─ belirsiz çiftler → inceleme kuyruğu

Akış bi­le­rek ince tu­tul­du: İş man­tı­ğı yu­ka­rı­da­ki fonk­si­yon­lar­da ve pro­je­nin ken­di mo­dül­le­rin­de du­rur, akış sa­de­ce sı­ra­yı, tek­rar de­ne­me­yi, ko­ta­yı ve ona­yı yö­ne­tir:

from datetime import datetime, timedelta, timezone

from prefect import flow, task
from prefect.artifacts import create_table_artifact
from prefect.cache_policies import INPUTS
from prefect.concurrency.sync import concurrency, rate_limit
from prefect.flow_runs import pause_flow_run
from prefect.input import RunInput
from prefect.logging import get_run_logger
from prefect.tasks import exponential_backoff
from prefect.variables import Variable

from musteri import depo, entegrator, eslestirme  # projenizin kendi modülleri

SON_CALISMA = "musteri_tekillestirme_son_calisma"


class BirlestirmeOnayi(RunInput):
    onay: bool
    aciklama: str = ""


@task(retries=3, retry_delay_seconds=exponential_backoff(backoff_factor=10))
def degisenleri_cek(baslangic: datetime) -> list[dict]:
    """Son çalışmadan beri eklenen ya da değişen müşteri kayıtları."""
    return depo.degisen_musteriler(baslangic)


@task
def standartlastir(kayitlar: list[dict]) -> list[dict]:
    """Ham değerlere dokunmadan standart sütunları hesaplar ve yazar."""
    return depo.standart_degerleri_yaz(kayitlar)


@task(
    retries=3,
    retry_delay_seconds=exponential_backoff(backoff_factor=30),
    cache_policy=INPUTS,
    cache_expiration=timedelta(days=30),
    persist_result=True,
)
def resmi_bilgi(vkn: str) -> dict | None:
    """Vergi numarasının resmi unvanı. Aynı numara 30 gün içinde tekrar sorgulanmaz."""
    rate_limit("entegrator-api")
    return entegrator.mukellef_sorgula(vkn)


@task
def adaylari_bul(kayitlar: list[dict]) -> tuple[list[dict], list[dict]]:
    """(otomatik birleşecek çiftler, incelemeye gidecek çiftler)"""
    return eslestirme.aday_ciftler(kayitlar)


@task
def kuyruga_yaz(ciftler: list[dict]) -> None:
    depo.inceleme_kuyruguna_ekle(ciftler)  # ON CONFLICT DO NOTHING


@task  # Retry yok: yarım kalmış bir birleştirmeyi tekrar denemek yeni bir risk yaratır.
def birlestir(cift: dict) -> bool:
    return depo.birlestir(cift["kalan"], cift["birlesen"], cift["karar_veren"], cift["gerekce"])


@flow(name="musteri-tekillestirme")
def musteri_tekillestirme(tam_tarama: bool = False, onay_iste: bool = False) -> None:
    logger = get_run_logger()
    simdi = datetime.now(timezone.utc)
    baslangic = datetime(1970, 1, 1, tzinfo=timezone.utc)
    if not tam_tarama:
        baslangic = datetime.fromisoformat(Variable.get(SON_CALISMA, default=baslangic.isoformat()))

    kayitlar = standartlastir(degisenleri_cek(baslangic))
    for kayit in kayitlar:
        if kayit["vkn_gecerli"]:
            kayit["resmi"] = resmi_bilgi(kayit["vkn"])

    otomatik, belirsiz = adaylari_bul(kayitlar)
    kuyruga_yaz(belirsiz)
    otomatik += depo.onaylanan_inceleme_kararlari()  # veri sahibinin "aynı" dediği çiftler

    create_table_artifact(
        key="musteri-tekillestirme",
        table=[{
            "taranan": len(kayitlar),
            "otomatik_birlesme": len(otomatik),
            "incelemeye_giden": len(belirsiz),
            "bekleyen_kuyruk": depo.bekleyen_inceleme_sayisi(),
        }],
        description="Gece tekilleştirme özeti",
    )

    if onay_iste and otomatik:
        onay = pause_flow_run(wait_for_input=BirlestirmeOnayi, timeout=8 * 60 * 60)
        if not onay.onay:
            logger.info("Birleştirme onaylanmadı: %s", onay.aciklama)
            return

    with concurrency("musteri-birlestirme", occupy=1):
        birlesen = sum(birlestir(cift) for cift in otomatik)
    logger.info("%s kayıt birleştirildi.", birlesen)

    Variable.set(SON_CALISMA, simdi.isoformat(), overwrite=True)

Akış­ta­ki her ayar bir so­ru­nu çö­zü­yor:

  • Retry sa­de­ce oku­ma­da. Kay­nak­tan oku­mak ve res­mi kay­na­ğı sor­gu­la­mak, ge­çi­ci bir ha­ta­da tek­rar de­ne­ne­bi­le­cek ka­dar gü­ven­li­dir; ar­tan bek­le­me sü­re­le­riy­le (exponential_backoff) kar­şı ta­ra­fa yük­len­me­den ye­ni­den de­ne­nir. Bir­leş­tir­me­de retry yok­tur. Pre­fect ya­zı­sın­da an­lat­tı­ğım gibi, retry bir gü­ven­lik ağı de­ğil bir çar­pan­dır; ya­zan bir adı­mı kör­le­me­si­ne tek­rar­la­mak, so­ru­nu da tek­rar­lar.
  • Kota ve ma­li­yet. rate_limit("entegrator-api"), res­mi kay­na­ğa atı­lan sor­gu­la­rı be­lir­le­di­ği­niz hız­la sı­nır­lar. cache_policy=INPUTS ve cache_expiration=timedelta(days=30) ise aynı ver­gi nu­ma­ra­sı­nın otuz gün için­de ikin­ci kez sor­gu­lan­ma­sı­nı ön­ler; so­nuç Prefect’in sak­la­dı­ğı ön­bel­lek­ten ge­lir. Ön­bel­le­ğin ça­lış­ma­sı için so­nu­cun sak­lan­ma­sı ge­re­kir; persist_result=True bu yüz­den ora­da.
  • Kal­dı­ğı yer. Gece akı­şı sa­de­ce son ba­şa­rı­lı ça­lış­ma­dan beri de­ği­şen ka­yıt­lar­la ça­lı­şır. Son ça­lış­ma za­ma­nı bir Pre­fect de­ğiş­ke­nin­de (Variable) tu­tu­lur ve an­cak akış ba­şa­rıy­la bi­ter­se gün­cel­le­nir; ya­rı­da ka­lan bir gece, er­te­si gece aynı yer­den ye­ni­den baş­lar.
  • Ça­kış­ma. Bir­leş­tir­me adı­mı concurrency("musteri-birlestirme") ile tek ki­şi­lik bir ki­li­din için­de ça­lı­şır. Elle baş­la­tıl­mış bir te­miz­lik ile gece akı­şı aynı anda ça­lış­sa bile aynı müş­te­ri­yi iki fark­lı kay­da bir­leş­ti­re­mez.
  • Ra­por. Her ça­lış­ma Pre­fect ara­yü­zü­ne kü­çük bir özet tab­lo bı­ra­kır. Hep aynı anah­tar­la (musteri-tekillestirme) ya­yım­lan­dı­ğı için ara­yüz­de gece gece na­sıl de­ğiş­ti­ği de gö­rü­nür: Bek­le­yen kuy­ruk bü­yü­yor mu, oto­ma­tik bir­leş­me sa­yı­sı dü­şü­yor mu?

Onay ka­pı­sı. İlk bü­yük te­miz­lik, her gece ya­pı­lan kü­çük ba­kım­dan fark­lı­dır: Yıl­lar­dır bi­ri­ken ve­ri­de tek se­fer­de bin­ler­ce bir­leş­tir­me öne­ri­le­bi­lir. Bu ça­lış­ma­da akış bir­leş­tir­me­ye geç­me­den önce özet tab­lo­yu ya­yım­lar ve pause_flow_run ile du­rur. Pre­fect ara­yü­zün­de bir onay for­mu açı­lır; veri sa­hi­bi öze­te ba­kar, “onay” ku­tu­su­nu işa­ret­ler ya da ge­rek­çe­si­ni ya­za­rak red­de­der. Veri sa­hi­bi onay­la­ma­dan tek bir ka­yıt bile bir­leş­mez. CRM ya­zı­sın­da­ki “Can­lı­ya geç­me­den önce bu ve­ri­ye kim ke­fil ola­cak?” so­ru­su­nun tek­nik ce­va­bı bu­dur.

pause_flow_run akı­şı bel­lek­te tu­ta­rak bek­le­tir; var­sa­yı­lan bek­le­me sü­re­si bir sa­at­tir ve süre do­lar­sa ça­lış­ma ba­şa­rı­sız sa­yı­lır. Ör­nek­te bu yüz­den se­kiz sa­at­lik bir süre ve­ril­di. Onay sa­de­ce elle baş­la­tı­lan ilk te­miz­lik­te is­te­nir; gece akı­şı onay bek­le­mez. Uzun bek­le­me­le­ri za­man­lan­mış bir ça­lış­ma­da da is­ti­yor­sa­nız suspend_flow_run ça­lış­ma­yı ta­ma­men dur­du­rur ve su­nu­cu kay­na­ğı­nı boşa tut­maz; ama bu­nun için ça­lış­ma­nın bir deployment’tan baş­la­tıl­mış ol­ma­sı ve so­nuç­la­rın sak­lan­ma­sı ge­re­kir.

Akış onay bek­le­me­ye geç­ti­ğin­de veri sa­hi­bi­nin bun­dan ha­be­ri ol­ma­lı. Prefect’te bir oto­mas­yon ta­nım­la­yın: Te­tik­le­yi­ci “Flow run sta­te chan­ge”, du­rum Pa­used, ey­lem veri sa­hi­bi­ne bir bil­di­rim. Aynı oto­mas­yo­nu Fa­iled du­ru­mu için de ku­run; gece akı­şı ses­siz­ce dur­ma­sın.

Deployment’lar. Pre­fect ya­zı­sın­da­ki ilke bu­ra­da da ge­çer­li: Deployment’lar kod­da du­rur. Aynı akış­tan iki dep­loy­ment çı­kar; biri her gece ça­lı­şır, di­ğe­ri ilk bü­yük te­miz­lik için elle baş­la­tı­lır:

from prefect import Flow
from prefect.schedules import Cron

akis = Flow.from_source(source="/app", entrypoint="flows/musteri_tekillestirme.py:musteri_tekillestirme")

# Her gece: sadece yeni ve değişen kayıtlar.
akis.deploy(
    name="musteri-tekillestirme-gece",
    work_pool_name="varsayilan-havuz",
    schedule=Cron("0 2 * * *", timezone="Europe/Istanbul"),
    tags=["veri-kalite", "musteri"],
)

# İlk büyük temizlik: elle başlatılır, bütün veriyi tarar ve birleştirmeden önce onay ister.
akis.deploy(
    name="musteri-tekillestirme-ilk-temizlik",
    work_pool_name="varsayilan-havuz",
    parameters={"tam_tarama": True, "onay_iste": True},
    tags=["veri-kalite", "musteri"],
)

Akı­şın kul­lan­dı­ğı iki li­mit bir kez oluş­tu­ru­lur: biri bir­leş­tir­me­yi tek ki­şi­lik ya­pan ki­lit, di­ğe­ri res­mi kay­na­ğa or­ta­la­ma sa­ni­ye­de bir sor­gu gön­de­ren hız li­mi­ti. Ör­nek­te­ki ayar en faz­la beş sor­gu­luk kısa ani ar­tış­la­ra izin ve­rir, son­ra hızı sa­ni­ye­de bire dü­şü­rür. De­ğer­le­ri ken­di en­teg­ra­tö­rü­nü­zün ko­ta­sı­na göre ayar­la­yın:

prefect gcl create musteri-birlestirme --limit 1
prefect gcl create entegrator-api --limit 5 --slot-decay-per-second 1

Sahaya geri bildirim, ama yük değil

Sa­ha­da­ki ekip bu sü­re­cin bü­yük kıs­mı­nı hiç gör­mez; gör­me­me­li­dir de. Te­le­fon nu­ma­ra­sı­nı stan­dar­da çek­mek, un­va­nı kat­la­mak, mü­ker­rer kay­dı bir­leş­tir­mek sis­te­min işi­dir. Ekip sa­de­ce sis­te­min ger­çek­ten çö­ze­me­di­ği ka­yıt­lar­dan ha­ber­dar olur, haf­ta­da bir kısa bir lis­tey­le:

Bu haf­ta si­zin müş­te­ri­le­ri­niz­den üçün­de dü­zelt­me ge­re­ki­yor: İki fir­ma­nın ver­gi nu­ma­ra­sı res­mi kay­nak­ta bu­lu­na­ma­dı, bir müş­te­ri­nin iki kay­dı için “aynı kişi mi?” so­ru­su sizi bek­li­yor.

Lis­te kısa ol­ma­lı; uzun bir lis­te, so­ru­nun sa­ha­da de­ğil ku­ral­lar­da ol­du­ğu­nu gös­te­rir. “Yeni müş­te­ri ek­le­me­den önce bir ara­ma ya­pın” alış­kan­lı­ğı da bir ku­ral de­ğil, öne­ri ola­rak ka­lır. Mü­ker­rer kay­dı ön­le­mek gü­zel­dir; ama asıl gü­ven­ce, oluş­tu­ğun­da onu bu­lan sü­reç­tir.

Ölçmek

Pre­fect ya­zı­sın­da­ki son ilke bu­ra­da da ge­çer­li: Akı­şın ça­lış­mış ol­ma­sı de­ğil, ve­ri­nin iyi­leş­miş ol­ma­sı önem­li­dir. Beş met­rik bunu gös­te­rir:

MetrikNeyi gösterir
Standarda uyum oranıKayıtların yüzde kaçı otomatik olarak standarda çekilebiliyor
Resmi kaynakla doğrulanmış kayıt oranıVergi numarası resmi kaynakla eşleşen firmaların oranı
Mükerrer kayıt oranıBekleyen ve birleştirilen aday çiftlerin toplam kayda oranı
İnceleme kuyruğunun yaşıEn eski bekleyen çiftin kaç gündür beklediği
Geri alınan birleştirme sayısıYanlış birleştirmenin doğrudan ölçüsü; sıfıra yakın kalmalı

Kuy­ru­ğun yaşı tek sa­tır­lık bir sor­gu­dur:

SELECT count(*) AS bekleyen,
       date_part('day', now() - min(eklendi)) AS en_eski_gun
FROM inceleme_kuyrugu
WHERE karar IS NULL;

Geri alı­nan bir­leş­tir­me­ler ar­tı­yor­sa oto­ma­tik eşik çok dü­şük­tür; in­ce­le­me kuy­ru­ğu bü­yü­yor­sa ya eşik çok yük­sek­tir ya da veri sa­hi­bi­nin bu işe ayır­dı­ğı za­man yet­mi­yor­dur. İki du­rum­da da çö­züm akış­ta de­ğil, ka­rar­lar­da­dır.

Kontrol listesi

  • Veri stan­dar­dı ya­zı­lı; her ala­nın stan­dart bi­çi­mi ve sa­hi­bi bel­li.
  • Ham de­ğer­ler ko­ru­nu­yor; stan­dart ve res­mi de­ğer­ler ayrı sü­tun­lar­da.
  • Eş­leş­tir­me anah­tar­la­rı dile bağ­lı lower() ye­ri­ne sa­bit bir Türk­çe kat­la­ma tab­lo­suy­la üre­ti­li­yor.
  • Ak­ta­rım­lar­da kod­la­ma ha­ta­la­rı öl­çü­lü­yor; eşik aşı­lın­ca ak­ta­rım du­ru­yor.
  • Ver­gi ve kim­lik nu­ma­ra­la­rı­nın kont­rol ha­ne­le­ri doğ­ru­la­nı­yor; ge­çer­siz­ler işa­ret­le­ni­yor, si­lin­mi­yor.
  • Kim­lik nu­ma­ra­sı şif­re­li ve mas­ke­li; eş­leş­tir­me anah­tar­lı özet­le ya­pı­lı­yor.
  • Fark­lı ge­çer­li kim­lik nu­ma­ra­la­rı ve fark­lı do­ğum ta­rih­le­ri ke­sin ay­rım sa­yı­lı­yor.
  • Ad, so­yad ve ile­ti­şim bil­gi­si hiç­bir za­man tek ba­şı­na oto­ma­tik bir­leş­tir­me­ye yet­mi­yor.
  • So­ya­dı ke­li­me ke­li­me kar­şı­laş­tı­rı­lı­yor; fark­lı so­ya­dı ay­rım ka­nı­tı sa­yıl­mı­yor.
  • Eşik­ler elle eti­ket­len­miş bir ör­nek­le ayar­lan­dı ve in­ce­le­me ka­rar­la­rıy­la gün­cel­le­ni­yor.
  • “Fark­lı” ka­rar­la­rı ka­lı­cı; aynı çift tek­rar öne­ril­mi­yor.
  • Bir­leş­tir­me tek bir ve­ri­ta­ba­nı iş­le­min­de, ka­yıt al­tın­da ve geri alı­na­bi­lir.
  • Şu­be­ler ve aile bi­rey­le­ri bir­leş­ti­ril­mi­yor, iliş­ki­len­di­ri­li­yor.
  • Res­mi kay­nak sor­gu­la­rı hız li­mit­li, ön­bel­lek­li ve tek­rar de­ne­me­li.
  • İlk bü­yük te­miz­lik veri sa­hi­bi­nin ona­yı ol­ma­dan bir­leş­tir­me yap­mı­yor.
  • Akış onay bek­le­di­ğin­de ya da hata ver­di­ğin­de veri sa­hi­bi­ne ha­ber gi­di­yor.

Bonus: Bir yapay zeka asistanına kurdurmak

Aşa­ğı­da­ki prompt’u Cla­ude Code, Cur­sor ya da ben­ze­ri bir kod­la­ma asis­ta­nı­na ve­re­bi­lir­si­niz. Kö­şe­li pa­ran­tez­li yer­le­ri ken­di ve­ri­ni­ze göre dol­du­run:

Sen kıdemli bir veri mühendisisin. Python, PostgreSQL ve Prefect 3 ile müşteri
verisi için bir tekilleştirme süreci kuracağız. Aşağıdaki kurallara harfiyen uy;
bir kuralı esnetmen gerekirse önce bana sor.

## Veri

- Müşteri tablosu ve alanları: [tablo ve sütun listesini buraya yapıştır]
- Bağlı tablolar (birleştirmede taşınacaklar): [ör. teklif, siparis, fatura]
- Müşteri tipleri: [ör. firma (vergi numarası), kişi (kimlik numarası)]
- Resmi kaynak: [ör. e-fatura entegratörünün mükellef sorgulama API'si]

## Kurallar

1. Ham değerlere asla dokunma. Standart değerler ve resmi kaynaktan gelen
   değerler ayrı sütunlarda tutulur.
2. Eşleştirme anahtarlarında dile bağlı lower()/upper() kullanma. İ, I, ı ve i
   önce tek harfe iner, Türkçe harfler sabit bir tabloyla ASCII'ye katlanır.
3. Vergi ve kimlik numaralarının kontrol hanelerini doğrula; geçersizleri sil-
   me, işaretle. Kimlik numarası eşleştirmede HMAC özetiyle kullanılır; anahtar
   Prefect Secret block'ta durur.
4. Kesin kurallar: aynı geçerli kimlik numarası = aynı kişi; farklı geçerli
   kimlik numaraları ya da farklı doğum tarihleri = farklı kişi. Ad, soyad,
   telefon ve e-posta asla tek başına otomatik birleştirme sebebi olmaz.
   Soyadı kelime kelime karşılaştırılır; farklı soyadı ayrım kanıtı değildir.
5. Kararlar üç eşikle verilir: otomatik birleştir [85], incele [50], ayrı.
   Her karar gerekçe listesiyle birlikte döner.
6. Belirsiz çiftler inceleme_kuyrugu tablosuna yazılır (küçük numara önde,
   ON CONFLICT DO NOTHING). "Farklı" kararı verilmiş çift tekrar önerilmez.
7. Birleştirme tek bir veritabanı işleminde yapılır: iki kayıt FOR UPDATE ile
   kilitlenir, bağlı kayıtlar taşınır, mükerrer kayıt silinmez işaretlenir,
   taşınanlar birlestirme_kaydi tablosuna yazılır. Koşullu benzersiz indeks
   aynı kaydın iki kez birleşmesini engeller. Geri alma prosedürü de yaz.
8. Prefect akışında retry sadece okuma ve resmi kaynak adımlarında olur.
   Resmi kaynak sorgusu rate_limit ile sınırlanır ve 30 gün önbelleklenir.
   Birleştirme tek kişilik bir concurrency limitinin içinde çalışır. Son
   çalışma zamanı bir Prefect Variable'da tutulur. tam_tarama ve onay_iste
   parametreleriyle ilk temizlik, birleştirmeden önce pause_flow_run ile
   veri sahibinden onay ister.

## İlk görev

Tabloları, standartlaştırma ve doğrulama fonksiyonlarını birim testleriyle
birlikte yaz. Testlerde Türkçe karakter tuzaklarını, baba-oğul ve evlilikle
değişen soyadı senaryolarını mutlaka kapsa. Örnek veriler kurgusal olsun.
Her adımdan sonra ne yaptığını kısaca özetle ve varsayım yaptığın yerleri
açıkça belirt.

Özet

  • Saha ser­best­çe gi­rer, sis­tem ge­ce­le­ri stan­dar­da çe­ker. Veri stan­dar­dı ya­zı­lı­dır; ham de­ğer­ler ko­ru­nur, stan­dart ve res­mi de­ğer­ler ya­nın­da du­rur.
  • Kim­lik her şey­den önce ge­lir. Fir­ma­da ver­gi nu­ma­ra­sı, ki­şi­de kim­lik nu­ma­ra­sı en gü­ve­ni­lir ta­nım­la­yı­cı­dır. Fark­lı kim­lik nu­ma­ra­la­rı, geri ka­lan her şe­yin aynı ol­ma­sın­dan daha güç­lü bir ka­nıt­tır.
  • Ad, so­yad ve ile­ti­şim bil­gi­si yet­mez. Baba ile oğul aynı adı, aynı te­le­fo­nu, aynı e-pos­ta­yı ta­şı­ya­bi­lir; so­ya­dı ev­li­lik­le de­ği­şe­bi­lir.
  • Türk­çe ka­rak­ter­ler dile bağ­lı fonk­si­yon­la­ra ema­net edil­mez. Eş­leş­tir­me anah­ta­rı sa­bit bir kat­la­ma tab­lo­suy­la üre­ti­lir; kod­la­ma ha­ta­la­rı içe­ri alır­ken ya­ka­la­nır.
  • Res­mi kay­nak, ser­best me­tin­den gü­ve­ni­lir­dir. Ver­gi nu­ma­ra­sın­dan ge­len res­mi un­van, eş­leş­tir­me­nin en ucuz ve en güç­lü sin­ya­li­dir.
  • Ma­ki­ne emin ola­ma­dı­ğın­da in­sa­na so­rar. Ka­rar­lar ge­rek­çey­le bir­lik­te gös­te­ri­lir; “fark­lı” ka­ra­rı ka­lı­cı­dır, ka­rar­lar eşik­le­ri ka­lib­re eder.
  • Bir­leş­tir­me geri alı­na­bi­lir, tek bir ve­ri­ta­ba­nı iş­le­min­de ya­pı­lır ve ka­yıt al­tın­da­dır.
  • Pre­fect sü­re­ci ta­şır: oku­ma­da retry, res­mi kay­nak­ta hız li­mi­ti ve ön­bel­lek, bir­leş­tir­me­de ki­lit, ilk te­miz­lik­te onay ka­pı­sı ve her sa­bah bir özet.

Te­kil­leş­tir­me bir te­miz­lik pro­je­si de­ğil, bir sa­hip­lik me­se­le­si­dir: Veri ka­li­te­si sa­ha­da­ki eki­bin de­ğil, sü­re­cin so­rum­lu­lu­ğu­dur.

Okumaya devam