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>
This commit is contained in:
bilalgursen
2026-09-21 20:24:29 +03:00
parent 49683fa113
commit 2ff8fe584b
5 changed files with 560 additions and 13 deletions

281
scripts/kimlik.ts Normal file
View File

@@ -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<number, string>;
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<string, unknown>,
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<string, string>): Map<number, string> {
const sonuc = new Map<number, string>();
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>~<son veri yılı>` (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:<id>` 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<string, string | number | null>
| 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<string, OnarimKaydi> {
const sonuc = new Map<string, OnarimKaydi>();
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;
}