/** * 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; }