Files
kolaytercih/scripts/veri-kalite.ts
bilalgursen 2ff8fe584b fix(veri): kimlik kilidi — kodu başka programa devredilmiş satırlar yakalanır ve onarılır
Kılavuz kodu başka programa devredildiğinde (ör. 209210292: Diş Hekimliği SAY
lisans → İlk ve Acil Yardım TYT önlisans) refresh yalnız koda bakıp yıl
kolonlarını yazıyordu; satır eski adıyla yeni programın sırasını gösteriyordu.

- scripts/kimlik.ts (yeni): kimlik = puan türü + önlisans + baz ad slug'ı.
  Devir yılı kanıt zinciriyle bulunur (API …1/…2/…3 sıra/puanı DB ile eşleşen
  ardışık yıllar + netler.puanTuru); API geçmişi kod bazlı ve kirli olduğu
  için (gk2, bazı basariSirasi3) zincirsiz güvenilmez. Yalnız adı değişen ve
  API penceresinin tamamında sürekliliği olan satır "ad değişimi"dir, geçmişi
  korunur.
- refresh: uyumsuz satır varsa listeler ve HİÇBİR ŞEY yazmadan durur;
  --kimlik-onar ile isim/tur/onlisans/fakulte/sure API'den yazılır, devir
  öncesi tüm yıl kolonları NULL'lanır, eski kimlik + geçmişi programs_arsiv'e
  <kod>~<yıl> id'siyle kopyalanır, eski yılların netleri oraya taşınır,
  veri_meta'ya kimlik_onar:<kod> kaydı düşer. Önceki-yıl sağlaması onarılanlar
  dışındaki satırlar için aynen geçerli. --isim-esitle (varsayılan kapalı):
  kimliği aynı, yalnız burs/dil eki farklı satırlarda isim API'den.
- gecmis-doldur: onarılmış satırda devir öncesi yılları hiçbir kaynaktan
  doldurmaz (yoksa API'nin kod bazlı …N alanları eski programı geri yazar).
- veri-kalite: --ham ile ZORUNLU kimlik kontrolü (0 uyumsuz beklenir);
  --onceki karşılaştırmasında onarılanlar ayrı doğrulanır.
- docs/veri/yillik-guncelleme.md: kimlik kilidi adımı.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:24:29 +03:00

300 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* yokatlas.db veri kalitesi denetimi — SALT OKUNUR. Yayın öncesi kapı:
* bir ZORUNLU kontrol başarısızsa çıkış kodu 1 (CI/runbook durur).
* UYARI'lar çıkış kodunu etkilemez; insan gözüyle bakılır.
*
* Kullanım:
* pnpm veri-kalite --yil 2026 --db /tmp/deneme.db [--onceki data/yokatlas.db] [--arsivli] [--ham <dosya>]
*
* --ham <dosya> Ham API çekimi (yoksa data/ham altındaki en yenisi). Verilirse
* KİMLİK kontrolü koşar: her DB satırının puan türü / önlisans /
* baz ad slug'ı aynı koddaki API kaydıyla uyuşmalı (0 beklenir;
* uyuşmayan = kılavuz kodu başka programa devredilmiş, satır eski
* adıyla yeni programın sırasını gösteriyor → refresh --kimlik-onar).
* --onceki <db> Güncelleme ÖNCESİ DB. Verilirse: iki DB'de de bulunan her
* programın önceki yıl kolonları (sira/puan/kontenjan/yerlesen)
* birebir aynı mı (dolu değer değişmiş/silinmiş olamaz).
* --arsivli programs içinde güncel kılavuzda olmayan satır kalmamış olmalı.
*/
import path from "node:path";
import { EFEKTIF_SIRA_SQL, ILK_YIL } from "../src/lib/veri-yillari";
import { EFEKTIF_INDEKS, efektifSorguPlani } from "./db-index";
import { kimlikFarklari, onarimKayitlari, yilKolonlari, type DbKimlik } from "./kimlik";
import { hamOku, sonHamDosya, TUR_MAP } from "./refresh";
import {
argOku,
bayrakVar,
calistir,
dbAc,
dbArgumani,
dur,
kolonAdlari,
metaOku,
tabloVar,
yilArgumani,
} from "./yokatlas-goc";
const TURLER = ["SAYISAL", "EŞİT AĞIRLIK", "SÖZEL", "DİL", "TYT"];
async function main() {
const yil = yilArgumani();
const dbYolu = dbArgumani();
const oncekiArg = argOku("onceki");
const arsivli = bayrakVar("arsivli");
const db = dbAc(dbYolu, { saltOkunur: true });
const onceki = yil - 1;
let basarisiz = 0;
let uyari = 0;
const zorunlu = (ad: string, tamam: boolean, detay: string) => {
console.log(`${tamam ? "GEÇTİ " : "KALDI "} ${ad} — ${detay}`);
if (!tamam) basarisiz++;
};
const bilgi = (ad: string, sorunlu: boolean, detay: string) => {
console.log(`${sorunlu ? "UYARI " : "bilgi "} ${ad} — ${detay}`);
if (sorunlu) uyari++;
};
const sayi = (sql: string, ...args: unknown[]) =>
(db.prepare(sql).get(...args) as { n: number }).n;
const ornek = (sql: string, ...args: unknown[]) =>
(db.prepare(sql).raw().all(...args) as unknown[][]).map((r) => r.join("/")).join(" ; ");
console.log(`DB: ${dbYolu}\nYıl: ${yil} Efektif sıra: ${EFEKTIF_SIRA_SQL}\n`);
// 1. Dosya bütünlüğü
const butunluk = (db.pragma("integrity_check") as { integrity_check: string }[])
.map((r) => r.integrity_check)
.join(",");
zorunlu("integrity_check", butunluk === "ok", butunluk);
// 2. Şema
const kolonlar = new Set(kolonAdlari(db, "programs"));
const gerekli = [`sira${yil}`, `puan${yil}`, `kontenjan${yil}`, `yerlesen${yil}`, "son_kilavuz_yili", "eski_kod"];
const eksikKolon = gerekli.filter((k) => !kolonlar.has(k));
zorunlu("şema", eksikKolon.length === 0, eksikKolon.length ? `eksik: ${eksikKolon.join(", ")}` : "yıl kolonları + son_kilavuz_yili + eski_kod var");
if (eksikKolon.length) {
dur("Şema eksik; kalan kontroller koşulamaz.");
}
// 3. Kapsam: güncel satır = API toplamı − atlanan
const apiToplam = Number(metaOku(db, `refresh_${yil}_api_toplam`) ?? NaN);
const atlanan = Number(metaOku(db, `refresh_${yil}_atlanan`) ?? NaN);
const guncel = sayi("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ?", yil);
zorunlu(
"kapsam",
Number.isFinite(apiToplam) && guncel === apiToplam - atlanan,
`son_kilavuz_yili=${yil}: ${guncel}; API toplamı ${apiToplam} − atlanan ${atlanan} = ${apiToplam - atlanan}`
);
// 4. Doluluk
const sirali = sayi(`SELECT COUNT(sira${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
const oncekiSirali = sayi(`SELECT COUNT(sira${onceki}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
const doluOran = guncel ? sirali / guncel : 0;
zorunlu(
`sira${yil} doluluk`,
doluOran >= 0.7,
`${sirali}/${guncel} (%${(doluOran * 100).toFixed(1)}; eşik %70). Aynı satırlarda sira${onceki} dolu: ${oncekiSirali}`
);
const puanli = sayi(`SELECT COUNT(puan${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
const kontenjanli = sayi(`SELECT COUNT(kontenjan${yil}) AS n FROM programs WHERE son_kilavuz_yili = ?`, yil);
bilgi(`${yil} diğer ölçüler`, kontenjanli < guncel * 0.95, `puan dolu ${puanli}, kontenjan dolu ${kontenjanli}, toplam ${guncel}`);
// 5. Yıllar arası tutarlılık: sira Y / sira Y-1 ∈ [0,5; 2]
const ikisiDolu = sayi(`SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${onceki} IS NOT NULL`);
const aralikta = sayi(
`SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${onceki} IS NOT NULL
AND sira${yil} * 1.0 / sira${onceki} BETWEEN 0.5 AND 2`
);
const aralikOran = ikisiDolu ? aralikta / ikisiDolu : 0;
zorunlu(
`sira${yil}/sira${onceki} ∈ [0,5; 2]`,
ikisiDolu > 0 && aralikOran >= 0.9,
`${aralikta}/${ikisiDolu} (%${(aralikOran * 100).toFixed(1)}; eşik %90)`
);
const ayniSira = sayi(
`SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND sira${yil} = sira${onceki} AND puan${yil} = puan${onceki}`
);
zorunlu(
"yıl kayması (kopya yıl)",
ikisiDolu > 0 && ayniSira / ikisiDolu < 0.02,
`sıra VE puanı ${onceki} ile birebir aynı satır: ${ayniSira}/${ikisiDolu} (çoksa ${yil} kolonuna ${onceki} verisi yazılmıştır)`
);
// 6. Değer aralıkları (tüm yıllar)
const yillar: number[] = [];
for (let y = ILK_YIL; y <= yil; y++) if (kolonlar.has(`sira${y}`)) yillar.push(y);
const bozukKosul = yillar
.map((y) => `sira${y} <= 0 OR sira${y} > 5000000 OR puan${y} <= 0 OR puan${y} > 600 OR kontenjan${y} < 0 OR yerlesen${y} < 0`)
.join(" OR ");
const bozuk = sayi(`SELECT COUNT(*) AS n FROM programs WHERE ${bozukKosul}`);
zorunlu("değer aralığı", bozuk === 0, `0/negatif/aşırı sıra-puan-kontenjan: ${bozuk}${bozuk ? ` ör. ${ornek(`SELECT id FROM programs WHERE ${bozukKosul} LIMIT 3`)}` : ""}`);
const siraPuanTutarsiz = sayi(
`SELECT COUNT(*) AS n FROM programs WHERE sira${yil} IS NOT NULL AND puan${yil} IS NULL`
);
zorunlu(`sira${yil} var, puan${yil} yok`, siraPuanTutarsiz === 0, `${siraPuanTutarsiz} satır`);
// 7. Kategorik alanlar
const bozukTur = sayi(
`SELECT COUNT(*) AS n FROM programs WHERE tur NOT IN (${TURLER.map(() => "?").join(",")}) OR onlisans NOT IN (0, 1)`,
...TURLER
);
zorunlu("tur/onlisans", bozukTur === 0, `geçersiz değer: ${bozukTur}`);
const tytTutarsiz = sayi("SELECT COUNT(*) AS n FROM programs WHERE (tur = 'TYT') <> (onlisans = 1) AND son_kilavuz_yili = ?", yil);
bilgi("TYT ↔ önlisans", tytTutarsiz > 0, `tur=TYT ile onlisans=1 örtüşmeyen güncel satır: ${tytTutarsiz}${tytTutarsiz ? ` ör. ${ornek("SELECT id, tur, onlisans FROM programs WHERE (tur = 'TYT') <> (onlisans = 1) AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
const uniturBos = sayi("SELECT COUNT(*) AS n FROM programs WHERE unitur IS NULL AND son_kilavuz_yili = ?", yil);
bilgi("unitur boş", uniturBos > 0, `güncel satırlarda ${uniturBos} (devlet/vakıf filtresinden düşer)${uniturBos ? ` ör. ${ornek("SELECT DISTINCT universite FROM programs WHERE unitur IS NULL AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
const uniturlar = ornek("SELECT COALESCE(unitur, '(NULL)'), COUNT(*) FROM programs GROUP BY unitur ORDER BY 2 DESC");
bilgi("unitur dağılımı", false, uniturlar);
const ilBos = sayi("SELECT COUNT(*) AS n FROM programs WHERE (il IS NULL OR TRIM(il) = '') AND son_kilavuz_yili = ?", yil);
bilgi("il boş", ilBos > 0, `güncel satırlarda ${ilBos}${ilBos ? ` ör. ${ornek("SELECT id, universite FROM programs WHERE (il IS NULL OR TRIM(il) = '') AND son_kilavuz_yili = ? LIMIT 3", yil)}` : ""}`);
// 8. Kontenjan–yerleşen
const tasan = sayi(`SELECT COUNT(*) AS n FROM programs WHERE yerlesen${yil} > kontenjan${yil} * 1.5 + 5`);
bilgi(`yerlesen${yil} ≫ kontenjan${yil}`, tasan > guncel * 0.02, `yerleşen > 1,5×genel kontenjan + 5: ${tasan} (yerleşen okul birincisi/şehit-gazi/deprem kontenjanlarını da içerir)${tasan ? ` ör. ${ornek(`SELECT id, kontenjan${yil}, yerlesen${yil} FROM programs WHERE yerlesen${yil} > kontenjan${yil} * 1.5 + 5 LIMIT 3`)}` : ""}`);
const doluAmaSirasiz = sayi(`SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili = ? AND sira${yil} IS NULL AND yerlesen${yil} > 0`, yil);
bilgi(`yerleşen var, sira${yil} yok`, false, `${doluAmaSirasiz} (API az yerleşenli programda sıra vermez; efektif sıra ${onceki}'e düşer)`);
// 9. Eski/çift satırlar
const eskiSatir = sayi("SELECT COUNT(*) AS n FROM programs WHERE son_kilavuz_yili IS NOT ?", yil);
if (arsivli) zorunlu("arşiv", eskiSatir === 0, `programs içinde güncel kılavuzda olmayan satır: ${eskiSatir}`);
else bilgi("arşivlenmemiş eski satır", eskiSatir > 0, `${eskiSatir} satır güncel kılavuzda yok ama sitede listelenir (efektif sıra ${onceki}'e düşer)`);
const ciftAd = sayi(
`SELECT COUNT(*) AS n FROM (SELECT 1 FROM programs WHERE son_kilavuz_yili = ?
GROUP BY universite, fakulte, isim, tur HAVING COUNT(*) > 1)`,
yil
);
bilgi("aynı adlı güncel program", false, `(universite, fakulte, isim, tur) yinelenen grup: ${ciftAd} (özel kontenjanlı adaş programlar olağandır)${ciftAd ? ` ör. ${ornek("SELECT universite, isim, COUNT(*) FROM programs WHERE son_kilavuz_yili = ? GROUP BY universite, fakulte, isim, tur HAVING COUNT(*) > 1 LIMIT 3", yil)}` : ""}`);
const eskiKodCakisma = sayi(
"SELECT COUNT(*) AS n FROM (SELECT eski_kod FROM programs WHERE eski_kod IS NOT NULL AND son_kilavuz_yili = ? GROUP BY eski_kod HAVING COUNT(*) > 1)",
yil
);
bilgi("eski_kod paylaşımı (bölünme)", eskiKodCakisma > 0, `birden çok güncel programın işaret ettiği eski kod: ${eskiKodCakisma}`);
// 9b. Kimlik: kod aynı, program aynı mı? (ham çekim gerekir)
const hamArg = argOku("ham");
const hamDosya = hamArg
? path.resolve(process.cwd(), hamArg)
: sonHamDosya(yil, path.resolve(process.cwd(), "data", "ham"));
const onarimlar = onarimKayitlari(db);
if (hamDosya) {
const { kayitlar } = await hamOku(hamDosya, yil);
const getir = db.prepare("SELECT id, isim, tur, onlisans, fakulte, sure, universite FROM programs WHERE id = ?");
const uyumsuz: string[] = [];
let kiyaslanan = 0;
let ekFarki = 0;
for (const r of kayitlar) {
if (!r.kilavuzKodu) continue;
const satir = getir.get(String(r.kilavuzKodu)) as (DbKimlik & { universite: string }) | undefined;
if (!satir) continue;
kiyaslanan++;
const farklar = kimlikFarklari(satir, r, TUR_MAP[r.puanTuru?.trim() ?? ""]);
if (farklar.length) {
uyumsuz.push(`${satir.id}[${farklar.join("+")}] DB="${satir.isim}"/${satir.tur}/${satir.onlisans} API="${r.birimAdi}"/${r.puanTuru}/${r.birimTuruId === 47 ? 1 : 0} (${satir.universite})`);
} else if (satir.isim !== r.birimAdi) {
ekFarki++;
}
}
zorunlu(
"kimlik (tur/önlisans/baz ad = API)",
uyumsuz.length === 0,
`${kiyaslanan} satır kıyaslandı, uyumsuz: ${uyumsuz.length}${uyumsuz.length ? ` ör. ${uyumsuz.slice(0, 3).join(" ; ")}` : ""} [${path.basename(hamDosya)}]`
);
bilgi("isim eki (burs/dil) API'den farklı", false, `${ekFarki} satır — kimlik aynı, DB adı eksiz/farklı yazımlı (aynı üniversitede adaş varyantlar ayırt edilemez)`);
} else {
bilgi("kimlik", true, "ATLANDI: --ham verilmedi ve data/ham altında çekim yok; kod devri denetlenemedi");
}
if (onarimlar.size) {
// Onarılan satırda devir öncesi hiçbir yıl kolonu dolu kalmış olamaz.
const yk = yilKolonlari(db);
let sizinti = 0;
const satirGetir = db.prepare("SELECT * FROM programs WHERE id = ?");
for (const [id, k] of onarimlar) {
const s = satirGetir.get(id) as Record<string, unknown> | undefined;
if (!s) continue;
if (yk.some((c) => c.yil < k.devirYili && s[c.kolon] != null)) sizinti++;
}
zorunlu("kimliği onarılanlar: devir öncesi boş", sizinti === 0, `${onarimlar.size} onarılmış satırın ${sizinti} tanesinde devir öncesi yıl yeniden dolmuş`);
}
// 10. netler
if (tabloVar(db, "netler")) {
const arsivVar = tabloVar(db, "programs_arsiv");
const yetim = sayi(
`SELECT COUNT(*) AS n FROM netler WHERE program_id NOT IN (SELECT id FROM programs)
${arsivVar ? "AND program_id NOT IN (SELECT id FROM programs_arsiv)" : ""}`
);
bilgi("yetim netler", yetim > 0, `ne programs ne arşivde karşılığı olan: ${yetim}`);
const netliGuncel = sayi(
"SELECT COUNT(DISTINCT n.program_id) AS n FROM netler n JOIN programs p ON p.id = n.program_id WHERE p.son_kilavuz_yili = ?",
yil
);
bilgi("netler kapsamı", false, `net satırı olan güncel program: ${netliGuncel}/${guncel}; yıl dağılımı: ${ornek("SELECT yil, COUNT(*) FROM netler GROUP BY yil ORDER BY yil")}`);
}
// 11. Sorgu planı
const plan = efektifSorguPlani(db);
zorunlu("sorgu planı", plan.some((s) => s.includes(EFEKTIF_INDEKS)), plan.join(" | "));
// 12. Önceki DB ile geçmiş yıl karşılaştırması
if (oncekiArg) {
const oncekiYol = path.resolve(process.cwd(), oncekiArg);
db.prepare("ATTACH DATABASE ? AS eski").run(oncekiYol);
const eskiKolonlar = new Set(
(db.prepare("PRAGMA eski.table_info(programs)").all() as { name: string }[]).map((c) => c.name)
);
const ortak: string[] = [];
for (let y = ILK_YIL; y < yil; y++) {
for (const o of ["sira", "puan", "kontenjan", "yerlesen"]) {
if (kolonlar.has(`${o}${y}`) && eskiKolonlar.has(`${o}${y}`)) ortak.push(`${o}${y}`);
}
}
const simdi = tabloVar(db, "programs_arsiv")
? `(SELECT id, ${ortak.join(", ")} FROM main.programs UNION ALL SELECT id, ${ortak.join(", ")} FROM main.programs_arsiv)`
: "main.programs";
const kaybolan = sayi(`SELECT COUNT(*) AS n FROM eski.programs e WHERE e.id NOT IN (SELECT id FROM ${simdi})`);
zorunlu("önceki DB: satır kaybı", kaybolan === 0, `önceki DB'de olup şimdi ne programs ne arşivde olan: ${kaybolan}`);
// Dolu değer değişmiş ya da NULL'a dönmüş olamaz; NULL → dolu (gecmis-doldur) serbest.
// Kimliği onarılan satırlarda devir öncesi yıllar BİLEREK NULL'landı; onlar
// ayrı sayılır: devir yılı ve sonrası yine birebir korunmuş olmalı.
const onarilanJson = JSON.stringify([...onarimlar.keys()]);
const degisen = sayi(
`SELECT COUNT(*) AS n FROM eski.programs e JOIN ${simdi} s ON s.id = e.id
WHERE e.id NOT IN (SELECT value FROM json_each(?))
AND (${ortak.map((k) => `(e.${k} IS NOT NULL AND e.${k} IS NOT s.${k})`).join(" OR ")})`,
onarilanJson
);
zorunlu("önceki DB: geçmiş yıllar korunmuş", degisen === 0, `${ortak.length} kolonda dolu değeri değişen/silinen satır: ${degisen} (kimliği onarılan ${onarimlar.size} satır hariç)`);
if (onarimlar.size) {
let bozulan = 0;
const eskiGetir = db.prepare("SELECT * FROM eski.programs WHERE id = ?");
const yeniGetir = db.prepare("SELECT * FROM main.programs WHERE id = ?");
for (const [id, k] of onarimlar) {
const e = eskiGetir.get(id) as Record<string, unknown> | undefined;
const s = yeniGetir.get(id) as Record<string, unknown> | undefined;
if (!e || !s) continue;
const korunmali = ortak.filter((kolon) => Number(kolon.slice(-4)) >= k.devirYili);
if (korunmali.some((kolon) => e[kolon] != null && e[kolon] !== s[kolon])) bozulan++;
}
zorunlu("önceki DB: onarılanlarda devir sonrası korunmuş", bozulan === 0, `devir yılı ve sonrasında değeri değişen onarılmış satır: ${bozulan}/${onarimlar.size}`);
}
const toplamlar = (tablo: string) =>
(db
.prepare(
`SELECT COUNT(*), TOTAL(sira${onceki}), TOTAL(puan${onceki}), TOTAL(kontenjan${onceki}), TOTAL(yerlesen${onceki}) FROM ${tablo}`
)
.raw()
.get() as number[]).join(" | ");
console.log(` ${onceki} sağlaması (count | Σsira | Σpuan | Σkontenjan | Σyerlesen)`);
console.log(` önceki DB : ${toplamlar("eski.programs")}`);
console.log(` şimdi, önceki DB'deki aynı id'ler : ${toplamlar(`${simdi} WHERE id IN (SELECT id FROM eski.programs)`)}`);
console.log(` şimdi, tüm satırlar (yeni dahil) : ${toplamlar(simdi)}`);
}
db.close();
console.log(`\nSonuç: ${basarisiz} ZORUNLU kontrol kaldı, ${uyari} uyarı.`);
if (basarisiz > 0) process.exit(1);
}
if (process.argv[1]?.endsWith("veri-kalite.ts")) calistir(main);