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:
281
scripts/kimlik.ts
Normal file
281
scripts/kimlik.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user