diff --git a/data/yokatlas.db b/data/yokatlas.db index bd2ed1f..302d2af 100644 Binary files a/data/yokatlas.db and b/data/yokatlas.db differ diff --git a/docs/veri/yillik-guncelleme.md b/docs/veri/yillik-guncelleme.md index eab6772..f0636b2 100644 --- a/docs/veri/yillik-guncelleme.md +++ b/docs/veri/yillik-guncelleme.md @@ -77,6 +77,19 @@ Güncellenen: 19…, eklenen: 1…, atlanan: 0 - Bu betik mevcut programların **eski yıl kolonlarına dokunmaz**; dokunursa kendi sağlaması bunu yakalar ve her şeyi geri alır. +- **Kimlik kilidi.** ÖSYM bazen bir kılavuz kodunu BAŞKA programa verir (2025'te + `209210292` "Diş Hekimliği"nden "İlk ve Acil Yardım" önlisansına geçti). Betik her + mevcut satırın puan türü / önlisans / bölüm adını servistekiyle kıyaslar; uyuşmayan + varsa listeyi basıp **hiçbir şey yazmadan durur** (`KİMLİĞİ KAYMIŞ N satır`). Listeye + göz at (yılda birkaç–birkaç on satır beklenir; yüzlerce ise servis biçim değiştirmiştir, + veri mühendisine göster), sonra aynı komutu `--kimlik-onar` ekleyerek, ham dosyadan + tekrarla: `pnpm refresh --yil 2027 --db --ham data/ham/2027-.jsonl.gz --kimlik-onar`. + Onarım: ad/tür/önlisans/fakülte/süre servisten yazılır; devirden **önceki** yılların + tüm değerleri o satırda boşaltılır (başka programa aitti), eski kimlik + geçmişi + `programs_arsiv`'e `~` id'siyle kopyalanır, eski yılların netleri oraya + taşınır. Çıktıdaki `KİMLİĞİ ONARILAN` tablosunu commit mesajına/rapora koy. + "AD DEĞİŞİMİ" yazan satırlarda yalnız ad güncellenir, geçmiş korunur. + Aynı işlemi servise gitmeden tekrarlamak için (ör. 8. adımda): `pnpm refresh --yil 2027 --db --ham data/ham/2027-.jsonl.gz` @@ -129,7 +142,7 @@ zaten hata verir: sitedeki sıralama aralığı sorguları yavaşlar. ## 7. Kalite kapısı (1 dk) ```bash -pnpm veri-kalite --yil 2027 --db /tmp/veri-2027/yeni.db --onceki /tmp/veri-2027/once.db --arsivli +pnpm veri-kalite --yil 2027 --db /tmp/veri-2027/yeni.db --onceki /tmp/veri-2027/once.db --arsivli --ham data/ham/2027-.jsonl.gz echo "çıkış kodu: $?" # 0 olmalı ``` @@ -137,7 +150,9 @@ echo "çıkış kodu: $?" # 0 olmalı - Her satır `GEÇTİ`, `KALDI`, `UYARI` ya da `bilgi` ile başlar. **Tek bir `KALDI` varsa yayına çıkılmaz**; çıkış kodu 1 olur. -- En önemli iki satır: +- En önemli üç satır: + - `kimlik (tur/önlisans/baz ad = API) — … uyumsuz: 0` — her kod hâlâ aynı programı + gösteriyor (uyumsuzsa satır eski adıyla başka programın sırasını gösterir). - `önceki DB: geçmiş yıllar korunmuş — … değişen/silinen satır: 0` - `sira2027/sira2026 ∈ [0,5; 2] — … (%9x)` — programların büyük çoğunluğunun sırası bir yılda yarıya inmez, iki katına çıkmaz. Oran düşükse yıllar kaymıştır. @@ -211,6 +226,7 @@ Push öncesi `AGENTS.md`'deki "Push öncesi build zorunlu" kuralı geçerli. Yay | `Benzersiz kod sayısı … ≠ API toplamı` | Çekim sırasında servis sayfaları kaydırdı | Birkaç dakika sonra 3. adımı tekrarla (DB'ye bir şey yazılmadı) | | `Aynı üniversitede birden fazla unitur` | Bir kurumun türü iki farklı yazılmış | Listelenen kurumları veri mühendisine göster; `scripts/unitur-onar.ts` | | `Güncel satır sayısı … refresh kaydıyla uyuşmuyor` (arşivle) | refresh yarım kalmış | 3. adımı tekrarla; arşivleme bilerek çalışmıyor | +| `N satırın kimliği API ile uyuşmuyor` (refresh) | Kılavuz kodu başka programa devredilmiş (ya da program ad değiştirmiş) | Listeye bak; 3. adımdaki gibi `--ham … --kimlik-onar` ile tekrarla (DB'ye bir şey yazılmadı) | | `veri-kalite` içinde `KALDI` | O kontrol tutmadı | Yayına çıkma; satırdaki sayıyı ve örnek kodları veri mühendisine ilet | ## Betikler ve dokundukları @@ -218,8 +234,8 @@ Push öncesi `AGENTS.md`'deki "Push öncesi build zorunlu" kuralı geçerli. Yay | Betik | Yazar mı? | Ne yazar | |---|---|---| | `scripts/yokatlas-goc.ts` (`pnpm yokatlas:goc`) | evet | Yalnız şema: yıl kolonları, `son_kilavuz_yili`, `eski_kod`, `programs_arsiv`, `veri_meta`. refresh bunu kendisi çağırır. | -| `scripts/refresh.ts` | evet | `sira/puan/kontenjan/yerlesen`, `son_kilavuz_yili`, `eski_kod`; yeni programlar; ham dosya | -| `scripts/gecmis-doldur.ts` | evet | Yalnız **NULL** geçmiş yıl alanları + `eski_kod` | +| `scripts/refresh.ts` | evet | `sira/puan/kontenjan/yerlesen`, `son_kilavuz_yili`, `eski_kod`; yeni programlar; ham dosya. `--kimlik-onar` ile: kimliği kaymış satırın ad/tür/önlisans/fakülte/süresi, devir öncesi yıl kolonları (NULL), `programs_arsiv` kopyası, `netler.program_id`, `veri_meta` `kimlik_onar:` | +| `scripts/gecmis-doldur.ts` | evet | Yalnız **NULL** geçmiş yıl alanları + `eski_kod`; kimliği onarılmış satırda devir öncesi yılları doldurmaz | | `scripts/eski-arsivle.ts` | `--kuru` ile hayır | Satır taşıma `programs` → `programs_arsiv`; `netler.program_id` yeni koda | | `scripts/db-index.ts` | evet | İndeks, `--vacuum`, WAL checkpoint | | `scripts/veri-kalite.ts` | **hayır** | Salt okunur; çıkış kodu 0/1 | diff --git a/scripts/gecmis-doldur.ts b/scripts/gecmis-doldur.ts index ca84bca..3e2e8f4 100644 --- a/scripts/gecmis-doldur.ts +++ b/scripts/gecmis-doldur.ts @@ -19,12 +19,17 @@ * en az bir ortak değer AYNIYSA (kanıt) → eski satırdan doldurulur, güncel * satıra `eski_kod` yazılır. Kanıtsız ya da çelişkili eşleşme birleştirilmez. * + * Kimliği onarılmış satırlar (refresh --kimlik-onar; bkz. kimlik.ts): devir + * yılından ÖNCEKİ yıllar hiçbir kaynaktan doldurulmaz — o yıllarda bu kod başka + * programa aitti ve API'nin kod bazlı `…N` alanları o programın değerini verir. + * * Kullanım: * pnpm gecmis-doldur --yil 2026 --db /tmp/deneme.db [--ham data/ham/2026-….jsonl.gz] [--kuru] */ import path from "node:path"; import type Database from "better-sqlite3"; import { ILK_YIL } from "../src/lib/veri-yillari"; +import { onarimKayitlari } from "./kimlik"; import { gecmisAlanlari, hamOku, sonHamDosya } from "./refresh"; import { argOku, @@ -95,6 +100,16 @@ async function main() { const arsivGetir = tabloVar(db, "programs_arsiv") ? db.prepare("SELECT * FROM programs_arsiv WHERE id = ?") : null; + // id → devir yılı: bu yıldan önceki kolonlar o satırda BİLEREK boş. + const devirYillari = new Map(); + for (const [id, k] of onarimKayitlari(db)) devirYillari.set(id, k.devirYili); + let devirOncesiAtlanan = 0; + const devirOncesi = (id: string, y: number): boolean => { + const devir = devirYillari.get(id); + if (devir == null || y >= devir) return false; + devirOncesiAtlanan++; + return true; + }; const setCache = new Map>(); const alanYaz = (id: string, kolon: string, deger: number) => { let st = setCache.get(kolon); @@ -109,6 +124,7 @@ async function main() { /** Hedefin NULL alanlarını kaynaktan doldurur; dolu+farklıyı sayar. */ const satirdanDoldur = (kaynakAdi: string, hedef: Satir, kaynak: Satir) => { for (const y of gecmisYillar) { + if (devirOncesi(hedef.id, y)) continue; for (const olcu of olculer) { const kolon = `${olcu}${y}`; const k = kaynak[kolon] as number | null; @@ -152,6 +168,7 @@ async function main() { for (const g of gecmisAlanlari(r)) { const y = yil - g.fark; if (!gecmisYillar.includes(y)) continue; + if (devirOncesi(hedef.id, y)) continue; for (const olcu of ["sira", "puan", "kontenjan"] as const) { const k = g[olcu]; if (k == null) continue; @@ -279,6 +296,13 @@ async function main() { if (v.length) console.log(` örnek ${k}: ${v.join(" ; ")}`); } + if (devirYillari.size) { + console.log( + `Kimliği onarılmış ${devirYillari.size} satırda devir öncesi yıl BİLEREK boş bırakıldı ` + + `(atlanan yıl denemesi: ${devirOncesiAtlanan}).` + ); + } + console.log(`\nDoldurulan NULL alanlar${kuru ? " (kuru — yazılmadı)" : ""}:`); const toplamlar = new Map(); for (const [k, n] of [...doldurulan].sort()) { diff --git a/scripts/kimlik.ts b/scripts/kimlik.ts new file mode 100644 index 0000000..10c45a5 --- /dev/null +++ b/scripts/kimlik.ts @@ -0,0 +1,281 @@ +/** + * Program KİMLİĞİ tutarlılığı: kılavuz kodu (programs.id) yıllar içinde BAŞKA + * bir programa devredilebiliyor (ör. 209210292: 2024'te "Diş Hekimliği, SAY + * lisans" → 2025'ten beri "İlk ve Acil Yardım, TYT önlisans"). refresh yalnız + * koda bakıp yıl kolonlarını yazarsa satır ESKİ adıyla YENİ programın sırasını + * gösterir. Bu modül refresh / veri-kalite / gecmis-doldur'un ortak kurallarıdır. + * + * Kimlik = puan türü + önlisans bayrağı + baz ad slug'ı + * (`turkishSlugify(bolumBazAdi(isim))`; burs/dil ekleri kimlik DEĞİLDİR — + * DB'deki eski satırlar eksiz, API ekli yazıyor). + * + * DEVİR YILI (kanıt zinciri; tahmin yok): SON yıl kesin olarak yeni programındır + * (API kaydı o kılavuzdan). Geriye doğru her yıl için API'nin `…1/…2/…3` sıra/ + * puanı DB'deki değerle EŞLEŞİYORSA o yıl da yeni programındır; eşleşmeyen ya + * da API'nin sıra/puanla sahiplenmediği İLK yılda zincir kopar — o yıl ve + * öncesi eski programa aittir. Zincir ŞART, çünkü API geçmişi kod bazlıdır ve + * kirlidir: `gk2` hep eski programın kontenjanıdır, `basariSirasi3` bazı + * kayıtlarda eski programın sırasıdır (ör. 205590251: TYT Mekatronik'e 2023 için + * DİL sırası 108.099). Sıra eşitliği yerine puan eşitliği de kabul edilir: + * 2024 sırası iki kaynakta farklı tanımlı (bkz. gecmis-doldur), puan aynıdır. + * İkinci bağımsız kaynak `netler.puanTuru`'dur (YÖK Atlas net API'si, yıl bazlı). + */ +import { ILK_YIL } from "../src/lib/veri-yillari"; +import { bolumBazAdi, turkishSlugify } from "../src/lib/slug"; +import { kolonAdlari, metaTablosunuGarantiEt, tabloVar, type Db } from "./yokatlas-goc"; + +/** refresh.ts ile döngüsel import olmasın diye kimliğin gerektirdiği alanlar. */ +export type KimlikKaydi = { + kilavuzKodu: number; + birimAdi: string; + fymkAdi?: string | null; + puanTuru: string; + ogrenimSuresi?: number | null; + birimTuruId: number; +}; + +export type GecmisAlani = { fark: number; sira: number | null; puan: number | null }; + +export type DbKimlik = { + id: string; + isim: string; + tur: string; + onlisans: number; + fakulte: string | null; + sure: number | null; +}; + +export type KimlikFarki = "tur" | "onlisans" | "ad"; + +const PUAN_TOLERANS = 0.01; +const META_ONEK = "kimlik_onar:"; + +export const bazAdSlug = (isim: string): string => turkishSlugify(bolumBazAdi(isim)); + +/** ÖSYM birim türü 47 = önlisans (refresh INSERT dalıyla aynı kural). */ +export const apiOnlisans = (r: KimlikKaydi): number => (r.birimTuruId === 47 ? 1 : 0); + +/** + * DB satırı ile API kaydının kimlik farkları. `apiTur` çözülemediyse (kaynak + * puan türünü vermiyor) tür KIYASLANMAZ — bilinmeyen, fark sayılmaz. + */ +export function kimlikFarklari( + satir: DbKimlik, + r: KimlikKaydi, + apiTur: string | undefined +): KimlikFarki[] { + const farklar: KimlikFarki[] = []; + if (apiTur && apiTur !== satir.tur) farklar.push("tur"); + if (apiOnlisans(r) !== satir.onlisans) farklar.push("onlisans"); + if (bazAdSlug(satir.isim) !== bazAdSlug(r.birimAdi)) farklar.push("ad"); + return farklar; +} + +export type DevirKaniti = { + /** API `…1/…2/…3` alanları. */ + gecmis: GecmisAlani[]; + /** `netler.puanTuru` (yıl → DB tür sözlüğüne çevrilmiş); bağımsız ikinci kaynak. */ + netTurleri?: Map; + eskiTur: string; + yeniTur: string; + farklar: KimlikFarki[]; +}; + +/** + * Yeni programa ait olduğu KANITLANAN ilk yıl. `yil` (son yıl) her zaman + * yenidir; geriye doğru zincir ilk kanıtsız yılda kopar (bkz. dosya başı). + * + * Bir yılın kanıtı: API sıra/puanı DB ile eşleşir. DB'de o yılın sıra/puanı HİÇ + * yoksa (dolmayan program; API'nin doğrulayacağı/sahiplenmeyeceği taban yok) ve + * tür değiştiyse, o yılın `netler` kaydının YENİ türde olması da yeter — böylece + * yalnız kontenjan/yerleşen/net korunur, yanlış etiketlenebilecek sıra kalmaz. + * DB'de sıra VARKEN API o yılı boş veriyorsa net türü yetmez: aynı türde başka + * bir program olabilir (dolu programda API'nin 2024'ü boş verme oranı uyumlu + * satırlarda %0,3; kimliği kaymışlarda 22/23 — API o yılı bilerek sahiplenmiyor). + * Karşı kanıt: tür değiştiyse ve o yılın neti ESKİ türdeyse yıl kesin eskidir. + * + * AD DEĞİŞİMİ: fark yalnız addaysa ve zincir API penceresinin TAMAMINDA (3 yıl) + * sağlamsa, API eski adın geçerli olduğu yılları da bu programa sayıyor demektir + * → program aynı, adı değişmiş; geçmiş korunur (ILK_YIL döner). + */ +export function devirYiliBul( + satir: Record, + kanit: DevirKaniti, + yil: number +): number { + const turDegisti = kanit.eskiTur !== kanit.yeniTur; + const gecmis = [...kanit.gecmis].sort((a, b) => a.fark - b.fark); + let devir = yil; + for (const g of gecmis) { + const y = yil - g.fark; + if (y < ILK_YIL || y !== devir - 1) break; + const netTur = kanit.netTurleri?.get(y); + if (turDegisti && netTur === kanit.eskiTur) break; + const dbSira = satir[`sira${y}`] as number | null; + const dbPuan = satir[`puan${y}`] as number | null; + const siraAyni = g.sira != null && dbSira != null && g.sira === dbSira; + const puanAyni = + g.puan != null && dbPuan != null && Math.abs(g.puan - dbPuan) <= PUAN_TOLERANS; + const netYeni = + dbSira == null && dbPuan == null && turDegisti && netTur === kanit.yeniTur; + if (!siraAyni && !puanAyni && !netYeni) break; + devir = y; + } + const pencereSonu = Math.max(ILK_YIL, yil - gecmis.length); + const yalnizAd = kanit.farklar.length === 1 && kanit.farklar[0] === "ad"; + if (yalnizAd && gecmis.length > 0 && devir === pencereSonu) return ILK_YIL; + return devir; +} + +/** `netler.puanTuru` yıl haritası (tablo yoksa boş). Değerler API kısa kodudur (SAY, TYT…). */ +export function netTurleriGetir(db: Db, id: string, turSozlugu: Record): Map { + const sonuc = new Map(); + if (!tabloVar(db, "netler")) return sonuc; + const satirlar = db + .prepare("SELECT yil, puanTuru FROM netler WHERE program_id = ? AND puanTuru IS NOT NULL") + .all(id) as { yil: number; puanTuru: string }[]; + for (const n of satirlar) { + const tur = turSozlugu[n.puanTuru.trim()]; + if (tur) sonuc.set(n.yil, tur); + } + return sonuc; +} + +/** programs'ın yıla bağlı TÜM kolonları (sira2024, gk2025, obkY2025…) → yıl. */ +export function yilKolonlari(db: Db, tablo = "programs"): { kolon: string; yil: number }[] { + const sonuc: { kolon: string; yil: number }[] = []; + for (const kolon of kolonAdlari(db, tablo)) { + const m = /^[A-Za-z0-9]*?[A-Za-z](\d{4})$/.exec(kolon); + if (!m) continue; + const y = Number(m[1]); + if (y >= ILK_YIL && y <= 2100) sonuc.push({ kolon, yil: y }); + } + return sonuc; +} + +export type OnarimKaydi = { + devirYili: number; + yil: number; + eski: { isim: string; tur: string; onlisans: number; fakulte: string | null; sure: number | null }; + yeni: { isim: string; tur: string; onlisans: number; fakulte: string | null; sure: number | null }; + silinenYillar: number[]; + arsivId: string | null; + tasinanNetYillari: number[]; +}; + +export type OnarimGirdisi = { + id: string; + yil: number; + devirYili: number; + yeni: OnarimKaydi["yeni"]; +}; + +/** + * Tek satırın kimliğini onarır (çağıran transaction içinde olmalı): + * 1. Devirden önce verisi/neti varsa eski kimliğiyle `programs_arsiv`'e kopya: + * id `~` (kod güncel satırda yaşamaya devam ettiği için + * arşivde aynı id kullanılamaz; ileride eski-arsivle ile PK çakışırdı). + * Kopyada devir yılı ve sonrası NULL (o yıllar yeni programın). + * 2. Devirden önceki `netler` satırları arşiv id'sine taşınır (yeni programın + * sayfasında eski programın netleri görünmesin; veri silinmez). + * 3. Güncel satırda devirden önceki TÜM yıl kolonları NULL; isim/tur/onlisans/ + * fakulte/sure API'den yazılır. + * 4. `veri_meta`'ya `kimlik_onar:` kaydı — gecmis-doldur bu yılların + * API'nin kirli `…N` alanlarından yeniden dolmasını bununla engeller. + */ +export function kimlikOnar(db: Db, g: OnarimGirdisi): OnarimKaydi { + const satir = db.prepare("SELECT * FROM programs WHERE id = ?").get(g.id) as + | Record + | undefined; + if (!satir) throw new Error(`kimlikOnar: satır yok ${g.id}`); + const kolonlar = yilKolonlari(db); + const onceki = kolonlar.filter((k) => k.yil < g.devirYili); + const sonraki = kolonlar.filter((k) => k.yil >= g.devirYili); + const silinenYillar = [ + ...new Set(onceki.filter((k) => satir[k.kolon] != null).map((k) => k.yil)), + ].sort(); + const netYillari = tabloVar(db, "netler") + ? ( + db + .prepare("SELECT yil FROM netler WHERE program_id = ? AND yil < ? ORDER BY yil") + .all(g.id, g.devirYili) as { yil: number }[] + ).map((n) => n.yil) + : []; + + let arsivId: string | null = null; + const sonVeriYili = Math.max(-1, ...silinenYillar, ...netYillari); + if (sonVeriYili > 0 && tabloVar(db, "programs_arsiv")) { + arsivId = `${g.id}~${sonVeriYili}`; + const arsivKolonlari = new Set(kolonAdlari(db, "programs_arsiv")); + const tasinan = Object.keys(satir).filter((k) => arsivKolonlari.has(k)); + const degerler = tasinan.map((k) => { + if (k === "id") return arsivId; + // Eski programın bu kodla verisinin bulunduğu son yıl (olgu, tahmin değil) + if (k === "son_kilavuz_yili") return sonVeriYili; + if (sonraki.some((s) => s.kolon === k)) return null; + return satir[k]; + }); + db.prepare( + `INSERT INTO programs_arsiv (${tasinan.join(", ")}) VALUES (${tasinan.map(() => "?").join(", ")})` + ).run(...degerler); + if (netYillari.length) { + db.prepare("UPDATE netler SET program_id = ? WHERE program_id = ? AND yil < ?").run( + arsivId, + g.id, + g.devirYili + ); + } + } + + const atamalar = [ + ...onceki.map((k) => `${k.kolon} = NULL`), + "isim = ?", + "tur = ?", + "onlisans = ?", + "fakulte = ?", + "sure = ?", + ]; + db.prepare(`UPDATE programs SET ${atamalar.join(", ")} WHERE id = ?`).run( + g.yeni.isim, + g.yeni.tur, + g.yeni.onlisans, + g.yeni.fakulte, + g.yeni.sure, + g.id + ); + + const kayit: OnarimKaydi = { + devirYili: g.devirYili, + yil: g.yil, + eski: { + isim: String(satir.isim), + tur: String(satir.tur), + onlisans: Number(satir.onlisans), + fakulte: satir.fakulte as string | null, + sure: satir.sure as number | null, + }, + yeni: g.yeni, + silinenYillar, + arsivId, + tasinanNetYillari: arsivId ? netYillari : [], + }; + metaTablosunuGarantiEt(db); + db.prepare( + "INSERT INTO veri_meta (anahtar, deger) VALUES (?, ?) " + + "ON CONFLICT(anahtar) DO UPDATE SET deger = excluded.deger" + ).run(`${META_ONEK}${g.id}`, JSON.stringify(kayit)); + return kayit; +} + +/** Daha önce onarılmış satırlar: id → kayıt (gecmis-doldur ve veri-kalite okur). */ +export function onarimKayitlari(db: Db): Map { + const sonuc = new Map(); + if (!tabloVar(db, "veri_meta")) return sonuc; + const satirlar = db + .prepare("SELECT anahtar, deger FROM veri_meta WHERE anahtar LIKE ?") + .all(`${META_ONEK}%`) as { anahtar: string; deger: string }[]; + for (const s of satirlar) { + sonuc.set(s.anahtar.slice(META_ONEK.length), JSON.parse(s.deger) as OnarimKaydi); + } + return sonuc; +} diff --git a/scripts/refresh.ts b/scripts/refresh.ts index ac891ea..ff543cd 100644 --- a/scripts/refresh.ts +++ b/scripts/refresh.ts @@ -21,11 +21,23 @@ * 4. DB yazımı TEK transaction; önceki yılların kolon sağlaması transaction * içinde karşılaştırılır, fark varsa geri alınır. * + * 5. KİMLİK KİLİDİ: mevcut satırın puan türü / önlisans bayrağı / baz ad + * slug'ı API kaydıyla uyuşmuyorsa kılavuz kodu BAŞKA programa devredilmiştir + * (bkz. kimlik.ts). Liste basılır ve HİÇBİR ŞEY yazılmadan durulur; + * liste gözle incelendikten sonra `--kimlik-onar` ile yeniden koşulur. + * * Yazdıkları: sira/puan/kontenjan/yerlesen, son_kilavuz_yili, eski_kod. * Mevcut satırların ÖNCEKİ yıl kolonlarına dokunmaz (onları yalnız NULL ise * gecmis-doldur.ts doldurur). Yeni program eklerken `…1/…2/…3` alanlarından * önceki üç yılı da yazar. API "veri yok" için 0 döndürür → NULL. * + * `--kimlik-onar`: kimliği kaymış satırlarda isim/tur/onlisans/fakulte/sure + * API'den yazılır; devirden ÖNCEKİ yılların tüm kolonları NULL'lanır (o geçmiş + * başka programa ait), eski kimlik + geçmişi `programs_arsiv`'e kopyalanır. + * `--isim-esitle`: kimliği AYNI olup yalnız adı farklı satırlarda (API burs/dil + * eki veriyor: "Tıp (İngilizce) (Burslu)") isim API'den yazılır. Varsayılan + * KAPALI: isim, sihirbaz kategorilerini ve bölüm sayfası başlığını besler. + * * Kaynağa nazik: sayfa başı 500 kayıt, istekler arası 0,5 sn, tek bağlantı. * `--ham ` verilirse API'ye HİÇ gidilmez; daha önce kaydedilmiş ham * dosyadan aynı işlem tekrarlanır (kopyada deneme → gerçek DB'ye uygulama). @@ -40,9 +52,20 @@ import readline from "node:readline"; import zlib from "node:zlib"; import { ILK_YIL } from "../src/lib/veri-yillari"; import { efektifIndeksiKur } from "./db-index"; +import { + apiOnlisans, + devirYiliBul, + kimlikFarklari, + kimlikOnar, + netTurleriGetir, + type DbKimlik, + type KimlikFarki, + type OnarimKaydi, +} from "./kimlik"; import { uniAdiNormalize, uniturOnar } from "./unitur-onar"; import { argOku, + bayrakVar, calistir, dbAc, dbArgumani, @@ -371,7 +394,13 @@ async function apidenCek( return { toplam: first.totalElements, kayitlar, hamDosya }; } -function oncekiYilSaglamasi(db: Db, yil: number, azamiRowid: number): string { +/** Önceki yıl kolonlarının sağlaması; `haricIdler` (kimliği onarılanlar) dışarıda. */ +function oncekiYilSaglamasi( + db: Db, + yil: number, + azamiRowid: number, + haricIdler: string[] +): string { const kolonlar: string[] = []; for (let y = ILK_YIL; y < yil; y++) { for (const olcu of ["sira", "puan", "kontenjan", "yerlesen"]) { @@ -379,9 +408,12 @@ function oncekiYilSaglamasi(db: Db, yil: number, azamiRowid: number): string { } } const r = db - .prepare(`SELECT COUNT(*), ${kolonlar.join(", ")} FROM programs WHERE rowid <= ?`) + .prepare( + `SELECT COUNT(*), ${kolonlar.join(", ")} FROM programs + WHERE rowid <= ? AND id NOT IN (SELECT value FROM json_each(?))` + ) .raw() - .get(azamiRowid) as number[]; + .get(azamiRowid, JSON.stringify(haricIdler)) as number[]; return r.join("|"); } @@ -497,15 +529,124 @@ async function main() { return mevcut || api; }; + // ── Kimlik kilidi: kod aynı, program aynı mı? ────────────────────────── + const kimlikOnarIste = bayrakVar("kimlik-onar"); + const isimEsitleIste = bayrakVar("isim-esitle"); + const kimlikGetir = db.prepare("SELECT * FROM programs WHERE id = ?"); + type Uyumsuz = { + id: string; + r: KilavuzKaydi; + satir: DbKimlik & Record; + farklar: KimlikFarki[]; + apiTur: string | undefined; + devirYili: number; + }; + const uyumsuzlar: Uyumsuz[] = []; + const ekFarkliIdler: string[] = []; + for (const [id, r] of benzersiz) { + const satir = kimlikGetir.get(id) as Uyumsuz["satir"] | undefined; + if (!satir) continue; + const apiTur = TUR_MAP[r.puanTuru?.trim() ?? ""]; + const farklar = kimlikFarklari(satir, r, apiTur); + if (farklar.length) { + uyumsuzlar.push({ + id, + r, + satir, + farklar, + apiTur, + devirYili: devirYiliBul( + satir, + { + gecmis: gecmisAlanlari(r), + netTurleri: netTurleriGetir(db, id, TUR_MAP), + eskiTur: satir.tur, + yeniTur: apiTur ?? satir.tur, + farklar, + }, + yil + ), + }); + } else if (satir.isim !== r.birimAdi) { + ekFarkliIdler.push(id); + } + } + if (uyumsuzlar.length) { + console.log(`\nKİMLİĞİ KAYMIŞ ${uyumsuzlar.length} satır (kod aynı, program farklı):`); + for (const u of uyumsuzlar) { + const gecmis = []; + for (let y = ILK_YIL; y <= yil; y++) gecmis.push(u.satir[`sira${y}`] ?? "-"); + const netOzet = [...netTurleriGetir(db, u.id, TUR_MAP)] + .sort((a, b) => a[0] - b[0]) + .map(([y, t]) => `${y}=${t}`) + .join(" "); + console.log( + ` ${u.id} [${u.farklar.join("+")}] ${u.satir.universite}\n` + + ` DB : ${u.satir.isim} | ${u.satir.tur} | onlisans=${u.satir.onlisans} | ${u.satir.fakulte}\n` + + ` API: ${u.r.birimAdi} | ${u.apiTur ?? `?(${u.r.puanTuru})`} | onlisans=${apiOnlisans(u.r)} | ${u.r.fymkAdi}\n` + + ` sira ${ILK_YIL}…${yil}: ${gecmis.join(", ")} | net türleri: ${netOzet || "-"} → ` + + (u.devirYili === ILK_YIL + ? "AD DEĞİŞİMİ (API penceresinin tamamı aynı program; geçmiş korunur)" + : `devir yılı ${u.devirYili} (öncesi silinir)`) + ); + } + const tursuz = uyumsuzlar.filter((u) => !u.apiTur); + if (!kimlikOnarIste) { + dur( + `${uyumsuzlar.length} satırın kimliği API ile uyuşmuyor; DB'ye HİÇBİR ŞEY yazılmadı. ` + + "Listeyi incele; devirse aynı komutu --kimlik-onar ile yeniden koş " + + "(isim/tur/onlisans/fakulte/sure API'den yazılır, devir öncesi yıllar NULL'lanır, eski kimlik arşive kopyalanır)." + ); + } + if (tursuz.length) { + dur( + `${tursuz.length} uyumsuz satırda API puan türü vermiyor (${tursuz.map((u) => u.id).join(", ")}); ` + + "tür UYDURULMAZ, onarım yapılamaz." + ); + } + } + console.log( + `Kimlik: ${uyumsuzlar.length} uyumsuz; yalnız adı (burs/dil eki) farklı ${ekFarkliIdler.length} satır` + + (isimEsitleIste ? " → isim API'den yazılacak (--isim-esitle)" : " → isim yazılmıyor (--isim-esitle verilmedi)") + ); + let updated = 0; let inserted = 0; const atlananlar: string[] = []; + const onarimlar: { id: string; universite: string; kayit: OnarimKaydi }[] = []; + let isimEsitlenen = 0; db.transaction(() => { const azamiRowid = ( db.prepare("SELECT COALESCE(MAX(rowid), 0) AS m FROM programs").get() as { m: number } ).m; - const once = oncekiYilSaglamasi(db, yil, azamiRowid); + const onarilanIdler = uyumsuzlar.map((u) => u.id); + const once = oncekiYilSaglamasi(db, yil, azamiRowid, onarilanIdler); + + // Kimlik onarımı yıl yazımından ÖNCE: arşiv kopyası eski hâliyle alınır. + for (const u of uyumsuzlar) { + const universite = String(u.satir.universite); + const kayit = kimlikOnar(db, { + id: u.id, + yil, + devirYili: u.devirYili, + yeni: { + isim: u.r.birimAdi, + tur: u.apiTur as string, + onlisans: apiOnlisans(u.r), + fakulte: fakulteSec(universite, u.r.fymkAdi ?? null), + sure: u.r.ogrenimSuresi ?? null, + }, + }); + onarimlar.push({ id: u.id, universite, kayit }); + } + if (isimEsitleIste) { + const isimYaz = db.prepare("UPDATE programs SET isim = ? WHERE id = ?"); + for (const id of ekFarkliIdler) { + isimYaz.run((benzersiz.get(id) as KilavuzKaydi).birimAdi, id); + isimEsitlenen++; + } + } for (const [id, r] of benzersiz) { const tur = TUR_MAP[r.puanTuru?.trim() ?? ""]; @@ -549,10 +690,10 @@ async function main() { } } - const sonra = oncekiYilSaglamasi(db, yil, azamiRowid); + const sonra = oncekiYilSaglamasi(db, yil, azamiRowid, onarilanIdler); if (once !== sonra) { dur( - "Mevcut satırların önceki yıl kolonları değişti — bu betik onlara dokunmamalı. Geri alındı.\n" + + "Kimliği onarılanlar DIŞINDAKİ satırların önceki yıl kolonları değişti — bu betik onlara dokunmamalı. Geri alındı.\n" + `önce : ${once}\nsonra: ${sonra}` ); } @@ -577,6 +718,20 @@ async function main() { `Güncellenen: ${updated}, eklenen: ${inserted}, atlanan: ${kodsuz + atlananlar.length}` ); for (const a of atlananlar) console.log(` atlandı: ${a}`); + if (isimEsitlenen) console.log(`İsmi API'den eşitlenen (yalnız ek farkı): ${isimEsitlenen}`); + if (onarimlar.length) { + console.log(`\nKİMLİĞİ ONARILAN ${onarimlar.length} satır:`); + console.log(" id | üniversite | eski ad [tür/önlisans] → yeni ad [tür/önlisans] | devir | NULL'lanan yıllar | arşiv | taşınan net yılları"); + for (const o of onarimlar) { + const k = o.kayit; + console.log( + ` ${o.id} | ${o.universite} | ${k.eski.isim} [${k.eski.tur}/${k.eski.onlisans}] → ` + + `${k.yeni.isim} [${k.yeni.tur}/${k.yeni.onlisans}] | ${k.devirYili} | ` + + `${k.silinenYillar.join(",") || "-"} | ${k.arsivId ?? "-"} | ${k.tasinanNetYillari.join(",") || "-"}` + ); + } + metaYaz(db, `kimlik_onar_${yil}_satir`, onarimlar.length); + } // API'nin universiteTuru döndürmediği kayıtlar devlet/vakıf filtrelerinden // düşmesin: her tazelemeden sonra unitur onarımı koşulur (idempotent). diff --git a/scripts/veri-kalite.ts b/scripts/veri-kalite.ts index 6309f91..862e848 100644 --- a/scripts/veri-kalite.ts +++ b/scripts/veri-kalite.ts @@ -4,8 +4,13 @@ * 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] + * pnpm veri-kalite --yil 2026 --db /tmp/deneme.db [--onceki data/yokatlas.db] [--arsivli] [--ham ] * + * --ham 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 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). @@ -14,6 +19,8 @@ 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, @@ -164,6 +171,52 @@ async function main() { ); 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 | 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"); @@ -202,11 +255,29 @@ async function main() { 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 ${ortak.map((k) => `(e.${k} IS NOT NULL AND e.${k} IS NOT s.${k})`).join(" OR ")}` + 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}`); + 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 | undefined; + const s = yeniGetir.get(id) as Record | 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( diff --git a/src/features/liste/liste-store.ts b/src/features/liste/liste-store.ts index cd74bdb..26167dc 100644 --- a/src/features/liste/liste-store.ts +++ b/src/features/liste/liste-store.ts @@ -152,9 +152,11 @@ export function sunucuylaBirlestir(sunucuItems: ManuelTercih[]): ManuelTercih[] liste = [...liste, ...ekler].slice(0, MANUEL_LISTE_MAX); kaydet(); yayinla(); - // Başka cihazda profilsiz eklenmiş kayıtlar risksiz gelir; profil lokalde - // zaten varsa PROFIL_DEGISTI_EVENT yayılmaz, riski burada doldur. - if (liste.some((t) => t.risk == null)) riskleriYenile(); + // Sunucudan gelen kayıtların riski başka cihazın o günkü tabanına göredir + // (ya da profilsiz eklendiyse hiç yoktur); sunucu tabanı tazeler ama riski + // yenilemez. Profil lokalde varsa hepsini güncel tabanla yeniden hesapla + // (profil yoksa riskleriYenile zaten işlem yapmaz). + riskleriYenile(); return liste; } diff --git a/src/lib/db.ts b/src/lib/db.ts index 97744cc..0d3b1be 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -210,6 +210,14 @@ export function netlerGetir(programId: string): ProgramNetSatiri[] { .all(programId) as ProgramNetSatiri[]; } +/** Güncel kılavuzdaki tek program; arşivlenmiş ya da bilinmeyen id'de null. */ +export function programGetir(id: string): Program | null { + const row = getDb() + .prepare(`SELECT ${SELECT_COLS} FROM programs WHERE id = ?`) + .get(id) as ProgramHamSatiri | undefined; + return row ? programSatiri(row) : null; +} + /** * Kayıtlı manuel liste satırlarının güncel efektif sırası (son yıl, yoksa bir * önceki). Kayıtlar efektif sırayı ekleme anının snapshot'ı olarak taşır; yeni diff --git a/src/lib/tadimlik-havuzu.ts b/src/lib/tadimlik-havuzu.ts index 90071fb..70412cf 100644 --- a/src/lib/tadimlik-havuzu.ts +++ b/src/lib/tadimlik-havuzu.ts @@ -10,7 +10,8 @@ import type { Dilim } from "./rapor-havuzu"; import type { RaporSonuc } from "@/features/rapor/types/rapor"; import { dilimdenRisk, riskHesapla, riskNotuMetni } from "./risk"; import { trendOzetiMetni } from "./trend"; -import { VERI_YIL_SAYISI } from "./veri-yillari"; +import { ILK_YIL, VERI_YIL_SAYISI } from "./veri-yillari"; +import { programGetir } from "./db"; /** Sihirbaz kategorisi seçilmemiş kullanıcı için kova×tür başına fallback satır. */ export const GENEL_KATEGORI = "genel"; @@ -123,7 +124,18 @@ export async function tadimlikSec( ), }); if (satir && gerekceServisEdilebilir(satir.gerekce)) { - const program = satir.program as RaporSonuc["programlar"][string]; + // Havuz batch'te üretilir ve yeni yerleştirme yılı yüklenince bayatlar: + // snapshot'taki taban/geçmiş yerine CANLI veri servis edilir ki örnek + // satır aynı sayfadaki tabloyla çelişmesin. Güncel kılavuzdan çıkmış + // (arşivlenmiş) program örnek gösterilmez — sıradaki kategoriye düşülür. + const canli = programGetir(satir.programId); + if (!canli) continue; + const program: RaporSonuc["programlar"][string] = { + ...(satir.program as RaporSonuc["programlar"][string]), + efektifSira: canli.efektifSira, + siraGecmisi: canli.siraGecmisi, + ilkYil: ILK_YIL, + }; return { programId: satir.programId, dilim: satir.dilim,