Files
kolaytercih/scripts/kimlik.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

282 lines
11 KiB
TypeScript
Raw Permalink 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.
/**
* 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;
}