---
title: "CSV İçe Aktarma"
url: "https://cilginyazilim.com/kutuphane/php-csv-import"
description: "Bu örnek, PHP 8 ve MySQL ile yazılmış üretime hazır bir CSV içe aktarma modülüdür. İşi üç adıma böler: yükleme, sütun eşleme ile dry-run önizleme, ve içe aktarma. Dosya bir kez yüklenir ve oturuma bağlı bir token ile üç adımda da aynı dosya kullanılır; böylece önizlemede görülen veri ile aktarılan verinin aynı olduğu garanti edilir. Ayraç (virgül, noktalı virgül, TAB, dikey çizgi) ve karakter kodlaması (UTF-8, UTF-8 BOM, Windows-1254/ISO-8859-9) dosya içeriğinden tespit edilir; ayraç sayımı tırnak içindeki karakterleri atlar. Önizleme uç noktası, gerçek aktarmayla aynı validate_row() fonksiyonunu çalıştırır ama veritabanına hiçbir şey yazmaz ve her satırı temiz, uyarı, hata, dosya içi tekrar veya veritabanı tekrarı olarak sınıflandırır. Kullanıcı iki karar verir: hata olursa atomik mi (hepsi ya da hiçbiri, HTTP 409) yoksa kısmi mi (geçerliler yazılır, hatalılar indirilebilir bir CSV raporuna düşer); ve e-posta zaten varsa mevcut kayıt atlansın mı yoksa CSV ile güncellensin mi. İçe aktarma künyesi (dosya adı, ayraç, kodlama, sayaçlar, süre, sonuç) kayıtlarla aynı transaction içinde yazılır, böylece izi olmayan kayıt oluşamaz. Tekrar koruması uygulama kodunda değil uq_contacts_email benzersiz indeksindedir ve 23000 hatası indeks adına bakılarak ayırt edilir. Güvenlik tarafında prepared statement, CSRF, içerikten MIME kontrolü, path traversal koruması, CSV formül enjeksiyonu kaçışlaması, web'e kapalı geçici klasör ve dört ayrı hız sınırı kovası bulunur."
published: "2026-08-30T17:05:55+03:00"
modified: "2026-09-05T01:35:52+03:00"
category: "Veri İşleme"
type: "kod örneği"
difficulty: "Orta"
tech: ["PHP 8", "PDO", "MySQL", "Ajax", "DataTables", "Bootstrap 5"]
tags: ["Ajax", "CSV", "DataTables", "Dosya Yükleme", "İçe Aktarma", "PDO", "Transaction", "Veri Doğrulama"]
license: "MIT"
site: "CılgınYazılım"
language: "tr"
---

# CSV İçe Aktarma

Bu örnek, PHP 8 ve MySQL ile yazılmış üretime hazır bir CSV içe aktarma modülüdür. İşi üç adıma böler: yükleme, sütun eşleme ile dry-run önizleme, ve içe aktarma. Dosya bir kez yüklenir ve oturuma bağlı bir token ile üç adımda da aynı dosya kullanılır; böylece önizlemede görülen veri ile aktarılan verinin aynı olduğu garanti edilir. Ayraç (virgül, noktalı virgül, TAB, dikey çizgi) ve karakter kodlaması (UTF-8, UTF-8 BOM, Windows-1254/ISO-8859-9) dosya içeriğinden tespit edilir; ayraç sayımı tırnak içindeki karakterleri atlar. Önizleme uç noktası, gerçek aktarmayla aynı validate_row() fonksiyonunu çalıştırır ama veritabanına hiçbir şey yazmaz ve her satırı temiz, uyarı, hata, dosya içi tekrar veya veritabanı tekrarı olarak sınıflandırır. Kullanıcı iki karar verir: hata olursa atomik mi (hepsi ya da hiçbiri, HTTP 409) yoksa kısmi mi (geçerliler yazılır, hatalılar indirilebilir bir CSV raporuna düşer); ve e-posta zaten varsa mevcut kayıt atlansın mı yoksa CSV ile güncellensin mi. İçe aktarma künyesi (dosya adı, ayraç, kodlama, sayaçlar, süre, sonuç) kayıtlarla aynı transaction içinde yazılır, böylece izi olmayan kayıt oluşamaz. Tekrar koruması uygulama kodunda değil uq_contacts_email benzersiz indeksindedir ve 23000 hatası indeks adına bakılarak ayırt edilir. Güvenlik tarafında prepared statement, CSRF, içerikten MIME kontrolü, path traversal koruması, CSV formül enjeksiyonu kaçışlaması, web'e kapalı geçici klasör ve dört ayrı hız sınırı kovası bulunur.

- **Gereksinim:** PHP 8.0+ (pdo_mysql, mbstring, fileinfo) · MySQL 5.7+ veya MariaDB 10.3+ · Apache/mod_rewrite
- **Kod deposu:** https://github.com/CilginYazilim/csv-import

## Öne Çıkan Özellikler

- Üç adımlı sihirbaz: yükle → sütun eşle ve önizle → içe aktar
- Dosya bir kez yüklenir, oturuma bağlı bir token ile üç kez kullanılır
- Dry-run önizleme, gerçek aktarmayla AYNI doğrulama fonksiyonunu çalıştırır ama hiçbir şey yazmaz
- Ayraç tespiti (virgül, noktalı virgül, TAB, dikey çizgi) — tırnak içindeki ayraçlar sayılmaz
- Kodlama tespiti: UTF-8 BOM, UTF-8 ve Windows-1254 (ISO-8859-9)
- Sütun eşlemesi tahmin edilir ama dayatılmaz; Türkçe ve İngilizce başlık takma adları tanınır
- Hata ile uyarı ayrımı: kimlik alanı eksikse satır reddedilir, nitelik alanı kusurluysa girer
- Telefon normalizasyonu: +90, baştaki sıfır, boşluk ve tire temizlenip 10 haneye indirgenir
- Atomik mod: bir hata bile varsa hiçbir satır yazılmaz (HTTP 409)
- Kısmi mod: geçerli satırlar yazılır, atlananlar indirilebilir bir hata raporu CSV'sine düşer
- Tekrar politikası seçilebilir: veritabanındaki kaydı atla veya CSV ile güncelle
- İçe aktarma künyesi ve kayıtlar aynı transaction'da yazılır — izi olmayan kayıt oluşamaz
- Benzersiz e-posta indeksi; 23000 hatası indeks adına bakılarak ayırt edilir
- CSV formül enjeksiyonu koruması, CSRF, dört ayrı hız sınırı kovası
- Paylaşılabilir derin bağlantı (#kisi-4) ve mobilde sıfır yatay kaydırma

## Kullanım Senaryoları

- Müşteri, bayi veya abone listesini Excel'den panele aktarmak
- Ürün ve stok listelerini toplu güncellemek (tekrar politikası: güncelle)
- Web formu dökümlerini mevcut veritabanına tekrarsız eklemek
- Muhasebe ve finans gibi "yarım veri, yanlış veridir" durumlarında atomik aktarma
- Başka bir sistemden gelen, biçimi belirsiz CSV dosyalarını temizleyerek almak
- Kullanıcının kendi dosyasını yüklediği her panelde güvenli yükleme deseni

## Kurulum

1. Depoyu indirin ya da ZIP olarak açın.
2. Veritabanını kurun: mysql -u root -p < cy_import.sql (dosya veritabanını kendisi oluşturur).
3. storage/imports/ klasörünün yazılabilir olduğundan emin olun.
4. Projeyi bir web sunucusu altına koyun ya da php -S 127.0.0.1:8000 ile çalıştırın.
5. Tarayıcıda açın; 33 kişi ve 6 içe aktarma kaydı dolu bir ekran gelir.
6. "Örnek CSV ile dene" düğmesiyle akışı tek tıkla deneyin.
7. Canlıya alırken system/config.local.php.example dosyasını config.local.php olarak kopyalayıp künyeyi oraya yazın.

## Ayrıntılı Anlatım

<img src="assets/images/logo.png" alt="Çılgın Yazılım" width="90">

## CSV İçe Aktarma

#### PHP PDO · MySQL · AJAX · DataTables · Bootstrap 5 · Çılgın Yazılım Tasarım Kalıbı

**İçe aktarmadan önce ne olacağını göster; sonra ya hepsini yaz ya da sadece temizleri.**

[![PHP](https://img.shields.io/badge/PHP-8.0%2B-777BB4?style=flat-square&logo=php&logoColor=white)](https://php.net)
[![MySQL](https://img.shields.io/badge/MySQL-5.7%2B-4479A1?style=flat-square&logo=mysql&logoColor=white)](https://mysql.com)
[![Bootstrap](https://img.shields.io/badge/Bootstrap-5.2-7952B3?style=flat-square&logo=bootstrap&logoColor=white)](https://getbootstrap.com)
[![DataTables](https://img.shields.io/badge/DataTables-1.13-0f5499?style=flat-square)](https://datatables.net)
[![License](https://img.shields.io/badge/Lisans-MIT-16a34a?style=flat-square)](LICENSE)

**🇹🇷 Türkçe** · [🇬🇧 English](README.en.md)

[**▶ Canlı Demo**](https://cilginyazilim.com/kutuphane/uygulama/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables-main/) · [Kaynak Kütüphanesi](https://cilginyazilim.com/kutuphane/php-csv-import) · [cilginyazilim.com](https://cilginyazilim.com)

---

### Canlı Demo

**Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.**

<a href="https://cilginyazilim.com/kutuphane/uygulama/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables-main/"><img src="https://img.shields.io/badge/CANLI_DEMOYU_A%C3%87-0b5cb5?style=for-the-badge&logo=googlechrome&logoColor=white&labelColor=061321" alt="Canlı Demoyu Aç" height="42"></a>
<a href="https://cilginyazilim.com/kutuphane/php-csv-import"><img src="https://img.shields.io/badge/KAYNAK_KODU_%C4%B0NCELE-0ea5e9?style=for-the-badge&logo=readthedocs&logoColor=white&labelColor=061321" alt="Kaynak Kodu İncele" height="42"></a>
<a href="https://github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables/archive/refs/heads/main.zip"><img src="https://img.shields.io/badge/ZIP_%C4%B0ND%C4%B0R-16a34a?style=for-the-badge&logo=github&logoColor=white&labelColor=061321" alt="ZIP İndir" height="42"></a>

<br><br>

<a href="https://cilginyazilim.com/kutuphane/uygulama/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables-main/" title="Canlı demoyu açmak için tıklayın">
  <img src="docs/screenshots/01-genel-gorunum.png" alt="CSV içe aktarma canlı demo önizlemesi" width="860">
</a>

<sub>▲ Görsele tıklayarak demoyu açabilirsiniz</sub>

<br>

#### Demoda 60 saniyede neleri deneyebilirsiniz?

| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|--------------|----------------------------|
| **1** | **▶ Örnek CSV ile dene** düğmesine basın | Depodaki `sample.csv` yüklenir. Ayraç `;` olarak **içerikten** tespit edilir, kodlama sınanır, başlıklar hedef alanlara **tahminle** eşlenir ("ad soyad" → Ad Soyad) |
| **2** | **Önizle (dry-run)** deyin | Veritabanına **hiçbir şey yazılmaz**. 14 satırın her biri tek tek doğrulanır ve özet çıkar: 6 temiz · 1 uyarı · 3 hata · 1 dosya içi tekrar · 3 veritabanı tekrarı |
| **3** | Kırmızı satırlara bakın | Doğrulama veri döndürmediği için **ham hücreler** gösterilir: `Hatalı Kayıt \| gecersiz-eposta \| 000 \| Konya`. Hangi satırın neden reddedildiği okunabilir |
| **4** | **"E-posta zaten varsa?"** seçimini `Güncelle` yapın | Özet **anında yeniden hesaplanır**: "Eklenecek 7 / Güncellenecek 0" iken "Eklenecek 0 / Güncellenecek 10" olur. Karar, kararın sonucuna bakılarak verilir |
| **5** | **Atomik** modu seçip içe aktarın | HTTP **409** döner ve **hiçbir kayıt yazılmaz** — dosyada 3 hatalı satır var. Bu bir arıza değil, sözleşmenin çalışmasıdır |
| **6** | **Kısmi** moda geçip tekrar aktarın | 7 kayıt girer, 7 satır atlanır ve **"Hata raporunu indir"** düğmesi çıkar. İnen CSV, atlanan her satırı numarası ve sebebiyle geri verir |
| **7** | Kişiler tablosunda telefonlara bakın | CSV'de `+90 533 210 4477`, `0533 210 44 77` ve `533-987-6543` yazıyordu; hepsi **10 haneye** indirgenip tek biçimde saklandı |
| **8** | 👁 **Göz** butonuna basın | "Bu kayıt nereden geldi?" bölümü açılır: hangi dosya, hangi ayraç, hangi kodlama, hangi mod. Adres çubuğu `#kisi-4` olur — bağlantı **paylaşılabilir** |
| **9** | Geçmişte **#3** numaralı satıra bakın | `İptal edildi` — atomik modda çalışmış, tek hatalı satır tüm aktarımı durdurmuş. Denenen ve başarısız olan aktarma da kayıttadır |
| **10** | Telefonunuzdan açın | Tablo yatay kaydırmaya **zorlamaz**; ikincil sütunlar gizlenir, bilgi detay modalında durur |

> **İpucu:** Demoyu açıkken **F12 → Network** sekmesini açın. `upload` → `preview` → `commit` üçlüsünü, aralarında taşınan `token` alanını ve HTTP durum kodlarını (200 / 403 / 409 / 422 / 429) canlı görebilirsiniz.

#### Demo alanı hakkında bilinmesi gerekenler

| Konu | Durum |
|------|-------|
| **Veriler** | `cy_import.sql` içindeki **33 kişi + 6 içe aktarma kaydı**. Tamamı uydurmadır; gerçek kişi verisi yoktur. |
| **Sıfırlama** | Demo veritabanı **düzenli aralıklarla** başlangıç haline döner; aktardığınız kayıtlar kalıcı değildir. |
| **Yüklenen dosyalar** | Geçici klasöre alınır, işlem biter bitmez **silinir**; kalanlar 30 dakika sonra otomatik temizlenir. Klasör web'e kapalıdır. |
| **Kimlik doğrulama** | **Yoktur.** Bilinçli bir tercihtir — örnek, içe aktarma akışına odaklanır. |
| **`APP_DEBUG`** | Canlıda **otomatik `false`** — sunucu adından türetilir, yerelde `true` kalır. |
| **Bağımlılık** | **Sıfır.** Composer yok, npm yok, CDN yok. Demo internetsiz bir sunucuda da aynı çalışır. |

> Demo geçici olarak kapalıysa endişelenmeyin: depoyu klonlayıp `cy_import.sql`'i içe aktarmanız aynı ekranı kendi bilgisayarınızda **2 dakikada** ayağa kaldırır → [Kurulum](#kurulum)

---

### Bu Proje Nedir?

Neredeyse her panelde bir "CSV yükle" düğmesi vardır ve arkasında genellikle **tek bir döngü** durur: dosyayı aç, satırları oku, hepsini `INSERT` et.

O döngü 800. satırda patladığında ilk 799 kayıt yarım girmiştir. Kullanıcı ne olduğunu göremez, hangi satırın bozuk olduğunu bilemez ve yapabileceği tek şey aynı dosyayı ikinci kez yüklemektir — bu sefer veriler ikiye katlanır. Böyle bir ekran bir içe aktarma aracı değil, **kontrolsüz bir yazma noktasıdır**.

Bu proje aynı işi üç adıma böler ve her adımın neden ayrı olduğunu anlatır:

1. **Yükle** — dosya bir kez ağdan geçer, ayracı ve kodlaması içerikten tespit edilir
2. **Eşle ve önizle** — her satır doğrulanır ve ne olacağı **veritabanına dokunulmadan** gösterilir
3. **Aktar** — kullanıcı iki kararı verdikten sonra yazılır: *hata olursa ne olsun?* ve *e-posta zaten varsa ne olsun?*

Ve dördüncü bir şey daha yapar: her aktarmanın **künyesini** tutar. Üç hafta sonra "bu 400 kayıt nereden geldi?" diye sorulduğunda cevap ekranda durur — hangi dosya, hangi ayraç, hangi modda, kaç satır girdi, kaç tanesi atlandı.

**Kimler için uygun?**

- Kendi projesine dosyadan veri aktarma özelliği ekleyecekler
- "Aynı dosyayı iki kez yükledim, kayıtlar ikiye katlandı" sorununu üretimde yaşamış olanlar
- Türkçe Excel'den çıkan `;` ayraçlı, ISO-8859-9 kodlamalı dosyalarla boğuşanlar
- PHP + AJAX + DataTables üçlüsünü **doğru** öğrenmek isteyenler
- Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir tasarım kalıbı arayanlar

> **Klonla, `cy_import.sql`'i içe aktar, çalıştır.** Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.

Bu proje, **[Çılgın Yazılım Kütüphanesi](https://cilginyazilim.com/kutuphane)** altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.

---

### İçindekiler

- [Canlı Demo](#canlı-demo)
- [Ekran Görüntüleri](#ekran-görüntüleri)
- [Dört Kritik Karar](#dört-kritik-karar)
- [Neler Var?](#neler-var)
- [Güvenlik: Neyi, Nasıl Kapattık?](#güvenlik-neyi-nasıl-kapattık)
- [Kurulum](#kurulum)
- [Yapılandırma](#yapılandırma)
- [Kendi Projenize Eklemek](#kendi-projenize-eklemek)
- [Çılgın Yazılım Tasarım Kalıbı](#çılgın-yazılım-tasarım-kalıbı)
- [Dosya Yapısı](#dosya-yapısı)
- [Nasıl Çalışıyor?](#nasıl-çalışıyor)
- [AJAX API Referansı](#ajax-api-referansı)
- [Veritabanı Şeması](#veritabanı-şeması)
- [Sık Sorulanlar](#sık-sorulanlar)
- [Canlı Ortama Alırken](#canlı-ortama-alırken)
- [Sorun Giderme](#sorun-giderme)
- [Yol Haritası](#yol-haritası)
- [Katkı](#katkı)
- [Lisans](#lisans)

---

### Ekran Görüntüleri

#### Genel görünüm

Üstte sihirbaz, ortada içe aktarmanın hedef tablosu, altta her aktarmanın künyesi. Sayaç şeridi işin nabzını gösterir: kaç kişi var, kaçı CSV'den geldi, kaç aktarma yapıldı, kaç satır atlandı.

![Genel görünüm](docs/screenshots/01-genel-gorunum.png)

#### Dry-run önizleme — hiçbir şey yazılmadan

Her satırın ne olacağı **önceden** görülür. Dosya künyesi (ayraç, kodlama, sütun sayısı) eşleme alanlarının hemen üstündedir; beklenmedik bir sonuçta ilk bakılacak yer orasıdır. "Atomik mi kısmi mi?" kararı ancak bu tabloyu gördükten sonra anlamlıdır.

![Dry-run önizleme](docs/screenshots/02-onizleme-dryrun.png)

#### Kişi detayı — "bu kayıt nereden geldi?"

Bir kaydın hangi dosyadan, hangi ayraç ve kodlamayla, hangi modda geldiği tek ekranda. Adres çubuğu `#kisi-4` olur; bağlantıyı gönderdiğinizde karşı taraf **doğrudan o kaydı** açar.

![Kişi detayı](docs/screenshots/03-kisi-detay.png)

#### Mobil görünüm

Dar ekranda tablo yatay kaydırmaya zorlamaz: ikincil sütunlar gizlenir, bilgi detay modalında korunur. Dokunma hedefleri en az 32–44px'dir.

<img src="docs/screenshots/04-mobil.png" alt="Mobil görünüm" width="360">

**Üç modal:**

| Modal | Açılış | İçerik |
|-------|--------|--------|
| 👁 **Kişi detayı** | Kişi tablosundaki göz butonu | Alanların tam hâli + geldiği içe aktarmanın künyesi + paylaşılabilir `#kisi-4` bağlantısı |
| 👁 **İçe aktarma detayı** | Geçmiş tablosundaki göz butonu | Satır sayaçları, mod, dedupe politikası, süre, "bu aktarmanın kişilerini göster" |
| 🗑 **Silme onayı** | Çöp kutusu butonu | Neyin silineceği + **"bu e-posta yeniden aktarılabilir hâle gelir"** uyarısı |

---

### Dört Kritik Karar

#### 1) Dry-run: yazmadan önce göstermek

İçe aktarma ekranlarının klasik kusuru, kullanıcıyı **karanlıkta karar vermeye** zorlamasıdır. "Yükle" düğmesine basana kadar dosyanın kaç satırının bozuk olduğu bilinmez; basıldıktan sonra da geri dönüş yoktur.

`preview` uç noktası dosyanın tamamını `commit` ile **birebir aynı** kodla (`validate_row()`) işler, ama tek bir `INSERT` çalıştırmaz:

```php
// handle_preview() — hiçbir yazma yok, yalnızca sayım
$summary = ['total' => 0, 'ok' => 0, 'warning' => 0,
            'error' => 0, 'dupe_file' => 0, 'dupe_db' => 0];
```

Bu simetri şart: önizleme ayrı bir doğrulama kodu kullansaydı, zamanla ikisi ayrışır ve "önizlemede temizdi ama aktarırken patladı" durumu doğardı. **Önizleme, aktarmanın kuru çalıştırılmasıdır** — taklidi değil.

#### 2) Hata ile uyarı ayrımı

Her kusuru hataya çevirmek kolaydır ama pahalıdır: %2'lik kirli veri yüzünden %98'lik temiz veriyi de reddetmek demektir.

```php
// Ad yoksa satır YAZILAMAZ → hata
if (mb_strlen($name) < 2) {
    return ['data' => [], 'error' => 'Ad en az 2 karakter olmalı', ...];
}

// Telefon tuhafsa satır yine de değerlidir → uyarı
if (strlen($digits) !== 10) {
    return [$digits, 'Telefon 10 hane değil (yine de kaydedilecek)'];
}
```

Kural şu: **kaydın kimliğini** kuran alanlar (ad, e-posta) eksikse satır reddedilir; **kaydı zenginleştiren** alanlar (telefon, şehir) kusurluysa satır girer ve kullanıcı uyarılır.

#### 3) Tekrarın üç farklı anlamı

"Bu e-posta zaten var" cümlesi üç ayrı duruma karşılık gelir ve üçünün de çözümü farklıdır:

| Durum | Ne demek? | Ne yapılır? |
|-------|-----------|-------------|
| `dupe_file` | Aynı e-posta **dosya içinde** iki kez geçiyor | İlk görülen kazanır; ikincisi atlanır ve raporlanır |
| `dupe_db` + `dedupe=skip` | Kayıt **veritabanında** zaten var | Mevcut kayda dokunulmaz; var olan veri kaynak sayılır |
| `dupe_db` + `dedupe=update` | Kayıt var, ama **CSV daha güncel** | Mevcut kayıt CSV ile tazelenir; dosya kaynak sayılır |

Son satır bir tercih değil, **verinin kime ait olduğuyla** ilgili bir karardır: telefon güncelleme dosyasında CSV haklıdır, müşteri listesi eklemede veritabanı haklıdır. Bu yüzden seçim kullanıcıya bırakılır ve önizleme, seçim değiştiğinde **anında yeniden hesaplanır**.

Ama asıl koruma uygulama kodunda değil, **veritabanı kısıtındadır**:

```sql
UNIQUE KEY `uq_contacts_email` (`email`)
```

"Önce `SELECT`, yoksa `INSERT`" kontrolü iki eşzamanlı istekte yarışır ve kaybeder; `UNIQUE` indeks kaybetmez. Kod yine de önce `SELECT` yapar — ama bunu **önizleme için** yapar. Yazma anındaki gerçek koruma indeksin kendisidir ve yarış durumunda dönen hata **indeks adına bakılarak** ayırt edilir:

```php
$isDupeEmail = $ex->getCode() === '23000'
    && str_contains($ex->getMessage(), 'uq_contacts_email');

if (!$isDupeEmail) {
    throw $ex;   // başka bir kısıt ihlali — yanlış mesajla gizleme
}
```

#### 4) Künye ve kayıtlar aynı transaction'da

`import_runs` satırı, kişilerle **aynı transaction** içinde açılır ve aynı transaction içinde sayaçlarıyla güncellenir:

```php
$db->beginTransaction();
$runStmt->execute([...]);          // künye açılır (boş sayaçlarla)
$runId = (int) $db->lastInsertId();
foreach ($rows as $raw) { ... }    // kişiler bu künyeye bağlanır
$db->prepare('UPDATE import_runs SET inserted = ...')->execute([...]);
$db->commit();
```

Künye ayrı yazılsaydı, kişiler girip künye yazılamadığında **izi olmayan 400 kayıt** oluşurdu — tam olarak bu projenin çözmeye çalıştığı sorun. Atomik modda fren devreye girdiğinde `rollBack()` künyeyi de siler; bu yüzden hemen ardından ayrı bir `aborted` künyesi yazılır: **denenip iptal edilen aktarma da geçmişte görünmelidir.**

---

### Neler Var?

<tr><td valign="top" width="50%">

**İçe aktarma çekirdeği**

- Üç adımlı sihirbaz: `upload` → `preview` → `commit`
- Dosya **bir kez** yüklenir, token ile üç kez kullanılır
- Ayraç tespiti: `,` `;` TAB `|` — **tırnak içi** ayraçlar sayılmaz
- Kodlama tespiti: UTF-8 / UTF-8 BOM / ISO-8859-9 (Windows-1254)
- Sütun eşleme **tahmini**, kullanıcı tarafından değiştirilebilir
- Türkçe + İngilizce başlık takma adları (`eposta`, `mail`, `e-posta`…)
- Dry-run önizleme: hata / uyarı / dosya içi tekrar / DB tekrarı
- İki mod: **atomik** (hepsi ya da hiçbiri) ve **kısmi**
- İki dedupe politikası: mevcudu **atla** veya **güncelle**
- Telefon normalizasyonu: `+90`, `0`, boşluk, tire → 10 hane
- Hata raporu CSV: satır numarası + sebep + ham hücreler

</td><td valign="top" width="50%">

**Ekranlar ve altyapı**

- Sayaç şeridi: kişi, CSV'den gelen, aktarma, atlanan satır, son aktarma
- Kişiler tablosu (DataTables **server-side**): arama, şehir ve kaynak filtresi
- İçe aktarma geçmişi: künye, sayaçlar, sonuç rozeti, süre
- "Bu aktarmanın kişilerini göster" — iki tabloyu bağlayan `import_id`
- CSV dışa aktarım, ekrandaki **aynı filtrelerle**
- Paylaşılabilir derin bağlantı: `#kisi-4`, `#aktarma-3`
- Sürükle-bırak dosya yükleme
- Markayla uyumlu silme onayı modalı (`confirm()` yok)
- Çift gönderim koruması, toast bildirimleri, `aria-live`
- Dört ayrı hız sınırı kovası (upload / preview / commit / export)
- Mobil: 991 / 767 / 480px eşikleri, **yatay kaydırma yok**

</td></tr>

---

### Güvenlik: Neyi, Nasıl Kapattık?

Bu projede saldırı yüzeyi alışılmadık biçimde geniştir: **içeriği tamamen kullanıcının belirlediği bir dosyayı** okuyup ekrana basıyor ve veritabanına yazıyoruz.

| Açık | Tipik hatalı kod | Bu projede |
|------|------------------|------------|
| **SQL Injection** | `"... WHERE city = '".$_POST['city']."'"` | Tüm sorgular prepared statement, `EMULATE_PREPARES = false`. Sıralama sütunu **beyaz liste**den geçer; `source`, `mode`, `dedupe` ve `status` değerleri sabit listelerle doğrulanır. |
| **XSS (önizleme tablosunda)** | `$('#cell').html(row.name)` | Önizlemedeki her hücre **doğrudan yüklenen dosyadan** gelir. Sunucuda `e()`, istemcide `esc()`. Bir CSV hücresinde `<script>` bulunması kural dışı değil, beklentidir. |
| **CSV formül enjeksiyonu** | Hücreyi olduğu gibi yazmak | `=`, `+`, `-`, `@` ile başlayan hücrelerin başına tek tırnak konur. Hata raporu **saldırganın kendi metnini** geri yazdığı için burada risk teorik değildir. |
| **Path traversal** | `IMPORT_DIR.'/'.$_POST['token'].'.csv'` | Token yalnızca onaltılık olabilir (`ctype_xdigit`), ayrıca dosya adı `session_id()` ile öneklenir — token'ı ele geçiren başka bir ziyaretçi bile dosyaya ulaşamaz. |
| **Yüklenen dosyanın çalıştırılması** | Dosyayı web köküne yazmak | Dosyalar `storage/imports/` altına gider; klasörde `Require all denied` vardır ve içerik hiçbir zaman `include` edilmez. |
| **Uzantıya güvenmek** | `if ($ext === 'csv')` | Tür **içerikten** okunur (`finfo`); `text/*` dışındaki türler reddedilir. |
| **CSRF** | *(genelde hiç yok)* | Oturuma bağlı 32 baytlık token, **her** yazma isteğinde, `hash_equals()` ile sabit zamanlı doğrulama. Token'sız istek → **HTTP 403**. |
| **İzi olmayan kayıt** | Önce `INSERT`, sonra ayrı bir `INSERT` | Kişiler ve içe aktarma künyesi **tek transaction**. Künye yazılamazsa kişiler de geri alınır. |
| **Yarış durumunda çift kayıt** | "Önce SELECT, yoksa INSERT" | `uq_contacts_email` benzersiz indeksi. `23000` hatası **indeks adına bakılarak** ayırt edilir; başka bir kısıt ihlali yutulmaz. |
| **Bozuk kodlamanın yanıtı yutması** | `json_encode()` → `false` → boş gövde | Dosya okunurken UTF-8'e çevrilir (`csv_to_utf8()`); ikinci hat olarak `JSON_INVALID_UTF8_SUBSTITUTE`. Tek bayt yüzünden 5000 satır kaybolmaz. |
| **Kaynak tüketimi (DoS)** | `while (fgetcsv(...))` sınırsız | `IMPORT_MAX_ROWS` (5000), `IMPORT_MAX_BYTES` (5 MB), `PAGE_SIZE_MAX` (200), `EXPORT_MAX_ROWS` (5000) ve **dört ayrı hız sınırı kovası**. |
| **Sahipsiz dosya birikmesi** | Yüklenen dosyayı silmemek | İş biter bitmez `unlink()`; ayrıca her istekte fırsatçı gc, `IMPORT_TTL_MIN` (30 dk) dolmuş dosyaları temizler. |
| **LIKE joker karakterleri** | `LIKE '%$search%'` | `%`, `_`, `\` kaçışlanır. |
| **Bilgi sızıntısı** | Ekrana basılan MySQL hataları | `APP_DEBUG` sunucu adından türetilir; canlıda otomatik `false`, detay `error_log()`'a gider. |
| **Kurulum dosyasının indirilmesi** | `/cy_import.sql` → HTTP 200 | `.htaccess`: `.sql`, `.md`, `.json`, `.log` … kapalı (README dosyaları bilinçli istisnadır). |

---

### Kurulum

> Sadece görmek istiyorsanız kurulum gerekmez → [**Canlı Demoyu açın**](https://cilginyazilim.com/kutuphane/uygulama/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables-main/). Aşağıdaki adımlar projeyi kendi bilgisayarınızda çalıştırmak içindir (~2 dakika).

#### Gereksinimler

- PHP **8.0+** (`pdo_mysql`, `mbstring` ve `fileinfo` eklentileri)
- MySQL **5.7+** veya MariaDB **10.3+**
- Apache (XAMPP / WAMP / Laragon) — ya da PHP'nin yerleşik sunucusu

#### Adımlar

**1 — Projeyi indirin**

```bash
git clone https://github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables.git
cd PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables
```

**2 — Veritabanını oluşturun**

`cy_import.sql` veritabanını da kendisi oluşturur; önceden `cy_import` adında bir veritabanı açmanıza gerek yok.

```bash
mysql -u root -p < cy_import.sql
```

phpMyAdmin ile: **İçe Aktar → Dosya seç → `cy_import.sql` → Başlat**

**3 — `storage/imports/` klasörünün yazılabilir olduğundan emin olun**

Windows'ta genelde sorun çıkmaz. Linux'ta:

```bash
chmod 750 storage/imports
```

**4 — Çalıştırın**

```bash
php -S 127.0.0.1:8000
```

XAMPP kullanıyorsanız projeyi `htdocs` altına koyup şu adresi açın:
`http://localhost/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables/`

**5 — Tarayıcıda açın** → `http://127.0.0.1:8000/`

Karşınıza **33 kişi** ve **6 örnek içe aktarma kaydı** dolu, çalışır durumda bir ekran gelecek. **▶ Örnek CSV ile dene** düğmesine basarak akışı hemen deneyebilirsiniz.

---

### Yapılandırma

Tüm ayarlar [system/config.php](system/config.php) içindedir.

| Sabit | Varsayılan | Ne işe yarar |
|-------|-----------|--------------|
| `IMPORT_MAX_ROWS` | `5000` | Bir dosyada işlenecek en fazla veri satırı |
| `IMPORT_MAX_BYTES` | `5 MB` | Yüklenecek dosya boyutu tavanı |
| `IMPORT_DIR` | `storage/imports` | Geçici dosya klasörü (web'e kapalı) |
| `IMPORT_TTL_MIN` | `30` | Sahipsiz geçici dosyaların ömrü (dakika) |
| `PREVIEW_MAX_ROWS` | `200` | Önizleme tablosunda gösterilecek en fazla satır |
| `DEDUPE_DEFAULT` | `skip` | Açılıştaki tekrar politikası (`skip` \| `update`) |
| `TARGET_FIELDS` | `name, email, phone, city` | Eşlenebilecek hedef alanlar |
| `REQUIRED_FIELDS` | `name, email` | Eşlenmeden aktarma başlatılamayan alanlar |
| `PAGE_SIZE_MAX` | `200` | DataTables sayfa boyutu tavanı |
| `EXPORT_MAX_ROWS` | `5000` | CSV dışa aktarımda satır tavanı |
| `RATE_LIMIT_*` | `[istek, saniye]` | upload / preview / commit / export için **ayrı** kovalar |

#### Hedef alan eklemek

`TARGET_FIELDS` dizisine bir ad eklemek, eşleme `<select>`'ini, sunucu doğrulamasını ve hata raporu başlıklarını **birlikte** günceller. Üç yer daha dokunulması gerekir:

1. `field_label()` — Türkçe etiketi ([system/function.php](system/function.php))
2. `validate_row()` — varsa doğrulama kuralı
3. `INSERT` / `UPDATE` sorguları ([system/ajax.php](system/ajax.php)) ve `contacts` tablosunda sütun

Takma ad tahmini için [assets/js/import.js](assets/js/import.js) içindeki `ALIASES` nesnesine de bir satır ekleyin.

#### Şifreyi koda yazmayın

Canlı veritabanı künyesi `config.php`'ye **yazılmaz**: o dosya depoda durur ve her dağıtımda depodaki sürümle değiştirilir. Bunun yerine yanına `config.local.php` koyun (`.gitignore` içindedir):

```bash
cp system/config.local.php.example system/config.local.php
# sonra dört satırı doldurun
```

Ortam değişkeni de kullanabilirsiniz:

```bash
# Linux / macOS
DB_HOST=127.0.0.1 DB_NAME=cy_import DB_USER=uygulama DB_PASS=gizli php -S 127.0.0.1:8000

# Windows (PowerShell)
$env:DB_PASS="gizli"; php -S 127.0.0.1:8000
```

Öncelik sırası: **`config.local.php` → ortam değişkeni → yerel varsayılan.**

---

### Kendi Projenize Eklemek

İçe aktarma katmanını kendi tablonuza taşımak için sırayla:

**1 — Hedef alanlarınızı tanımlayın**

```php
// system/config.php
define('TARGET_FIELDS',   ['ad', 'stok_kodu', 'fiyat', 'adet']);
define('REQUIRED_FIELDS', ['ad', 'stok_kodu']);
```

**2 — Doğrulamayı yazın**

`validate_row()` içindeki kuralları kendi alanlarınıza uyarlayın. **Hata / uyarı ayrımını koruyun**: kaydın kimliğini kuran alan eksikse hata, zenginleştiren alan kusurluysa uyarı.

**3 — Benzersizlik anahtarınızı seçin**

Bu projede `email`, sizde `stok_kodu` olabilir. Üç yeri birlikte değiştirin:

```sql
UNIQUE KEY `uq_urun_stok_kodu` (`stok_kodu`)
```

```php
// handle_preview: SELECT stok_kodu FROM urunler WHERE stok_kodu IN (…)
// handle_commit : str_contains($ex->getMessage(), 'uq_urun_stok_kodu')
```

**4 — `INSERT` / `UPDATE` sorgularını değiştirin**

`handle_commit()` içindeki iki prepared statement. **Döngünün dışında** hazırlanmalarını koruyun; içeride hazırlamak 5000 satırda 5000 gereksiz gidiş-dönüş demektir.

**5 — Künye tablosunu olduğu gibi alın**

`import_runs` tablosu alanlarınızdan bağımsızdır; hiç değiştirmeden kullanabilirsiniz.

> **Atlamayın:** `preview` ve `commit` **aynı** `validate_row()` fonksiyonunu çağırmalıdır. İkisini ayırdığınız gün önizleme yalan söylemeye başlar.

---

### Çılgın Yazılım Tasarım Kalıbı

[assets/css/cilginyazilim.css](assets/css/cilginyazilim.css) dosyası bu projeye değil **markaya** aittir. Diğer örnek projelerde de aynı görsel dili kullanabilmek için ayrı bir dosya olarak tutulur; bu örneğe özgü her şey (sihirbaz adımları, bırakma alanı, önizleme tablosu, özet çipleri) [assets/css/style.css](assets/css/style.css) içindedir.

#### Hazır bileşenler

| Sınıf | Ne işe yarar |
|-------|--------------|
| `.cy-card` / `.cy-card__header` / `__body` / `__footer` | Gradyan başlıklı ana kart |
| `.cy-brand` / `.cy-brand__mark` / `__title` / `__subtitle` | Logo kutusu + başlık bloğu |
| `.cy-btn` + `--primary` \| `--onbrand` \| `--glass` | Marka butonları |
| `.cy-btn-icon` + `--view` \| `--edit` \| `--delete` | Tablo içi ikon butonları |
| `.cy-table` / `.cy-actions` / `.cy-id` / `.cy-name` | Marka görünümlü tablo bileşenleri |
| `.cy-badge` + `--glass` \| `--soft` | Rozetler |
| `.cy-modal` / `.cy-detail` | Gradyan başlıklı modal ve etiket/değer listesi |
| `.cy-toast` + `--success` \| `--danger` \| `--info` | Bildirim balonları |

**Bu örneğe özgü** (`style.css`): `.cy-step` / `.cy-step__num`, `.cy-dropzone`, `.cy-filemeta` / `.cy-fm`, `.cy-chip`, `.cy-verdict`, `.cy-preview`, `.cy-choice`, `.cy-result`, `.cy-op--ok|warning|error|dupe_db|success|partial|aborted`, `.cy-src`, `.cy-count`.

#### Renkleri değiştirmek

Tüm bileşenler CSS değişkenlerinden beslenir. **Tek yeri** değiştirmek yeter:

```css
:root {
    --cy-brand-900: #061321;   /* Logodaki en koyu lacivert */
    --cy-brand-600: #0b5cb5;   /* Ana marka mavisi          */
    --cy-accent:    #0ea5e9;   /* Vurgu rengi               */
    --cy-gradient:  linear-gradient(135deg, #061321, #0b5cb5 45%, #0284c7);
}
```

#### Koyu tema

Kullanıcının işletim sistemi koyu temadaysa **otomatik** devreye girer. Zorlamak isterseniz:

```html
<html data-cy-theme="dark">   <!-- veya "light" -->
```

---

### Dosya Yapısı

```
csv-import/
├── index.php                 → Arayüz. Veritabanına DOKUNMAZ; üç kart + üç modal.
├── cy_import.sql             → Şema + 33 kişi + 6 içe aktarma künyesi (NOW() - INTERVAL)
├── sample.csv                → Örnek dosya: her durumu temsil eden 14 satır
├── .htaccess                 → Dizin listeleme kapalı, .sql/.md kapalı (README istisna)
│
├── system/
│   ├── config.php            → Ayarlar, PDO bağlantısı, APP_DEBUG türetimi
│   ├── config.local.php      → (siz oluşturursunuz) canlı künye — .gitignore'da
│   ├── config.local.php.example
│   ├── function.php          → CSRF, hız sınırı, CSV okuma/yazma, doğrulama
│   ├── ajax.php              → 12 uç nokta (sihirbaz + ekranlar)
│   └── .htaccess             → BEYAZ LİSTE: yalnızca ajax.php dışarı açık
│
├── storage/imports/          → Geçici CSV'ler. Web'e KAPALI, 30 dk sonra temizlenir.
│   ├── .htaccess             → Require all denied
│   └── .gitignore            → yüklenen dosyalar depoya girmez
│
├── assets/
│   ├── css/
│   │   ├── cilginyazilim.css → MARKA KALIBI (projeler arası ortak — dokunmayın)
│   │   ├── style.css         → Yalnızca bu örneğe özgü stiller
│   │   ├── bootstrap.min.css
│   │   └── dataTables.bootstrap5.min.css
│   ├── js/
│   │   ├── import.js         → Sihirbaz + tablolar + modallar (12 bölüm)
│   │   ├── jquery-3.7.0.js
│   │   ├── bootstrap.bundle.js
│   │   └── jquery.dataTables.min.js
│   └── images/logo.png
│
├── docs/screenshots/         → README'de kullanılan görseller
├── CHANGELOG.md
├── README.md · README.en.md
└── LICENSE                   → MIT
```

---

### Nasıl Çalışıyor?

```
┌─ ADIM 1: upload ─────────────────────────────────────────────────────┐
│  · içerikten MIME kontrolü + boyut sınırı                            │
│  · csv_to_utf8()   → BOM / UTF-8 geçerliliği / Windows-1254          │
│  · detect_delimiter() → , ; TAB |   (tırnak içi ayraçlar sayılmaz)   │
│  · fgetcsv ile ayrıştır, IMPORT_MAX_ROWS tavanını uygula             │
│  · storage/imports/<session_id>_<token>.csv olarak sakla             │
│  ↳ döner: token · başlık · ilk 5 satır · ayraç · kodlama · satır sayısı│
└──────────────────────────────────────────────────────────────────────┘
                              │  token
                              ▼
┌─ ADIM 2: preview  (DRY-RUN — VERİTABANINA HİÇBİR ŞEY YAZILMAZ) ──────┐
│  · load_and_map() → mapping doğrula (zorunlu alanlar, sütun var mı?) │
│  · dosyadaki e-postaları TEK sorguda sor:                            │
│        SELECT email FROM contacts WHERE email IN (…)   ← 1 sorgu     │
│  · her satır → validate_row()                                        │
│        ok · warning · error · dupe_file · dupe_db                    │
│  ↳ döner: özet sayaçları · ilk 200 satırın önizlemesi · atomik uyarısı│
└──────────────────────────────────────────────────────────────────────┘
                              │  token + mapping + mode + dedupe
                              ▼
┌─ ADIM 3: commit ─────────────────────────────────────────────────────┐
│  BEGIN                                                               │
│    INSERT import_runs (…)          ← künye açılır, id alınır         │
│    foreach satır:                                                    │
│        validate_row()                                                │
│        ├─ hata + mode=atomic  → ROLLBACK → 409 → aborted künyesi     │
│        ├─ hata + mode=kısmi   → hata raporuna ekle, atla             │
│        ├─ DB'de var + skip    → atla ve raporla                      │
│        ├─ DB'de var + update  → UPDATE contacts                      │
│        └─ yeni                → INSERT contacts (import_id = künye)  │
│    UPDATE import_runs SET inserted, updated, skipped, status, …      │
│  COMMIT                                                              │
│  ↳ hata varsa rapor CSV'si yazılır, kaynak dosya SİLİNİR             │
└──────────────────────────────────────────────────────────────────────┘
```

#### Sorumluluk dağılımı

| Katman | Dosya | Sorumluluk |
|--------|-------|-----------|
| Sunum | `index.php` | Yalnızca HTML iskeleti. Veri yok, sorgu yok. |
| Arayüz mantığı | `assets/js/import.js` | Sihirbaz durumu, DataTables, modallar, kaçışlama |
| Uç noktalar | `system/ajax.php` | İstek doğrulama, işlem akışı, JSON yanıtı |
| Alan mantığı | `system/function.php` | CSV okuma, kodlama, doğrulama, CSV yazma |
| Ayar | `system/config.php` | Sabitler, PDO, `APP_DEBUG` türetimi |

---

### AJAX API Referansı

Tüm istekler `system/ajax.php` adresine gider. İndirme uçları (`error_csv`, `contacts_export`) dışında **POST** ve **CSRF token** zorunludur.

<details>
<summary><b><code>upload</code> · <code>upload_sample</code> — dosya yükle</b></summary>

`multipart/form-data` ile `file` alanı. `upload_sample` dosya beklemez; depodaki `sample.csv` ile aynı kod yolunu çalıştırır.

```json
{
  "success": true,
  "token": "84351d3b8bb2c4ea6421ea499e87aaec",
  "filename": "sample.csv",
  "delimiter": ";",
  "encoding": "UTF-8",
  "header": ["ad soyad", "e-posta", "telefon", "şehir"],
  "sample": [["Zeynep Kaya", "zeynep@example.com", "0532 111 22 44", "Bursa"]],
  "row_count": 14,
  "targets": [{"key": "name", "label": "Ad Soyad", "required": true}]
}
```
</details>

<details>
<summary><b><code>preview</code> — dry-run</b></summary>

**İstek:** `token`, `mapping` (JSON: `{"name":0,"email":1,…}`), `dedupe`

```json
{
  "success": true,
  "summary": {"total": 14, "ok": 6, "warning": 1, "error": 3, "dupe_file": 1, "dupe_db": 3},
  "will_insert": 7,
  "will_update": 0,
  "will_skip": 7,
  "preview": [
    {"line": 2, "status": "dupe_db", "status_label": "Veritabanında var",
     "note": "Veritabanında var → ATLANACAK",
     "data": {"name": "Zeynep Kaya", "email": "zeynep@example.com", "phone": "5321112244", "city": "Bursa"},
     "raw": []}
  ],
  "preview_shown": 14,
  "atomic_hint": "Atomik modda 3 hatalı satır TÜM aktarımı iptal eder."
}
```

`data` boşsa satır doğrulamayı geçememiştir; o durumda `raw` dosyadaki ham hücreleri taşır.
</details>

<details>
<summary><b><code>commit</code> — gerçek aktarma</b></summary>

**İstek:** `token`, `mapping`, `mode` (`atomic` \| `skip_invalid`), `dedupe` (`skip` \| `update`)

```json
{
  "success": true,
  "run_id": 9,
  "mode": "skip_invalid",
  "mode_label": "Kısmi (hatalıları atla)",
  "inserted": 7,
  "updated": 0,
  "skipped": 7,
  "error_count": 7,
  "duration_ms": 10,
  "status": "partial",
  "report_token": "3efdc2a4cce2e44e097f1ad25ac72d4e"
}
```

Atomik modda hata bulunursa **409** döner ve hiçbir kayıt yazılmaz:

```json
{"success": false, "description": "Atomik mod: 4. satır hatalı (Geçersiz e-posta). HİÇBİR kayıt yazılmadı.",
 "line": 4, "reason": "Geçersiz e-posta", "mode": "atomic"}
```
</details>

<details>
<summary><b><code>error_csv</code> — hata raporu (GET)</b></summary>

`?action=error_csv&token=<report_token>` → UTF-8 BOM'lu, `;` ayraçlı CSV indirir.

```
satir;hata;"Ad Soyad";E-posta;Telefon;Şehir
4;"Geçersiz e-posta";"Hatalı Kayıt";gecersiz-eposta;000;Konya
10;"Geçersiz e-posta";"'=HYPERLINK(""http://kotu.example.com"",""Fatura"")";sahte-eposta@;5551234567;Rize
```

İkinci satırdaki baştaki tek tırnağa dikkat: formül enjeksiyonu koruması.
</details>

<details>
<summary><b><code>stats</code> — sayaç şeridi</b></summary>

```json
{"success": true, "contacts": 33, "from_import": 30, "cities": 16,
 "runs": 6, "skipped": 13, "errors": 8, "last_run": "29.08.2026 14:29",
 "cities_list": ["Adana", "Ankara", "…"]}
```
</details>

<details>
<summary><b><code>contacts_list</code> · <code>contact_detail</code> · <code>contact_delete</code> · <code>contacts_export</code></b></summary>

`contacts_list` standart DataTables server-side yanıtı döner (`draw`, `recordsTotal`, `recordsFiltered`, `data`). Filtreler: `search[value]`, `f_city`, `f_source`, `f_run`.

`contacts_export` **GET**'tir ve `contact_filters()` fonksiyonunu listeyle paylaşır — indirilen dosya ekranda görünenle birebir aynıdır.
</details>

<details>
<summary><b><code>runs_list</code> · <code>run_detail</code></b></summary>

`runs_list` filtresi: `f_status` (`success` \| `partial` \| `aborted`).
`run_detail` künyeye ek olarak `still_here` döner: o aktarmadan gelip **hâlâ duran** kayıt sayısı.
</details>

#### HTTP durum kodları

| Kod | Anlamı |
|-----|--------|
| `200` | İşlem başarılı |
| `400` | Geçersiz parametre (örn. hatalı ID) |
| `403` | CSRF token geçersiz veya oturum düşmüş |
| `404` | Kayıt / rapor bulunamadı |
| `405` | POST dışı istek |
| `409` | **Atomik fren** — dosyada hatalı satır var, hiçbir şey yazılmadı |
| `410` | Geçici dosyanın süresi dolmuş, yeniden yükleyin |
| `422` | Doğrulama hatası (dosya türü, boyut, eksik eşleme — `errors` alanı döner) |
| `429` | Hız sınırı aşıldı (`retry_after` saniye döner) |
| `500` | Sunucu / veritabanı hatası |

---

### Veritabanı Şeması

```sql
CREATE TABLE `import_runs` (
  `id`              INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `filename`        VARCHAR(190) NOT NULL,
  `delimiter_label` VARCHAR(8)   NOT NULL DEFAULT ',',
  `encoding`        VARCHAR(24)  NOT NULL DEFAULT 'UTF-8',
  `mode`            ENUM('atomic','skip_invalid') NOT NULL DEFAULT 'skip_invalid',
  `dedupe`          ENUM('skip','update')         NOT NULL DEFAULT 'skip',
  `total_rows`      INT UNSIGNED NOT NULL DEFAULT 0,
  `inserted`        INT UNSIGNED NOT NULL DEFAULT 0,
  `updated`         INT UNSIGNED NOT NULL DEFAULT 0,
  `skipped`         INT UNSIGNED NOT NULL DEFAULT 0,
  `error_count`     INT UNSIGNED NOT NULL DEFAULT 0,
  `duration_ms`     INT UNSIGNED NOT NULL DEFAULT 0,
  `status`          ENUM('success','partial','aborted') NOT NULL DEFAULT 'success',
  `created_at`      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_runs_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `contacts` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `name`       VARCHAR(150) NOT NULL,
  `email`      VARCHAR(190) NOT NULL,
  `phone`      VARCHAR(20)  NOT NULL DEFAULT '',
  `city`       VARCHAR(80)  NOT NULL DEFAULT '',
  `source`     ENUM('manual','import') NOT NULL DEFAULT 'import',
  `import_id`  INT UNSIGNED NULL DEFAULT NULL,
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uq_contacts_email` (`email`),
  KEY `idx_contacts_city` (`city`),
  KEY `idx_contacts_import` (`import_id`),
  CONSTRAINT `fk_contacts_import` FOREIGN KEY (`import_id`)
    REFERENCES `import_runs` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

| Karar | Neden |
|-------|-------|
| **`uq_contacts_email`** | "Aynı dosyayı iki kez yükledim, kayıtlar ikiye katlandı" sorununun tek gerçek çözümü. Uygulama katmanındaki kontrol yarışır ve kaybeder; indeks kaybetmez |
| **`delimiter_label`, `delimiter` değil** | `DELIMITER`, mysql istemcisinin **komutudur**. Satır başında geçtiğinde kurulum dosyası sessizce bozulur |
| **`status` ENUM (3 değer)** | `partial` ile `aborted` farklı sorulara cevap verir: biri "kısmen girdi", diğeri "hiç girmedi". Tek bir `success` bayrağı bu ayrımı yutardı |
| **`ON DELETE SET NULL`** | Künye silinse bile kişi kaydı durur, yalnızca izi kopar. `CASCADE` olsaydı bir künye silmek 400 kişiyi de silerdi |
| **`source` + `import_id` birlikte** | `import_id` NULL olabilir (elle girilmiş ya da künyesi silinmiş). `source` bu iki durumu ayırt eder |
| **`duration_ms`** | Performansı ölçülebilir kılar: 5000 satırlık bir aktarmanın 4 saniye mi 40 saniye mi sürdüğü, indeks kararlarının tek somut geri bildirimidir |
| **`updated_at` ON UPDATE** | `dedupe=update` politikasının ne yaptığı veriden okunabilir: oluşturma ve güncelleme zamanı farklı olan kayıtlar tazelenmiş olanlardır |
| **InnoDB** | Transaction desteği — künye ile kişilerin birlikte yazılabilmesinin ön şartı |

---

### Sık Sorulanlar

<details>
<summary><b>Neden dosyayı iki kez yüklemiyoruz — önizleme için bir, aktarma için bir?</b></summary>

İki sebeple. Birincisi hız: 5 MB'lık bir dosyayı iki kez göndermek kullanıcıyı bekletir. İkincisi ve asıl önemlisi **tutarlılık**: iki ayrı yükleme arasında dosya değişebilir. Önizlemede gördüğünüz veri ile aktarılan verinin **aynı dosya** olduğunu garanti etmenin tek yolu, dosyayı bir kez alıp bir token'la işaret etmektir.

Token oturuma bağlıdır ve `IMPORT_TTL_MIN` (30 dakika) sonra geçersizleşir. Süre dolarsa sunucu **410** döner ve kullanıcıdan yeniden yüklemesini ister.
</details>

<details>
<summary><b>Kodlama tespiti nasıl çalışıyor? Neden mb_detect_encoding() yetmiyor?</b></summary>

`mb_detect_encoding()` tek başına güvenilmezdir: `ISO-8859-9` ile `Windows-1254` arasında bayt düzeyinde ayrım yapılamaz ve fonksiyon hemen her baytı "latin1" olarak tanır — yani geçerli UTF-8 dosyaları da yanlışlıkla çevirir.

Bu yüzden sıra tersine çevrilmiştir:

1. UTF-8 BOM varsa dosya kesin UTF-8'dir
2. `mb_check_encoding($raw, 'UTF-8')` geçiyorsa UTF-8'dir (ASCII de öyledir)
3. **Ancak o zaman** Windows-1254 varsayılıp çevrilir

Çeviri son çaredir, ilk tahmin değil. Türkçe Excel'in ürettiği dosyalar 3. adıma düşer ve doğru okunur.
</details>

<details>
<summary><b>Neden 5000 satır sınırı var? Daha büyük dosyaları nasıl aktarırım?</b></summary>

Sınırın sebebi kimlik doğrulaması olmayan bir uç noktaya sınırsız iş yaptırmamaktır. 100.000 satırlık bir dosya, tek bir istekte hem belleği hem işlemciyi tüketir; bu ucuz bir hizmet dışı bırakma kaldıracıdır.

Daha büyük dosyalar için üç yol var:

1. `IMPORT_MAX_ROWS` ve `IMPORT_MAX_BYTES` değerlerini yükseltin (kimlik doğrulaması olan bir panelde makul)
2. Dosyayı parçalara bölüp sırayla aktarın — `import_runs` her parçayı ayrı künyede tutar
3. Gerçekten büyük veri için `LOAD DATA INFILE` kullanın; ama o zaman satır bazlı doğrulamadan ve önizlemeden vazgeçersiniz
</details>

<details>
<summary><b>Atomik mod gerçekten "hepsi ya da hiçbiri" mi?</b></summary>

Evet. Tüm satırlar tek bir transaction içinde işlenir; ilk hatada `rollBack()` çağrılır ve o ana kadar yazılan her şey geri alınır. Künye satırı da aynı transaction'da olduğu için o da silinir — bu yüzden hemen ardından ayrı bir `aborted` künyesi yazılır.

Bir uyarı: MySQL'de `ALTER TABLE` gibi DDL komutları örtük commit yapar. Bu kod yalnızca `INSERT`/`UPDATE` çalıştırdığı için sorun yoktur, ama içe aktarma sırasında şema değiştiren bir kod eklerseniz atomiklik garantisi kırılır.
</details>

<details>
<summary><b>Telefon numarasını neden değiştiriyorsunuz? Ham hâlini saklasam olmaz mı?</b></summary>

Aynı numara bir CSV'de altı farklı biçimde yazılabilir: `0532 111 22 33`, `+90 532 111 22 33`, `905321112233`, `532-111-22-33`… Bunları olduğu gibi saklamak, "bu iki kayıt aynı kişi mi?" sorusunu sonsuza dek cevapsız bırakır ve telefonla arama yapmayı imkânsızlaştırır.

Normalizasyon **kayıp veri değil, kanonik biçimdir**: rakam dışı karakterler zaten bilgi taşımıyordu. Ekranda gösterirken `format_phone()` ile okunur hâle geri döndürülür.

10 haneye oturmayan numaralar **hata sayılmaz**: uyarıyla birlikte olduğu gibi saklanır, çünkü eksik bir telefon yüzünden geçerli bir kişi kaydını çöpe atmak veriyi düzeltmez — sadece kaybeder.
</details>

<details>
<summary><b>Hata raporu neden ayrı bir CSV? Ekranda göstersek yetmez mi?</b></summary>

Ekranda göstermek "kaç satır hatalı" sorusunu cevaplar; rapor ise **düzeltmeyi mümkün kılar**. Kullanıcı raporu indirir, Excel'de açar, satır numarasına ve sebebe bakarak kaynak dosyasını düzeltir ve yeniden yükler.

Rapor oturuma bağlı bir token'la korunur ve `IMPORT_TTL_MIN` sonra silinir. Formül enjeksiyonuna karşı hücreler `csv_cell()` ile korunur — bu dosya kullanıcının kendi (muhtemelen düşmanca) metnini geri yazdığı için burada koruma zorunludur.
</details>

<details>
<summary><b>`dedupe=update` mevcut kaydın hangi alanlarını değiştiriyor?</b></summary>

`name`, `phone`, `city` ve `import_id`. `email` değişmez — zaten eşleşme anahtarıdır. `created_at` de değişmez; kaydın ne zaman **ilk** girdiği bilgisi korunur, `updated_at` ise otomatik tazelenir.

Bu seçim bilinçlidir: bir güncelleme dosyası kaydın kimliğini değil, **niteliklerini** tazelemek içindir.
</details>

---

### Canlı Ortama Alırken

- [ ] `APP_DEBUG` zaten ortamdan türetiliyor — yine de canlıda `false` olduğunu **doğrulayın**
- [ ] `system/config.local.php` oluşturun; künyeyi `config.php`'ye **yazmayın**
- [ ] Veritabanı için `root` yerine **sınırlı yetkili** bir kullanıcı oluşturun
- [ ] `storage/imports/` klasörünün web'den erişilemediğini **doğrulayın** (tarayıcıda deneyin)
- [ ] Klasör yazılabilir olsun ama **çalıştırılabilir olmasın**
- [ ] **HTTPS** kullanın; `session.cookie_secure = 1` ve `session.cookie_httponly = 1` ayarlayın
- [ ] **Giriş sistemi ekleyin** — kimlik doğrulaması olmayan bir içe aktarma ekranı, herkese açık bir yazma noktasıdır
- [ ] `IMPORT_MAX_ROWS` / `IMPORT_MAX_BYTES` değerlerini sunucunuzun `upload_max_filesize` ve `post_max_size` ayarlarıyla **uyumlu** tutun
- [ ] Nginx kullanıyorsanız `.htaccess` çalışmaz; şu iki yolu sunucu yapılandırmasından kapatın:
  ```nginx
  location ~* \.(sql|log|ini|bak)$        { deny all; }
  location ^~ /storage/                    { deny all; }
  ```
- [ ] Geçici dosya temizliğinin çalıştığını doğrulayın (`storage/imports/` birikmemeli)

---

### Sorun Giderme

| Belirti | Çözüm |
|---------|-------|
| **"Veritabanına bağlanılamadı"** | MySQL çalışmıyor veya `DB_*` bilgileri hatalı. XAMPP panelinden MySQL'i başlatın. |
| **"Dosya kaydedilemedi"** | `storage/imports/` yok ya da yazılabilir değil. Klasörü oluşturup izin verin. |
| **"Yalnızca CSV/metin dosyası kabul edilir"** | `finfo` dosyayı metin olarak tanımadı. Genelde gerçekten CSV olmayan (xlsx, zip) bir dosya seçilmiştir. `fileinfo` eklentisinin açık olduğunu da kontrol edin. |
| **Türkçe karakterler önizlemede bozuk** | Kodlama tespiti kaçırmış olabilir. Dosyayı Excel'de "CSV UTF-8" olarak yeniden kaydedip deneyin; künyedeki **Kodlama** alanı ne dediğine bakın. |
| **Tüm satırlar tek sütuna düşmüş** | Ayraç yanlış tespit edilmiş. Künyedeki **Ayraç** alanına bakın; dosyanın ilk satırında ayraç hiç geçmiyorsa varsayılan `,` kullanılır. |
| **HTTP 410 "dosya bulunamadı"** | Geçici dosyanın 30 dakikalık ömrü dolmuş. Dosyayı yeniden yükleyin. |
| **HTTP 409 dönüyor** | Bu bir arıza değil: **atomik fren**. Dosyada hatalı satır var ve hiçbir şey yazılmadı. Kısmi moda geçin ya da dosyayı düzeltin. |
| **HTTP 403 dönüyor** | Oturum düşmüş — sayfayı yenileyin. Sunucuda `session.save_path` yazılabilir olmalıdır. |
| **CSV Excel'de bozuk görünüyor** | İndirilen dosya UTF-8 BOM'lu ve `;` ayraçlıdır. "Metni Sütunlara Dönüştür" ile açtıysanız ayracı `;` seçin. |
| **`$ is not defined`** | JavaScript yükleme sırası bozulmuş. jQuery **her zaman** en başta gelmelidir. |
| **DataTables "Requested unknown parameter"** | `<th>` sayısı ile sunucudan dönen dizi uzunluğu farklı. İkisini eşitleyin. |
| **Örnek dosya düğmesi 404 veriyor** | `sample.csv` proje kökünde olmalı; ZIP'ten çıkarırken atlanmış olabilir. |

---

### Yol Haritası

- [ ] Sütun eşlemesini **kaydetme**: aynı biçimdeki dosya ikinci kez yüklendiğinde eşleme hazır gelsin
- [ ] Arka plan kuyruğu ile **büyük dosya** desteği (parça parça işleme + ilerleme çubuğu)
- [ ] Excel (XLSX) dosyalarını doğrudan okuma
- [ ] İçe aktarmayı **geri alma**: bir künyeye bağlı tüm kayıtları tek tıkla kaldırma
- [ ] Satır bazlı düzeltme: hatalı satırları ekranda düzeltip yeniden deneme
- [ ] Kullanıcı girişi ve rol tabanlı yetkilendirme
- [ ] PHPUnit ile birim testleri (`detect_delimiter`, `csv_to_utf8`, `normalize_phone`, `validate_row`)
- [ ] Koyu tema için elle açma/kapama düğmesi

---

### Katkı

**Bu proje herkese açıktır — dilediğiniz geliştirmeyle katkı sağlayabilirsiniz.**

📦 **Depo:** [github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables](https://github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables)

| Nasıl katkı sağlarım? | Nereden |
|----------------------|---------|
| 🐛 Hata bildir | [Issues](https://github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables/issues) |
| 💡 Özellik öner | [Issues](https://github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables/issues) |
| 🔧 Kod gönder | [Pull Requests](https://github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables/pulls) |
| ❓ Soru sor | [Discussions](https://github.com/CilginYazilim/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables/discussions) |

#### Katkı ölçütleri

- **Kod açıklamalı olsun.** Bu projenin temel amacı öğretmek; yorumsuz kod PR'ı geri döner.
- **`preview` ve `commit` aynı doğrulama fonksiyonunu kullanmalıdır.** İkisini ayıran PR kabul edilmez — önizlemenin doğruluğu projenin tezidir.
- **Yeni bir yazma işlemi eklerken** künye kaydını ve transaction'ı unutmayın.
- **Tasarım değişikliklerini** `style.css` üzerinden yapın; `cilginyazilim.css` markaya aittir ve diğer projelerle **ortaktır**.
- Yeni bir dış kütüphane eklemeden önce issue açıp tartışalım — proje bilinçli olarak bağımlılıksızdır.

---

### Lisans

[MIT](LICENSE) — ticari kullanım dahil serbesttir.

#### Önce bir deneyin

<a href="https://cilginyazilim.com/kutuphane/uygulama/PHP-MySQL-CSV-Import-Ice-Aktarma-PDO-Ajax-DataTables-main/"><img src="https://img.shields.io/badge/CANLI_DEMOYU_A%C3%87-0b5cb5?style=for-the-badge&logo=googlechrome&logoColor=white&labelColor=061321" alt="Canlı Demoyu Aç" height="42"></a>
<a href="https://cilginyazilim.com/kutuphane"><img src="https://img.shields.io/badge/D%C4%B0%C4%9EER_%C3%96RNEKLER-061321?style=for-the-badge&logo=bookstack&logoColor=white&labelColor=061321" alt="Diğer Örnekler" height="42"></a>

**[cilginyazilim.com](https://cilginyazilim.com)** tarafından ❤ ile geliştirildi

## Sık Sorulan Sorular

### Dry-run önizleme tam olarak ne yapıyor?

Yüklenen dosyanın tamamını, gerçek içe aktarmayla birebir aynı doğrulama fonksiyonundan geçirir ama tek bir INSERT çalıştırmaz. Sonuçta her satırın ne olacağını gösteren bir özet çıkar: kaç satır temiz, kaç tanesi uyarılı, kaç tanesi hatalı, kaç tanesi dosya içinde veya veritabanında tekrar ediyor. Bu simetri bilinçlidir: önizleme ayrı bir kod kullansaydı zamanla ikisi ayrışır ve "önizlemede temizdi ama aktarırken patladı" durumu doğardı.

### Atomik mod ile kısmi mod arasındaki fark nedir?

Atomik modda tüm satırlar tek bir transaction içinde işlenir ve ilk hatada her şey geri alınır; HTTP 409 döner ve veritabanına hiçbir satır girmez. Muhasebe ve finans gibi "yarım veri, yanlış veridir" durumları için uygundur. Kısmi modda geçerli satırlar yazılır, hatalılar atlanır ve satır numarasıyla birlikte indirilebilir bir CSV raporuna düşer. Büyük ve kirli dosyalarda tercih edilir, çünkü yüzde ikilik bozuk veri yüzünden yüzde doksan sekizlik temiz veriyi reddetmez.

### Türkçe Excel'den çıkan bozuk karakterler nasıl çözülüyor?

Excel'in CSV kaydetme seçenekleri dosyayı çoğu zaman UTF-8 değil Windows-1254 olarak yazar; bu dosya doğrudan okunduğunda Türkçe harfler bozulur ve json_encode() geçersiz baytta sessizce false döner. Uygulama sırayla üç şeye bakar: önce UTF-8 BOM var mı, sonra içerik geçerli UTF-8 olarak çözümleniyor mu, ve ancak ikisi de olmazsa Windows-1254 varsayıp çevirir. Çeviri son çaredir; mb_detect_encoding() tek başına kullanılsaydı geçerli UTF-8 dosyaları da yanlışlıkla çevrilirdi.

### Aynı dosyayı iki kez yüklersem kayıtlar ikiye katlanır mı?

Hayır. Asıl koruma uygulama kodunda değil, e-posta sütunundaki benzersiz indekstedir. "Önce SELECT, yoksa INSERT" kontrolü iki eşzamanlı istekte yarışır ve kaybeder; veritabanı kısıtı kaybetmez. Kod yine de önce SELECT yapar ama bunu önizleme için yapar, yani kullanıcıya "bu üç satır zaten var" diyebilmek için. Yazma anındaki gerçek koruma indeksin kendisidir ve dönen hata, indeks adına bakılarak diğer kısıt ihlallerinden ayırt edilir.

### Yüklenen dosyalar sunucuda nerede duruyor, güvenli mi?

Dosyalar web'e kapalı bir klasöre (storage/imports) alınır ve adları oturum kimliğiyle öneklenir; token yalnızca onaltılık olabildiği için yol manipülasyonu mümkün değildir. Dosya türü uzantıdan değil içerikten okunur ve metin dışı türler reddedilir. İçe aktarma bittiğinde kaynak dosya hemen silinir; sahipsiz kalanlar otuz dakika sonra her istekte çalışan fırsatçı temizlikle kaldırılır. Dosya içeriği hiçbir zaman çalıştırılmaz, yalnızca okunur.

### Kendi tablomda kullanmak için neleri değiştirmem gerekir?

Dört yer: config.php içindeki TARGET_FIELDS ve REQUIRED_FIELDS sabitleri, function.php içindeki validate_row() doğrulama kuralları ve field_label() etiketleri, ajax.php içindeki INSERT ve UPDATE sorguları, ve benzersizlik anahtarınız (bu örnekte e-posta, sizde stok kodu olabilir) — indeks adını hem şemada hem hata ayıklama kontrolünde güncelleyin. İçe aktarma künyesi tablosu alanlarınızdan bağımsızdır, hiç değiştirmeden kullanabilirsiniz. Değiştirmemeniz gereken tek şey, önizleme ile aktarmanın aynı doğrulama fonksiyonunu çağırmasıdır.

---

Kaynak: [CSV İçe Aktarma](https://cilginyazilim.com/kutuphane/php-csv-import)
