e-Fatura XML'ini Otomasyona Bağlamak: UBL Ayrıştırma ve Muhasebe Mutabakatı
UBL-TR e-Fatura XML dosyalarını Python ile başlık ve kalem tablosuna çevirme, Decimal ile KDV kırılımı ve muhasebe kayıtlarıyla otomatik mutabakat yöntemi.
e-Fatura XML ayrıştırma, muhasebe tarafında elle yapılan en tekrarlı işlerden birini otomasyona bağlar. Entegratör panelinden indirilen yüzlerce XML dosyası, içinde fatura numarası, taraflar, kalemler ve KDV kırılımı olan yapılandırılmış belgelerdir; buna rağmen çoğu ekip bu bilgiyi PDF görüntüsünden okuyup e-tabloya elle girer. Oysa XML'in kendisi tablo olmaya hazırdır.
Türkiye'de e-Fatura ve e-Arşiv belgeleri UBL-TR biçiminde üretilir. UBL-TR, uluslararası UBL 2.1 standardının Gelir İdaresi Başkanlığı tarafından Türkiye için daraltılmış ve zorunlu alanları belirlenmiş halidir. Ad alanları, zorunlu alanlar ve para alanlarının biçimi belli olduğundan, doğru yazılmış tek bir ayrıştırıcı farklı entegratörlerden gelen dosyaları aynı şekilde okur.
Bu yazıda bir klasördeki XML'leri gezip fatura başlığı ve kalem tablolarını üreten bir ayrıştırıcı, ardından bu tabloları muhasebe kaydıyla karşılaştıran bir mutabakat betiği kuruyoruz. Kod standart kütüphaneyle çalışır; Decimal ile para hesaplar, hatalı dosyayı atlayıp raporlar.
UBL-TR faturasının yapısı ve ad alanları
UBL-TR faturası, kök öğesi Invoice olan ve üç ad alanı kullanan bir XML belgesidir: kök için Invoice-2, tekil değerler için CommonBasicComponents-2 (kısaltması cbc), bileşik yapılar için CommonAggregateComponents-2 (kısaltması cac). Ad alanı belirtilmeden yapılan arama hiçbir öğeyi bulmaz; ayrıştırıcının ilk işi bu üç adresi bir sözlüğe koymaktır.
Başlık düzeyinde en çok kullanılan alanlar cbc:ID (fatura numarası), cbc:UUID (evrensel kimlik), cbc:IssueDate (tarih), cbc:ProfileID (temel, ticari ya da e-Arşiv senaryosu), cbc:InvoiceTypeCode (satış, iade gibi tür) ve cbc:DocumentCurrencyCode (para birimi) alanlarıdır. Satıcı cac:AccountingSupplierParty, alıcı cac:AccountingCustomerParty altında durur; vergi kimlik numarası cac:PartyIdentification/cbc:ID öğesinde, schemeID özniteliği VKN ya da TCKN olarak gelir.
Para toplamları iki yerde bulunur. cac:LegalMonetaryTotal altında matrah (TaxExclusiveAmount), KDV dahil tutar (TaxInclusiveAmount) ve ödenecek tutar (PayableAmount) vardır. cac:TaxTotal altında toplam vergi ve her oran için ayrı cac:TaxSubtotal kırılımı bulunur; KDV'nin vergi türü kodu cac:TaxScheme/cbc:TaxTypeCode içinde 0015 olarak yazılır. Kalemler cac:InvoiceLine öğelerinde sıralanır ve her biri kendi miktar, birim fiyat, tutar ve vergi bilgisini taşır.
Zorunlu ve opsiyonel alan ayrımı ayrıştırıcının hata davranışını belirler:
- Zorunlu alanlar (ID, UUID, IssueDate, taraflar, LegalMonetaryTotal) eksikse dosya hatalı sayılır ve rapora düşer.
- Opsiyonel alanlar (kişi adı, ek belge referansı, not) eksikse boş değerle devam edilir.
- Şahıs faturalarında PartyName yerine cac:Person altında ad ve soyad gelebilir; ayrıştırıcı iki yolu da denemelidir.
Klasördeki XML'leri başlık ve kalem tablosuna çeviren ayrıştırıcı
Ayrıştırıcı, belirtilen klasördeki her XML dosyasını açar, başlık alanlarını tek satıra ve kalemleri fatura numarasıyla ilişkili satırlara çevirir, sonucu iki CSV dosyasına yazar. Ayrıştırılamayan dosya sürecin tamamını durdurmaz; adı ve hata mesajı listeye eklenir, döngü devam eder. Para alanları string olarak okunup Decimal'e çevrilir; float hiçbir aşamada kullanılmaz.
İki tablo yaklaşımı, e-tablodan alışık olunan tek düz tabloya göre daha esnektir. Başlık tablosu mutabakat ve raporlama için yeterlidir; kalem tablosu ürün bazlı analiz ve KDV kırılımı için ayrıca kullanılır. İki tabloyu SQLite'a yükleyip sorgulamak da mümkündür; bu düzenin kuruluşu SQLite ile küçük ama sağlam veri boru hatları yazısında anlatılmıştı.
import csv
import xml.etree.ElementTree as ET
from decimal import Decimal
from pathlib import Path
NS = {
"inv": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
"cbc": "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
"cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
}
def metin(node, yol, varsayilan=""):
hedef = node.find(yol, NS)
return hedef.text.strip() if hedef is not None and hedef.text else varsayilan
def para(node, yol):
return Decimal(metin(node, yol, "0"))
def taraf(node, yol):
p = node.find(yol + "/cac:Party", NS)
if p is None:
raise ValueError("taraf eksik: " + yol)
kimlik = metin(p, "cac:PartyIdentification/cbc:ID")
ad = metin(p, "cac:PartyName/cbc:Name")
if not ad:
ad = (metin(p, "cac:Person/cbc:FirstName") + " "
+ metin(p, "cac:Person/cbc:FamilyName")).strip()
return kimlik, ad
def fatura_oku(dosya):
kok = ET.parse(dosya).getroot()
satici_vkn, satici_ad = taraf(kok, "cac:AccountingSupplierParty")
alici_vkn, alici_ad = taraf(kok, "cac:AccountingCustomerParty")
baslik = {
"dosya": dosya.name,
"fatura_no": metin(kok, "cbc:ID"),
"uuid": metin(kok, "cbc:UUID"),
"tarih": metin(kok, "cbc:IssueDate"),
"profil": metin(kok, "cbc:ProfileID"),
"tip": metin(kok, "cbc:InvoiceTypeCode"),
"para_birimi": metin(kok, "cbc:DocumentCurrencyCode"),
"satici_vkn": satici_vkn,
"satici_ad": satici_ad,
"alici_vkn": alici_vkn,
"alici_ad": alici_ad,
"matrah": para(kok, "cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount"),
"kdv": para(kok, "cac:TaxTotal/cbc:TaxAmount"),
"odenecek": para(kok, "cac:LegalMonetaryTotal/cbc:PayableAmount"),
}
if not baslik["fatura_no"] or not baslik["uuid"] or not baslik["tarih"]:
raise ValueError("zorunlu başlık alanı eksik")
kalemler = []
for satir in kok.findall("cac:InvoiceLine", NS):
miktar = satir.find("cbc:InvoicedQuantity", NS)
kalemler.append({
"fatura_no": baslik["fatura_no"],
"sira": metin(satir, "cbc:ID"),
"urun": metin(satir, "cac:Item/cbc:Name"),
"miktar": miktar.text if miktar is not None else "",
"birim": miktar.get("unitCode", "") if miktar is not None else "",
"birim_fiyat": para(satir, "cac:Price/cbc:PriceAmount"),
"tutar": para(satir, "cbc:LineExtensionAmount"),
"kdv_oran": metin(satir, "cac:TaxTotal/cac:TaxSubtotal/cbc:Percent"),
"kdv": para(satir, "cac:TaxTotal/cbc:TaxAmount"),
})
return baslik, kalemler
def yaz(yol, satirlar):
if not satirlar:
return
with open(yol, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(satirlar[0]))
w.writeheader()
w.writerows(satirlar)
if __name__ == "__main__":
basliklar, kalemler, hatali = [], [], []
for dosya in sorted(Path("faturalar").glob("*.xml")):
try:
b, k = fatura_oku(dosya)
except (ET.ParseError, ValueError, ArithmeticError) as hata:
hatali.append((dosya.name, str(hata)))
continue
basliklar.append(b)
kalemler.extend(k)
yaz("fatura_baslik.csv", basliklar)
yaz("fatura_kalem.csv", kalemler)
for ad, hata in hatali:
print("HATALI", ad, hata)
print(len(basliklar), "fatura,", len(kalemler), "kalem,", len(hatali), "hatalı")
KDV kırılımı ve yuvarlama için üç kural yeterlidir. Birincisi, tutarlar dosyada yazıldığı haliyle Decimal olarak alınır ve yeniden hesaplanmaz; kalem tutarlarının toplamı başlıktaki matrahla karşılaştırılır, eşleşmiyorsa fark rapora yazılır. İkincisi, para birimi TRY dışında ise cbc:PayableAmount alanının currencyID özniteliği ve varsa kur bilgisi ayrı sütuna alınır; dönüşüm ayrıştırıcıda yapılmaz. Üçüncüsü, oran bazlı toplamlar cac:TaxSubtotal öğelerinden okunur, kalemlerden yeniden türetilmez.
Muhasebe kayıtlarıyla mutabakat
Mutabakat, XML'den çıkan başlık tablosunu muhasebe programından alınan belge listesiyle fatura numarası üzerinden eşleştiren ve dört sonuç üreten karşılaştırmadır: eşleşen, tutar farkı olan, muhasebede olmayan ve XML'de olmayan kayıtlar. Karşılaştırma tutarı kuruş toleransıyla yapılır; yuvarlama farkı ile gerçek eksiklik ayrı sınıflara düşer.
Muhasebe tarafındaki dışa aktarımda ondalık ayırıcı virgül olabilir, belge numarası boşluk ya da öntakı taşıyabilir. Bu yüzden karşılaştırma öncesi anahtarlar ve tutarlar normalize edilir. Aynı belge numarasının iki kez geçmesi de ayrı bir sınıftır; mükerrer kayıt eşleşmeyi bozar ve tek başına raporlanır.
import csv
from collections import defaultdict
from decimal import Decimal
TOLERANS = Decimal("0.01")
def normalize_no(no):
return no.strip().upper().replace(" ", "")
def oku(yol, anahtar, tutar):
kayit = defaultdict(list)
with open(yol, newline="", encoding="utf-8") as f:
for satir in csv.DictReader(f):
deger = Decimal(satir[tutar].strip().replace(".", "").replace(",", ".")
if "," in satir[tutar] else satir[tutar].strip())
kayit[normalize_no(satir[anahtar])].append(deger)
return kayit
def mutabakat(xml_yolu, muhasebe_yolu):
xml = oku(xml_yolu, "fatura_no", "odenecek")
muh = oku(muhasebe_yolu, "belge_no", "tutar")
sonuc = {"eslesti": [], "tutar_farki": [], "muhasebede_yok": [],
"xmlde_yok": [], "mukerrer": []}
for no, tutarlar in xml.items():
if len(tutarlar) > 1 or len(muh.get(no, [])) > 1:
sonuc["mukerrer"].append(no)
continue
if no not in muh:
sonuc["muhasebede_yok"].append(no)
continue
fark = abs(tutarlar[0] - muh[no][0])
hedef = "eslesti" if fark <= TOLERANS else "tutar_farki"
sonuc[hedef].append((no, fark))
sonuc["xmlde_yok"] = sorted(set(muh) - set(xml))
return sonuc
if __name__ == "__main__":
s = mutabakat("fatura_baslik.csv", "muhasebe_kayitlari.csv")
for sinif, liste in s.items():
print("%-15s %d" % (sinif, len(liste)))
for no, fark in s["tutar_farki"]:
print(" fark:", no, fark)
for no in s["muhasebede_yok"]:
print(" muhasebede yok:", no)
Betiğin çıktısı muhasebe ekibine giden günlük ya da haftalık kontrol listesidir. Tutar farkı satırları çoğunlukla yuvarlama ya da yanlış girilmiş bir kalemden, muhasebede olmayan satırlar ise henüz kaydedilmemiş faturalardan doğar. Aynı denetim mantığını Excel tabanlı süreçlere uygulamak isteyenler için Excel kalite kontrol otomasyonu yazısı tamamlayıcı bir okumadır.
Saklama, gizlilik ve hukuki çerçeve
Hukuki çerçeve, e-Fatura XML'lerinin yasal belge niteliğinden ve içerdikleri kişisel veriden kaynaklanır. Vergi Usul Kanunu'na göre defter ve belgeler ilgili yılı izleyen takvim yılından başlayarak beş yıl saklanır; e-belgede bu yükümlülük mali mühürlü orijinal XML için geçerlidir, ondan üretilen CSV için değil. Ayrıştırıcı bu yüzden kaynak dosyayı hiçbir zaman değiştirmez ya da taşımaz, yalnız okur.
Şahıs faturalarında ad, soyad, TCKN ve adres bulunur ve bunlar Kişisel Verilerin Korunması Kanunu kapsamında kişisel veridir. Üretilen CSV dosyaları da bu veriyi taşır; erişim yetkisi, saklama süresi ve imha kuralı orijinal belgeyle aynı ciddiyetle tanımlanır. Mutabakat çıktısı bir e-posta ekine dönüşecekse, kişi verisi içeren sütunlar çıkarılır ya da yalnız belge numarası ve tutar bırakılır.
Uygulamada şu ilkeler yeterli bir başlangıç sağlar:
- Orijinal XML'ler salt okunur bir arşivde tutulur; ayrıştırıcı yalnız kopya ya da bağlı klasör üzerinde çalışır.
- Türetilmiş tablolar için ayrı bir saklama süresi belirlenir ve süre dolunca otomatik silinir.
- Mutabakat raporu paylaşılmadan önce kişi adı ve kimlik numarası sütunları düşürülür.
- Entegratör API'sinden çekilen dosyalar için erişim anahtarı betiğe gömülmez, ortam değişkeninden okunur.
Sonuç: ayrıştırıcıyı bir ay boyunca elle sürecin yanında çalıştırın
e-Fatura XML'i tabloya çevirmek, ad alanlarını doğru tanımlayan ve para alanlarını Decimal ile okuyan kısa bir ayrıştırıcıyla çözülür; asıl değer mutabakat adımında ortaya çıkar. Muhasebede olmayan faturalar ve tutar farkları, ay sonu kapanışından önce günlük kontrol listesi olarak görünür. Ayrıştırıcı orijinal belgeye dokunmaz, kişisel veriyi türetilmiş tablolarda da korur.
İlk adım olarak geçmiş bir ayın XML'lerini bir klasöre koyup ayrıştırıcıyı çalıştırın, çıkan başlık tablosunu o ayın muhasebe listesiyle mutabakata sokun. Elle yapılan sürecin sonucuyla betiğin sonucunu bir ay boyunca yan yana tutun; iki liste tutarlı hale gelince elle girişi kaldırın.
Kendi entegratörünüzden gelen dosyaların yapısını çıkarmak ve mutabakatı muhasebe programınızın dışa aktarımına uyarlamak için iletişim bölümünden yazabilirsiniz.