programs.isim burs/dil varyantını taşımıyor (Başkent Tıp 6 satırda da "Tıp"); ayırt edici alanlar program_ozellik.burs_orani / ogrenim_dili / ogrenim_turu. SELECT_COLS üç ilişkili alt sorgu kazanır, programSatiri varyantEtiketi ile "Burslu · İngilizce" gibi tek etiket üretir; devlet / Türkçe / örgün programda null. Havuz JSON'una da girer: model aynı adlı satırları ayırt edebilsin. Tablo yokluğu koruması bilinçli yok: data/yokatlas.db depoda ve Docker seed'inde (Dockerfile) program_ozellik tablosuyla geliyor. programs_arsiv sorguları dokunulmadı (arşivde özellik yok). Test: pnpm tsx scripts/eval/varyant-denetim.ts (salt okur, çıkış 0). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
725 lines
26 KiB
TypeScript
725 lines
26 KiB
TypeScript
// server-only guard'ı bilinçli yok: scripts/tadimlik-uret.ts bu modülü düz
|
||
// node (tsx) altında import ediyor; server-only paketi orada fırlar.
|
||
import path from "node:path";
|
||
import Database from "better-sqlite3";
|
||
import { KATEGORILER, kategoriBul } from "./kategoriler";
|
||
import { EFEKTIF_SIRA_SQL, ILK_YIL, SON_YIL, YILLAR } from "./veri-yillari";
|
||
import { turkishSlugify } from "./slug";
|
||
import { varyantEtiketi } from "./program-varyant";
|
||
import {
|
||
PUAN_TURLERI,
|
||
type ArsivProgram,
|
||
type DilimKey,
|
||
type KontenjanDilimi,
|
||
type KontenjanTurleri,
|
||
type KontenjanTuruAnahtari,
|
||
type Program,
|
||
type ProgramGecmisSatiri,
|
||
type ProgramNetSatiri,
|
||
type PuanTuruKey,
|
||
type RankResults,
|
||
type SihirbazFacetleri,
|
||
type UniturGrubu,
|
||
} from "@/types/yokatlas";
|
||
|
||
// Dev'de hot-reload başına yeni bağlantı açılmasın diye global cache
|
||
const globalForDb = globalThis as unknown as { yokatlasDb?: Database.Database };
|
||
|
||
export function getDb(): Database.Database {
|
||
if (!globalForDb.yokatlasDb) {
|
||
globalForDb.yokatlasDb = new Database(
|
||
path.join(process.cwd(), "data", "yokatlas.db"),
|
||
{ readonly: true, fileMustExist: true }
|
||
);
|
||
}
|
||
return globalForDb.yokatlasDb;
|
||
}
|
||
|
||
// Yıl ekli kolonlar yalnız burada, `YILLAR`'dan üretilerek adlandırılır; DB
|
||
// dışına yılsız `Program` çıkar. SELECT_COLS ile okunan her satır
|
||
// `programSatiri` eşleyicisinden geçmelidir (ham satır `Program` değildir).
|
||
// Burs/dil/öğretim türü `program_ozellik`'ten ilişkili alt sorguyla gelir
|
||
// (id PRIMARY KEY). Bütün SELECT_COLS kullanımları takma adsız `FROM programs`
|
||
// olduğu için `programs.id` çözümlenir; `programs_arsiv` (ARSIV_SELECT) bu
|
||
// kolonları almaz (arşivde özellik yok). Tablo yokluğu koruması gerekmez:
|
||
// data/yokatlas.db depoda ve Docker seed'inde tabloyla gelir.
|
||
export const SELECT_COLS = `id, isim, universite, unitur, il, fakulte, tur, sure,
|
||
${YILLAR.map((yil) => `sira${yil} AS s${yil}`).join(", ")},
|
||
puan${SON_YIL} AS puanSon, kontenjan${SON_YIL} AS kontenjanSon, yerlesen${SON_YIL} AS yerlesenSon,
|
||
(SELECT burs_orani FROM program_ozellik o WHERE o.id = programs.id) AS bursOrani,
|
||
(SELECT ogrenim_dili FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimDili,
|
||
(SELECT ogrenim_turu FROM program_ozellik o WHERE o.id = programs.id) AS ogrenimTuru`;
|
||
|
||
// Son yılın sıralaması yoksa (az yerleşen/yeni program) bir önceki yıla düşer.
|
||
// İfade indeks sözleşmesidir; tek kaynağı lib/veri-yillari.ts.
|
||
export const EFEKTIF_SIRA = EFEKTIF_SIRA_SQL;
|
||
|
||
/** SELECT_COLS ile okunan ham satır: sıra kolonları `s<yıl>` takma adıyla,
|
||
* varyant ise üç ham özellik alanı olarak gelir. */
|
||
export type ProgramHamSatiri = Omit<
|
||
Program,
|
||
"siraGecmisi" | "efektifSira" | "siraSon" | "varyant"
|
||
> &
|
||
Record<`s${number}`, number | null> & {
|
||
bursOrani: string | null;
|
||
ogrenimDili: string | null;
|
||
ogrenimTuru: string | null;
|
||
};
|
||
|
||
/** Ham DB satırını yılsız `Program`'a çevirir. */
|
||
export function programSatiri(row: ProgramHamSatiri): Program {
|
||
const siraGecmisi = YILLAR.map((yil) => row[`s${yil}`] ?? null);
|
||
const siraSon = siraGecmisi[siraGecmisi.length - 1] ?? null;
|
||
return {
|
||
id: row.id,
|
||
isim: row.isim,
|
||
universite: row.universite,
|
||
unitur: row.unitur,
|
||
il: row.il,
|
||
fakulte: row.fakulte,
|
||
tur: row.tur,
|
||
sure: row.sure,
|
||
siraGecmisi,
|
||
// EFEKTIF_SIRA ile aynı kural: son yıl, yoksa bir önceki yıl
|
||
efektifSira: siraSon ?? siraGecmisi[siraGecmisi.length - 2] ?? null,
|
||
siraSon,
|
||
puanSon: row.puanSon,
|
||
kontenjanSon: row.kontenjanSon,
|
||
yerlesenSon: row.yerlesenSon,
|
||
varyant: varyantEtiketi(row),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Kullanıcının başarı sıralamasına göre programları üç dilime ayırır.
|
||
* Sıralamada küçük sayı daha iyidir; program taban sıralaması kullanıcının
|
||
* sıralamasından küçükse geçen yıl oraya yerleşmek daha iyi dereceyle mümkündü
|
||
* demektir (hayal), büyükse daha güvenli demektir (garanti).
|
||
*/
|
||
/**
|
||
* Danışman profilinden gelen program özelliği süzgeci (program_ozellik
|
||
* tablosu; bkz. lib/program-ozellik.ts). Tablo yoksa süzgeç uygulanmaz.
|
||
* Devlet programlarında ücret/burs NULL'dur ve her zaman geçer.
|
||
*/
|
||
export type OzellikSuzgeci = {
|
||
dil?: "Türkçe" | "İngilizce";
|
||
yalnizOrgun?: boolean;
|
||
yalnizBurslu?: boolean;
|
||
ucretUst?: number;
|
||
};
|
||
|
||
let ozellikTablosuVar: boolean | null = null;
|
||
|
||
function ozellikTablosuVarMi(): boolean {
|
||
if (ozellikTablosuVar == null) {
|
||
ozellikTablosuVar =
|
||
getDb()
|
||
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='program_ozellik'")
|
||
.get() !== undefined;
|
||
}
|
||
return ozellikTablosuVar;
|
||
}
|
||
|
||
function ozellikFiltreSql(o?: OzellikSuzgeci): { sql: string; args: (string | number)[] } {
|
||
if (!o) return { sql: "", args: [] };
|
||
const kosul: string[] = [];
|
||
const args: (string | number)[] = [];
|
||
if (o.dil) {
|
||
kosul.push("ogrenim_dili LIKE ?");
|
||
args.push(`${o.dil}%`);
|
||
}
|
||
if (o.yalnizOrgun) kosul.push("(ogrenim_turu IS NULL OR ogrenim_turu = 'Örgün Öğretim')");
|
||
if (o.yalnizBurslu) kosul.push("(burs_orani IS NULL OR burs_orani = 'Burslu')");
|
||
if (o.ucretUst) {
|
||
kosul.push("(ucret IS NULL OR burs_orani = 'Burslu' OR ucret <= ?)");
|
||
args.push(o.ucretUst);
|
||
}
|
||
if (kosul.length === 0) return { sql: "", args: [] };
|
||
if (!ozellikTablosuVarMi()) return { sql: "", args: [] };
|
||
return {
|
||
sql: `AND id IN (SELECT id FROM program_ozellik WHERE ${kosul.join(" AND ")})`,
|
||
args,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Başarı sırası barajı: `program_ozellik.min_basari_sirasi` programı tercih
|
||
* edebilmek için gereken en düşük sıradır (YÖK Atlas'tan, kılavuz yılına ait;
|
||
* ör. tıp 50.000). Adayın sırası bundan kötüyse o programı yazamaz. Barajı
|
||
* boş olan program (barajsız ya da veride eksik) süzülmez.
|
||
*/
|
||
function barajFiltreSql(sira: number): { sql: string; args: number[] } {
|
||
if (!ozellikTablosuVarMi()) return { sql: "", args: [] };
|
||
return {
|
||
sql: "AND id NOT IN (SELECT id FROM program_ozellik WHERE min_basari_sirasi < ?)",
|
||
args: [sira],
|
||
};
|
||
}
|
||
|
||
function uniturFiltreSql(grup?: UniturGrubu): { sql: string; args: string[] } {
|
||
if (grup === "devlet") return { sql: "AND unitur = ?", args: ["DEVLET"] };
|
||
if (grup === "vakif")
|
||
return { sql: "AND unitur IS NOT NULL AND unitur <> ?", args: ["DEVLET"] };
|
||
return { sql: "", args: [] };
|
||
}
|
||
|
||
/**
|
||
* Dilim sınırları. Pencereler çarpan bazlı DEĞİL, açık uçlu: hayal adayın
|
||
* üstündeki her şeyi, garanti 1.4× eşiğinin altındaki her şeyi kapsar.
|
||
* "Yakından uzağa" sıralama + limit/offset sayesinde uçtaki gerçekçi olmayan
|
||
* programlar ancak sayfalandıkça görünür; eski ×0.5/×3 pencereleri küçük
|
||
* sıralamalarda (ör. 87) hiç sonuç bırakmıyordu.
|
||
*/
|
||
export function dilimAraligi(
|
||
sira: number,
|
||
dilim: DilimKey
|
||
): { alt: number; ust: number | null } {
|
||
const dengeliUst = Math.round(sira * 1.4);
|
||
if (dilim === "hayal") return { alt: 1, ust: sira - 1 };
|
||
if (dilim === "dengeli") return { alt: sira, ust: dengeliUst };
|
||
return { alt: dengeliUst + 1, ust: null };
|
||
}
|
||
|
||
/**
|
||
* Tek dilimi "yakından uzağa" sıralı ve sayfalı getirir: hayal adayın
|
||
* sıralamasından yukarı (taban DESC), dengeli/garanti aşağı (taban ASC).
|
||
* toplam, limit'ten bağımsız olarak dilimdeki tüm eşleşme sayısıdır.
|
||
*/
|
||
export function dilimAra(
|
||
sira: number,
|
||
turKey: PuanTuruKey,
|
||
dilim: DilimKey,
|
||
opts: {
|
||
il?: string;
|
||
iller?: string[];
|
||
uniturGrubu?: UniturGrubu;
|
||
ozellik?: OzellikSuzgeci;
|
||
/** Adayın sırasıyla yazamayacağı (baraj dışı) programları çıkar */
|
||
barajDisiHaric?: boolean;
|
||
limit?: number;
|
||
offset?: number;
|
||
} = {}
|
||
): { programlar: Program[]; toplam: number } {
|
||
const db = getDb();
|
||
const tur = PUAN_TURLERI[turKey];
|
||
const onlisans = turKey === "tyt" ? 1 : 0;
|
||
const limit = opts.limit ?? 12;
|
||
const offset = opts.offset ?? 0;
|
||
|
||
// il (tekil, geriye uyumlu) veya iller (çoklu) — ikisi de verilirse birleşir
|
||
const illerListe = [
|
||
...(opts.il ? [opts.il] : []),
|
||
...(opts.iller ?? []),
|
||
].filter((v, i, a) => v && a.indexOf(v) === i);
|
||
const ilFiltre =
|
||
illerListe.length > 0
|
||
? `AND il IN (${illerListe.map(() => "?").join(",")})`
|
||
: "";
|
||
const unitur = uniturFiltreSql(opts.uniturGrubu);
|
||
const ozellik = ozellikFiltreSql(opts.ozellik);
|
||
const baraj = opts.barajDisiHaric
|
||
? barajFiltreSql(sira)
|
||
: { sql: "", args: [] };
|
||
|
||
const { alt, ust } = dilimAraligi(sira, dilim);
|
||
if (ust != null && ust < alt) return { programlar: [], toplam: 0 };
|
||
const siraKosulu =
|
||
ust != null ? `${EFEKTIF_SIRA} BETWEEN ? AND ?` : `${EFEKTIF_SIRA} >= ?`;
|
||
const siraArgs = ust != null ? [alt, ust] : [alt];
|
||
const args = [
|
||
tur,
|
||
onlisans,
|
||
...illerListe,
|
||
...unitur.args,
|
||
...ozellik.args,
|
||
...baraj.args,
|
||
...siraArgs,
|
||
];
|
||
|
||
const base = `FROM programs
|
||
WHERE tur = ? AND onlisans = ? ${ilFiltre} ${unitur.sql} ${ozellik.sql} ${baraj.sql}
|
||
AND ${EFEKTIF_SIRA} IS NOT NULL
|
||
AND ${siraKosulu}`;
|
||
// hayal: sınıra en yakın (en büyük taban) önce; diğerleri en yakından uzağa
|
||
const yon = dilim === "hayal" ? "DESC" : "ASC";
|
||
|
||
const satirlar = db
|
||
.prepare(
|
||
`SELECT ${SELECT_COLS} ${base} ORDER BY ${EFEKTIF_SIRA} ${yon} LIMIT ? OFFSET ?`
|
||
)
|
||
.all(...args, limit, offset) as ProgramHamSatiri[];
|
||
const toplam = (
|
||
db.prepare(`SELECT COUNT(*) AS adet ${base}`).get(...args) as {
|
||
adet: number;
|
||
}
|
||
).adet;
|
||
|
||
return { programlar: satirlar.map(programSatiri), toplam };
|
||
}
|
||
|
||
export function searchByRank(
|
||
sira: number,
|
||
turKey: PuanTuruKey,
|
||
opts: {
|
||
il?: string;
|
||
iller?: string[];
|
||
uniturGrubu?: UniturGrubu;
|
||
ozellik?: OzellikSuzgeci;
|
||
limitPerBucket?: number;
|
||
} = {}
|
||
): RankResults {
|
||
const ortak = {
|
||
il: opts.il,
|
||
iller: opts.iller,
|
||
uniturGrubu: opts.uniturGrubu,
|
||
ozellik: opts.ozellik,
|
||
limit: opts.limitPerBucket ?? 12,
|
||
};
|
||
const hayal = dilimAra(sira, turKey, "hayal", ortak);
|
||
const dengeli = dilimAra(sira, turKey, "dengeli", ortak);
|
||
const garanti = dilimAra(sira, turKey, "garanti", ortak);
|
||
|
||
return {
|
||
hayal: hayal.programlar,
|
||
dengeli: dengeli.programlar,
|
||
garanti: garanti.programlar,
|
||
toplam: {
|
||
hayal: hayal.toplam,
|
||
dengeli: dengeli.toplam,
|
||
garanti: garanti.toplam,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Aday havuzunun ham girdisi: üç dilimin TAMAMI, yakından uzağa sıralı
|
||
* (limit yok). Alan (kategori) DB'de kolon değil, JS'te isim+fakülteden
|
||
* türüyor; süzgeç limitten SONRA uygulanınca sıraya en yakın satırlar her
|
||
* alandan karışık geldiği için seçilen alan olduğundan az görünüyordu. Bu
|
||
* yüzden havuz dilimin tamamını alır, alan/il süzgecini kendisi uygular ve
|
||
* en son kırpar (bkz. lib/rapor-havuzu.ts). En büyük puan türü ~9 bin satır.
|
||
* Adayın başarı sırası barajını geçemediği programlar buraya hiç girmez.
|
||
*/
|
||
export function havuzAdaylari(
|
||
sira: number,
|
||
turKey: PuanTuruKey,
|
||
opts: { uniturGrubu?: UniturGrubu; ozellik?: OzellikSuzgeci } = {}
|
||
): Record<DilimKey, Program[]> {
|
||
// LIMIT -1: SQLite'ta "sınırsız"
|
||
const ortak = { ...opts, barajDisiHaric: true, limit: -1 };
|
||
return {
|
||
hayal: dilimAra(sira, turKey, "hayal", ortak).programlar,
|
||
dengeli: dilimAra(sira, turKey, "dengeli", ortak).programlar,
|
||
garanti: dilimAra(sira, turKey, "garanti", ortak).programlar,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Programa yerleşen son kişinin yıl bazlı netleri, yeniden eskiye sıralı.
|
||
* Veri scripts/detay.ts ile YÖK Atlas /api/netler/search'ten doldurulur.
|
||
*/
|
||
export function netlerGetir(programId: string): ProgramNetSatiri[] {
|
||
return getDb()
|
||
.prepare(
|
||
`SELECT yil, puanTuru, tabanPuan, obp, katsayi,
|
||
tytTrkNet, tytSosNet, tytMatNet, tytFenNet,
|
||
aytMatNet, aytFizNet, aytKimNet, aytBioNet,
|
||
aytTdeNet, aytTrh1Net, aytCog1Net, aytTrh2Net, aytCog2Net,
|
||
aytDinNet, aytFelNet, ydtYdilNet
|
||
FROM netler WHERE program_id = ? ORDER BY yil DESC`
|
||
)
|
||
.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;
|
||
}
|
||
|
||
// ── Yıl bazlı geçmiş, kontenjan türleri, arşiv ───────────────────────────
|
||
// Bu alanlar `Program` tipine girmez: liste satırları client'a serileşiyor,
|
||
// her satırı 4×6 kolonla şişirmek istemiyoruz. Program ayrıntı çekmecesi
|
||
// (/api/program-ayrinti), danışman araçları ve uyum puanı buradan okur.
|
||
|
||
let programKolonlari: Set<string> | null = null;
|
||
|
||
/** `programs` tablosunun kolon adları (bir kez okunur). */
|
||
function programKolonlariGetir(): Set<string> {
|
||
if (!programKolonlari) {
|
||
programKolonlari = new Set(
|
||
(getDb().prepare("PRAGMA table_info(programs)").all() as { name: string }[]).map(
|
||
(c) => c.name
|
||
)
|
||
);
|
||
}
|
||
return programKolonlari;
|
||
}
|
||
|
||
/**
|
||
* Programın yıl bazlı geçmişi, YENİDEN ESKİYE. Veri penceresi `YILLAR`;
|
||
* kolonu olmayan yıl atlanır. Bilinmeyen id'de boş dizi.
|
||
*/
|
||
export function programGecmisiGetir(id: string): ProgramGecmisSatiri[] {
|
||
const kolonlar = programKolonlariGetir();
|
||
const yillar = YILLAR.filter((y) => kolonlar.has(`sira${y}`));
|
||
if (yillar.length === 0) return [];
|
||
const secim = yillar
|
||
.map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`)
|
||
.join(", ");
|
||
const row = getDb()
|
||
.prepare(`SELECT ${secim} FROM programs WHERE id = ?`)
|
||
.get(id) as Record<string, number | null> | undefined;
|
||
if (!row) return [];
|
||
return [...yillar].reverse().map((yil) => ({
|
||
yil,
|
||
sira: row[`s${yil}`] ?? null,
|
||
puan: row[`p${yil}`] ?? null,
|
||
kontenjan: row[`k${yil}`] ?? null,
|
||
yerlesen: row[`y${yil}`] ?? null,
|
||
}));
|
||
}
|
||
|
||
/** Kontenjan türü kolonu öneki → KontenjanTurleri alanı. */
|
||
const KONTENJAN_TURU_KOLON = {
|
||
gk: "genel",
|
||
obk: "okulBirincisi",
|
||
sgy: "sehitGaziYakini",
|
||
dprm: "depremzede",
|
||
y34k: "kadin34",
|
||
} as const;
|
||
|
||
/** KontenjanTurleri alanı → kolon öneki (SQL süzgeçleri `${onek}${kontenjanTuruYili()}`). */
|
||
export const KONTENJAN_TURU_ONEK: Record<KontenjanTuruAnahtari, string> = {
|
||
genel: "gk",
|
||
okulBirincisi: "obk",
|
||
sehitGaziYakini: "sgy",
|
||
depremzede: "dprm",
|
||
kadin34: "y34k",
|
||
};
|
||
|
||
let kontenjanTuruYiliOnbellek: number | null | undefined;
|
||
|
||
/**
|
||
* Kontenjan türü kırılımının bulunduğu en yeni yıl (`gk<yil>` kolonu olan).
|
||
* Kırılım bir sonraki kılavuzla açıklandığı için SON_YIL'dan bir geride
|
||
* olabilir; hiç yoksa null (eski DB).
|
||
*/
|
||
export function kontenjanTuruYili(): number | null {
|
||
if (kontenjanTuruYiliOnbellek !== undefined) return kontenjanTuruYiliOnbellek;
|
||
const kolonlar = programKolonlariGetir();
|
||
let bulunan: number | null = null;
|
||
for (let y = SON_YIL; y >= ILK_YIL; y--) {
|
||
if (kolonlar.has(`gk${y}`) && kolonlar.has(`y34k${y}`)) {
|
||
bulunan = y;
|
||
break;
|
||
}
|
||
}
|
||
kontenjanTuruYiliOnbellek = bulunan;
|
||
return bulunan;
|
||
}
|
||
|
||
function kontenjanTurleriSatiri(
|
||
yil: number,
|
||
row: Record<string, number | null>
|
||
): KontenjanTurleri {
|
||
const dilim = (onek: string): KontenjanDilimi => ({
|
||
kontenjan: row[`${onek}${yil}`] ?? null,
|
||
yerlesen: row[`${onek}Y${yil}`] ?? null,
|
||
});
|
||
return {
|
||
yil,
|
||
genel: dilim("gk"),
|
||
okulBirincisi: dilim("obk"),
|
||
sehitGaziYakini: dilim("sgy"),
|
||
depremzede: dilim("dprm"),
|
||
kadin34: dilim("y34k"),
|
||
};
|
||
}
|
||
|
||
function kontenjanTuruSecimi(yil: number): string {
|
||
return Object.keys(KONTENJAN_TURU_KOLON)
|
||
.map((onek) => `${onek}${yil}, ${onek}Y${yil}`)
|
||
.join(", ");
|
||
}
|
||
|
||
/** Tek programın kontenjan türleri; kırılım kolonu ya da program yoksa null. */
|
||
export function kontenjanTurleriGetir(id: string): KontenjanTurleri | null {
|
||
const yil = kontenjanTuruYili();
|
||
if (yil == null) return null;
|
||
const row = getDb()
|
||
.prepare(`SELECT ${kontenjanTuruSecimi(yil)} FROM programs WHERE id = ?`)
|
||
.get(id) as Record<string, number | null> | undefined;
|
||
return row ? kontenjanTurleriSatiri(yil, row) : null;
|
||
}
|
||
|
||
const IN_PARCA = 500;
|
||
|
||
/** Çok programın kontenjan türleri (uyum puanı, arama sonuçları); id → satır. */
|
||
export function kontenjanTurleriGetirToplu(
|
||
ids: readonly string[]
|
||
): Record<string, KontenjanTurleri> {
|
||
const sonuc: Record<string, KontenjanTurleri> = {};
|
||
const yil = kontenjanTuruYili();
|
||
if (yil == null || ids.length === 0) return sonuc;
|
||
const db = getDb();
|
||
for (let i = 0; i < ids.length; i += IN_PARCA) {
|
||
const parca = ids.slice(i, i + IN_PARCA);
|
||
const satirlar = db
|
||
.prepare(
|
||
`SELECT id, ${kontenjanTuruSecimi(yil)} FROM programs WHERE id IN (${parca.map(() => "?").join(",")})`
|
||
)
|
||
.all(...parca) as (Record<string, number | null> & { id: string })[];
|
||
for (const r of satirlar) sonuc[r.id] = kontenjanTurleriSatiri(yil, r);
|
||
}
|
||
return sonuc;
|
||
}
|
||
|
||
// Katlanmış (Türkçe harf duyarsız) ad eşleştirme: SQLite LIKE "isletme"yi
|
||
// İşletme'yle, "bogazici"yi Boğaziçi'yle eşlemez. Kolonun ayrık değerleri
|
||
// (birkaç bin) tablo başına bir kez okunur, eşleşme JS'te yapılır. Danışman
|
||
// araçları (program_ara) ve arşiv araması aynı yardımcıyı kullanır.
|
||
type AyrikDeger = { ham: string; katli: string };
|
||
const ayrikDegerler = new Map<string, AyrikDeger[]>();
|
||
|
||
export function ayrikListe(
|
||
kolon: "isim" | "universite",
|
||
tablo: "programs" | "programs_arsiv" = "programs"
|
||
): AyrikDeger[] {
|
||
const anahtar = `${tablo}.${kolon}`;
|
||
let liste = ayrikDegerler.get(anahtar);
|
||
if (!liste) {
|
||
liste = (getDb().prepare(`SELECT DISTINCT ${kolon} AS v FROM ${tablo}`).all() as { v: string | null }[])
|
||
.filter((x): x is { v: string } => x.v != null)
|
||
.map((x) => ({ ham: x.v, katli: turkishSlugify(x.v) }));
|
||
ayrikDegerler.set(anahtar, liste);
|
||
}
|
||
return liste;
|
||
}
|
||
|
||
/** Kolonun, katlanmış hâli `katliSorgu`yu içeren ham değerleri. */
|
||
export function katliEslesenler(
|
||
kolon: "isim" | "universite",
|
||
katliSorgu: string,
|
||
tablo: "programs" | "programs_arsiv" = "programs"
|
||
): string[] {
|
||
return ayrikListe(kolon, tablo)
|
||
.filter((x) => x.katli.includes(katliSorgu))
|
||
.map((x) => x.ham);
|
||
}
|
||
|
||
let arsivTablosuVar: boolean | null = null;
|
||
|
||
function arsivTablosuVarMi(): boolean {
|
||
if (arsivTablosuVar == null) {
|
||
arsivTablosuVar =
|
||
getDb()
|
||
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='programs_arsiv'")
|
||
.get() !== undefined;
|
||
}
|
||
return arsivTablosuVar;
|
||
}
|
||
|
||
const ARSIV_SELECT = `id, isim, universite, unitur, il, fakulte, tur, sure, son_kilavuz_yili AS sonKilavuzYili, eski_kod AS eskiKod,
|
||
${YILLAR.map((y) => `sira${y} AS s${y}, puan${y} AS p${y}, kontenjan${y} AS k${y}, yerlesen${y} AS y${y}`).join(", ")}`;
|
||
|
||
function arsivSatiri(row: Record<string, unknown>): ArsivProgram {
|
||
// Dolu son yerleştirme yılı: sırası ya da kontenjanı olan en yeni yıl
|
||
let sonYil: number | null = null;
|
||
for (const y of [...YILLAR].reverse()) {
|
||
if (row[`s${y}`] != null || row[`k${y}`] != null) {
|
||
sonYil = y;
|
||
break;
|
||
}
|
||
}
|
||
const deger = (onek: string) =>
|
||
sonYil == null ? null : ((row[`${onek}${sonYil}`] as number | null) ?? null);
|
||
return {
|
||
id: String(row.id),
|
||
isim: String(row.isim),
|
||
universite: String(row.universite),
|
||
unitur: (row.unitur as string | null) ?? null,
|
||
il: (row.il as string | null) ?? null,
|
||
fakulte: (row.fakulte as string | null) ?? null,
|
||
tur: String(row.tur),
|
||
sure: (row.sure as number | null) ?? null,
|
||
sonKilavuzYili: (row.sonKilavuzYili as number | null) ?? null,
|
||
sonYil,
|
||
sonSira: deger("s"),
|
||
sonPuan: deger("p"),
|
||
sonKontenjan: deger("k"),
|
||
sonYerlesen: deger("y"),
|
||
eskiKod: (row.eskiKod as string | null) ?? null,
|
||
};
|
||
}
|
||
|
||
function inKosulu(kolon: string, degerler: string[]): string {
|
||
return degerler.length ? `${kolon} IN (${degerler.map(() => "?").join(",")})` : "0 = 1";
|
||
}
|
||
|
||
/**
|
||
* Arşivdeki (güncel kılavuzda olmayan) programları arar: bölüm adı parçası,
|
||
* üniversite adı parçası (katlanmış), il (DB biçimi). En yeni son kılavuz yılı
|
||
* önce. Tablo yoksa boş.
|
||
*/
|
||
export function arsivProgramAra(
|
||
a: { arama?: string; universite?: string; il?: string; limit?: number }
|
||
): { toplam: number; programlar: ArsivProgram[] } {
|
||
if (!arsivTablosuVarMi()) return { toplam: 0, programlar: [] };
|
||
const kosullar: string[] = [];
|
||
const args: (string | number)[] = [];
|
||
if (a.arama) {
|
||
const adlar = katliEslesenler("isim", turkishSlugify(a.arama), "programs_arsiv");
|
||
kosullar.push(inKosulu("isim", adlar));
|
||
args.push(...adlar);
|
||
}
|
||
if (a.universite) {
|
||
const adlar = katliEslesenler("universite", turkishSlugify(a.universite), "programs_arsiv");
|
||
kosullar.push(inKosulu("universite", adlar));
|
||
args.push(...adlar);
|
||
}
|
||
if (a.il) {
|
||
kosullar.push("il = ?");
|
||
args.push(a.il);
|
||
}
|
||
if (kosullar.length === 0) return { toplam: 0, programlar: [] };
|
||
const where = `WHERE ${kosullar.join(" AND ")}`;
|
||
const db = getDb();
|
||
const toplam = (db.prepare(`SELECT COUNT(*) AS n FROM programs_arsiv ${where}`).get(...args) as { n: number }).n;
|
||
const satirlar = db
|
||
.prepare(
|
||
`SELECT ${ARSIV_SELECT} FROM programs_arsiv ${where}
|
||
ORDER BY son_kilavuz_yili DESC, universite, isim LIMIT ?`
|
||
)
|
||
.all(...args, a.limit ?? 10) as Record<string, unknown>[];
|
||
return { toplam, programlar: satirlar.map(arsivSatiri) };
|
||
}
|
||
|
||
/** Arşivdeki tek program (id `kod` ya da `kod~yıl` biçiminde olabilir). */
|
||
export function arsivProgramGetir(id: string): ArsivProgram | null {
|
||
if (!arsivTablosuVarMi()) return null;
|
||
const row = getDb()
|
||
.prepare(`SELECT ${ARSIV_SELECT} FROM programs_arsiv WHERE id = ? OR id LIKE ? ORDER BY son_kilavuz_yili DESC LIMIT 1`)
|
||
.get(id, `${id}~%`) as Record<string, unknown> | undefined;
|
||
return row ? arsivSatiri(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
|
||
* yerleştirme yılı yüklenince bununla tazelenir. Güncel kılavuzda olmayan
|
||
* (arşivlenmiş) id'ler sonuçta yer almaz — çağıran taraf eski değeri korur.
|
||
*/
|
||
export function efektifSiralarGetir(
|
||
ids: readonly string[]
|
||
): Record<string, number | null> {
|
||
if (ids.length === 0) return {};
|
||
const satirlar = getDb()
|
||
.prepare(
|
||
`SELECT id, ${EFEKTIF_SIRA} AS efektif FROM programs
|
||
WHERE id IN (${ids.map(() => "?").join(",")})`
|
||
)
|
||
.all(...ids) as { id: string; efektif: number | null }[];
|
||
const sonuc: Record<string, number | null> = {};
|
||
for (const s of satirlar) sonuc[s.id] = s.efektif;
|
||
return sonuc;
|
||
}
|
||
|
||
// Sihirbaz bubble'ları için erişilebilir program penceresi: hayal için
|
||
// gerçekçi alt sınırdan (sira*0.5) itibaren AÇIK uçlu — garanti tarafı artık
|
||
// sınırsız olduğundan üst kesim yok. Böylece 500B sıradaki aday Tıp bubble'ı
|
||
// görmez; 5B sıradaki görür.
|
||
//
|
||
// Kademeli (cascade) filtre: adımlar birbirini süzer. Kategoriler yalnızca
|
||
// pencereden; iller seçili kategorilerden; üniversite tipi kategori+il'den
|
||
// hesaplanır. Böylece "Mühendislik + İzmir" seçen aday 3. adımda yalnızca
|
||
// İzmir'deki mühendislik programlarının devlet/vakıf dağılımını görür.
|
||
export function rankWindowFacets(
|
||
sira: number,
|
||
turKey: PuanTuruKey,
|
||
secim: { kategoriler?: string[]; iller?: string[] } = {}
|
||
): SihirbazFacetleri {
|
||
const db = getDb();
|
||
const tur = PUAN_TURLERI[turKey];
|
||
const onlisans = turKey === "tyt" ? 1 : 0;
|
||
const altSinir = Math.round(sira * 0.5);
|
||
|
||
const satirlar = db
|
||
.prepare(
|
||
`SELECT isim, fakulte, il, unitur FROM programs
|
||
WHERE tur = ? AND onlisans = ? AND ${EFEKTIF_SIRA} IS NOT NULL
|
||
AND ${EFEKTIF_SIRA} >= ?`
|
||
)
|
||
.all(tur, onlisans, altSinir) as {
|
||
isim: string;
|
||
fakulte: string | null;
|
||
il: string | null;
|
||
unitur: string | null;
|
||
}[];
|
||
|
||
// isim+fakülte → kategori eşlemesi pahalı (regexler); tekrar edenler için önbellek
|
||
const kategoriCache = new Map<string, string[]>();
|
||
const kategorileriGetir = (
|
||
isim: string,
|
||
fakulte: string | null
|
||
): string[] => {
|
||
const anahtar = `${isim}|${fakulte ?? ""}`;
|
||
let k = kategoriCache.get(anahtar);
|
||
if (!k) {
|
||
k = kategoriBul(isim, fakulte);
|
||
kategoriCache.set(anahtar, k);
|
||
}
|
||
return k;
|
||
};
|
||
|
||
const seciliKategoriler = secim.kategoriler ?? [];
|
||
const kategoriUyar = (isim: string, fakulte: string | null) =>
|
||
seciliKategoriler.length === 0 ||
|
||
kategorileriGetir(isim, fakulte).some((k) =>
|
||
seciliKategoriler.includes(k)
|
||
);
|
||
const seciliIller = new Set(secim.iller ?? []);
|
||
const ilUyar = (il: string | null) =>
|
||
seciliIller.size === 0 || (il != null && seciliIller.has(il));
|
||
|
||
const kategoriSay = new Map<string, number>();
|
||
const ilSay = new Map<string, number>();
|
||
let devlet = 0;
|
||
let vakif = 0;
|
||
|
||
for (const { isim, fakulte, il, unitur } of satirlar) {
|
||
// 1. adım: kategoriler yalnızca pencereye bağlı (filtre uygulanmaz)
|
||
for (const k of kategorileriGetir(isim, fakulte)) {
|
||
kategoriSay.set(k, (kategoriSay.get(k) ?? 0) + 1);
|
||
}
|
||
if (!kategoriUyar(isim, fakulte)) continue;
|
||
// 2. adım: iller seçili kategorilerle süzülür
|
||
if (il) ilSay.set(il, (ilSay.get(il) ?? 0) + 1);
|
||
if (!ilUyar(il)) continue;
|
||
// 3. adım: üniversite tipi kategori + il ile süzülür
|
||
if (unitur === "DEVLET") devlet += 1;
|
||
else if (unitur) vakif += 1;
|
||
}
|
||
|
||
const kategoriler = KATEGORILER.map((ad) => ({
|
||
ad,
|
||
adet: kategoriSay.get(ad) ?? 0,
|
||
}))
|
||
.filter((k) => k.adet >= 3) // gürültüyü ele
|
||
.sort((a, b) => b.adet - a.adet);
|
||
|
||
// Penceredeki programı olan TÜM iller döner (81 il + KKTC/yurtdışı);
|
||
// sıralama/gruplama UI'da yapılır. Kırpma yok: kullanıcı her ili görebilmeli.
|
||
const iller = [...ilSay.entries()]
|
||
.map(([il, adet]) => ({ il, adet }))
|
||
.sort((a, b) => b.adet - a.adet);
|
||
|
||
const uniturler: { grup: UniturGrubu; adet: number }[] = [];
|
||
if (devlet > 0) uniturler.push({ grup: "devlet", adet: devlet });
|
||
if (vakif > 0) uniturler.push({ grup: "vakif", adet: vakif });
|
||
|
||
return { kategoriler, iller, uniturler, toplam: satirlar.length };
|
||
}
|