diff --git a/scripts/eval/varyant-denetim.ts b/scripts/eval/varyant-denetim.ts
new file mode 100644
index 0000000..310a67b
--- /dev/null
+++ b/scripts/eval/varyant-denetim.ts
@@ -0,0 +1,79 @@
+/**
+ * Program varyant etiketinin MEKANİK testi — parasız, anahtarsız, salt okur.
+ *
+ * pnpm tsx scripts/eval/varyant-denetim.ts
+ *
+ * react-server koşulu gerekmez: db.ts ve program-varyant.ts saf/sqlite.
+ * Doğrulananlar: (1) varyantEtiketi birim kuralları, (2) Başkent Tıp'ın 6
+ * satırının etiketleri birbirinden farklı ve hiçbiri null, (3) dilimAra'nın
+ * döndürdüğü her satırda `varyant` tanımlı (SELECT_COLS tüm yolu kapsıyor),
+ * (4) devlet/Türkçe/örgün programda null. data/yokatlas.db'ye yazılmaz.
+ * Beklenti tutmazsa çıkış kodu 1.
+ */
+import { dilimAra, programGetir } from "../../src/lib/db";
+import { varyantEtiketi, type VaryantGirdisi } from "../../src/lib/program-varyant";
+
+let hata = 0;
+function bekle(ad: string, kosul: boolean, detay = ""): void {
+ console.log(`${kosul ? "✔" : "✘"} ${ad}${detay ? ` — ${detay}` : ""}`);
+ if (!kosul) hata++;
+}
+
+// 1. Birim: etiket kuralları
+const birim: [VaryantGirdisi, string | null][] = [
+ [{ bursOrani: "Burslu", ogrenimDili: "İngilizce", ogrenimTuru: "Örgün Öğretim" }, "Burslu · İngilizce"],
+ [{ bursOrani: null, ogrenimDili: "Türkçe", ogrenimTuru: "Örgün Öğretim" }, null],
+ [{ bursOrani: "Ücretli", ogrenimDili: "Türkçe", ogrenimTuru: "İkinci Öğretim" }, "Ücretli · İkinci Öğretim"],
+ [{ bursOrani: "%50 İndirimli", ogrenimDili: null, ogrenimTuru: null }, "%50 İndirimli"],
+ // DB'de burs_orani boş string de geliyor (devlet); null gibi davranır
+ [{ bursOrani: "", ogrenimDili: "Türkçe", ogrenimTuru: "Uzaktan Öğretim" }, "Uzaktan Öğretim"],
+];
+for (const [girdi, beklenen] of birim) {
+ const sonuc = varyantEtiketi(girdi);
+ bekle(
+ `birim ${JSON.stringify(girdi)}`,
+ sonuc === beklenen,
+ `beklenen ${JSON.stringify(beklenen)}, gelen ${JSON.stringify(sonuc)}`,
+ );
+}
+
+// 2. Başkent Tıp ×6: isim hepsinde "Tıp", varyant hepsinde farklı
+const BASKENT_TIP = ["200610657", "200610666", "200611488", "200690357", "200690358", "200690359"];
+const baskent = BASKENT_TIP.map((id) => ({ id, p: programGetir(id) }));
+for (const { id, p } of baskent) {
+ bekle(
+ `programGetir(${id}) varyant dolu`,
+ p != null && p.varyant != null,
+ p ? `${p.universite} / ${p.isim} → ${p.varyant}` : "bulunamadı",
+ );
+}
+const etiketler = baskent.map(({ p }) => p?.varyant ?? null);
+bekle(
+ "Başkent Tıp 6 varyant birbirinden farklı",
+ new Set(etiketler).size === BASKENT_TIP.length,
+ etiketler.join(" | "),
+);
+
+// 3. dilimAra yolu: her satırda varyant tanımlı (string ya da null)
+const { programlar } = dilimAra(100000, "say", "dengeli", { limit: 50 });
+const tanimsiz = programlar.filter(
+ (p) => !("varyant" in p) || (p.varyant !== null && typeof p.varyant !== "string"),
+);
+bekle(
+ `dilimAra 50 satırda varyant tanımlı (${programlar.length} satır)`,
+ programlar.length > 0 && tanimsiz.length === 0,
+ tanimsiz.map((p) => p.id).join(","),
+);
+const etiketli = programlar.filter((p) => p.varyant).length;
+console.log(` (bilgi) dilimAra örneğinde etiketli satır: ${etiketli}/${programlar.length}`);
+
+// 4. Devlet / Türkçe / örgün → null (Marmara Hukuk)
+const marmara = programGetir("107210332");
+bekle(
+ "107210332 Marmara Hukuk varyant null",
+ marmara != null && marmara.varyant === null,
+ marmara ? `${marmara.universite} / ${marmara.isim} → ${JSON.stringify(marmara.varyant)}` : "bulunamadı",
+);
+
+console.log(hata === 0 ? "\nVaryant denetimi: TEMİZ" : `\nVaryant denetimi: ${hata} hata`);
+process.exit(hata === 0 ? 0 : 1);
diff --git a/src/features/katalog/components/bolum-program-listesi.tsx b/src/features/katalog/components/bolum-program-listesi.tsx
index 9083819..2d80712 100644
--- a/src/features/katalog/components/bolum-program-listesi.tsx
+++ b/src/features/katalog/components/bolum-program-listesi.tsx
@@ -22,6 +22,7 @@ import {
SATIR_BUTON_BASLIK,
SATIR_BUTON_HUCRE,
SATIR_BUTON_KABI,
+ SATIR_CIP,
SATIR_GRUP,
SATIR_IKINCIL_LINK,
SATIR_META,
@@ -242,6 +243,10 @@ export function BolumProgramListesi({
)}
+ {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
+ {p.varyant ? (
+ {p.varyant}
+ ) : null}
{p.il ? (
{TUR_ETIKET[p.tur] ?? p.tur}
+ {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
+ {p.varyant ? (
+ {p.varyant}
+ ) : null}
{p.sure ? (
{p.sure} yıl
) : null}
diff --git a/src/features/liste/components/liste-cekmecesi.tsx b/src/features/liste/components/liste-cekmecesi.tsx
index 6db74f5..3104dc6 100644
--- a/src/features/liste/components/liste-cekmecesi.tsx
+++ b/src/features/liste/components/liste-cekmecesi.tsx
@@ -452,6 +452,10 @@ function CekmeceSatiri({
>
{trBaslikDuzeni(tercih.universite)}
+ {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder */}
+ {tercih.varyant ? (
+ · {tercih.varyant}
+ ) : null}
{tercih.risk || tercih.efektifSira != null ? (
diff --git a/src/features/liste/components/program-ekle-butonu.tsx b/src/features/liste/components/program-ekle-butonu.tsx
index 10ba5ce..8275d43 100644
--- a/src/features/liste/components/program-ekle-butonu.tsx
+++ b/src/features/liste/components/program-ekle-butonu.tsx
@@ -28,6 +28,7 @@ type ProgramOzet = Pick<
| "unitur"
| "tur"
| "efektifSira"
+ | "varyant"
>;
const PROFILSIZ_EKLEME_TOAST_ID = "profilsiz-ekleme";
diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx
index 1ba6711..d720c5a 100644
--- a/src/features/liste/components/program-tablosu.tsx
+++ b/src/features/liste/components/program-tablosu.tsx
@@ -55,6 +55,7 @@ import {
SATIR_BUTON_HUCRE,
SATIR_BUTON_KABI,
SATIR_ALT_GRUP,
+ SATIR_CIP,
SATIR_IKINCIL_LINK,
SATIR_META,
SATIR_MOBIL_SAYI,
@@ -373,6 +374,10 @@ function ProgramSatiri({
unitur={p.unitur}
onClick={(e) => e.stopPropagation()}
/>
+ {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
+ {p.varyant ? (
+ {p.varyant}
+ ) : null}
{p.il ? (
;
kaynak: ManuelKaynak;
adaySira?: number;
@@ -271,11 +272,16 @@ export function programEkleIste(opts: {
}
const profil = profilsiz ? null : (durum.profil ?? tercihProfiliOku());
- const tercih = programdanManuelTercih(
- program,
- adaySira ?? profil?.sira ?? null,
- kaynak,
- );
+ // programdanManuelTercih (liste-store) varyantı bilmez; etiket burada
+ // kopyalanır ki çekmece aynı adlı satırları ayırt edebilsin.
+ const tercih = {
+ ...programdanManuelTercih(
+ program,
+ adaySira ?? profil?.sira ?? null,
+ kaynak,
+ ),
+ varyant: program.varyant ?? null,
+ };
const ok = ekle(tercih);
if (!ok) {
return {
diff --git a/src/features/liste/liste-dogrula.ts b/src/features/liste/liste-dogrula.ts
index 2396a66..c63494d 100644
--- a/src/features/liste/liste-dogrula.ts
+++ b/src/features/liste/liste-dogrula.ts
@@ -30,6 +30,8 @@ export type ManuelTercih = {
/** Ekleme / profil anındaki aday sıralamasına göre risk */
risk: RiskSeviyesi | null;
kaynak?: ManuelKaynak;
+ /** Burs/dil/öğretim türü etiketi (Program.varyant); eski kayıtlarda yok */
+ varyant?: string | null;
};
/** Güvenilmeyen JSON'u (localStorage veya sunucu kaydı) listeye süzer. */
@@ -66,6 +68,7 @@ export function dogrula(ham: unknown): ManuelTercih[] {
: null,
risk,
kaynak,
+ varyant: typeof kayit.varyant === "string" ? kayit.varyant : null,
});
if (sonuc.length >= MANUEL_LISTE_MAX) break;
}
diff --git a/src/features/liste/liste-queries.ts b/src/features/liste/liste-queries.ts
index d716a2e..4f95cb7 100644
--- a/src/features/liste/liste-queries.ts
+++ b/src/features/liste/liste-queries.ts
@@ -25,6 +25,7 @@ export function programOzetle(p: Program): ProgramListeSatiri {
efektifSira: p.efektifSira,
kontenjanSon: p.kontenjanSon,
yerlesenSon: p.yerlesenSon,
+ varyant: p.varyant,
};
}
diff --git a/src/features/rapor/components/rapor-listesi.tsx b/src/features/rapor/components/rapor-listesi.tsx
index 02f01a2..29cde2b 100644
--- a/src/features/rapor/components/rapor-listesi.tsx
+++ b/src/features/rapor/components/rapor-listesi.tsx
@@ -41,6 +41,7 @@ import {
SATIR_BUTON_HUCRE,
SATIR_DUGME_BOYU,
SATIR_ALT_GRUP,
+ SATIR_CIP,
SATIR_IKINCIL_LINK,
SATIR_META,
SATIR_MOBIL_SAYI,
@@ -267,6 +268,10 @@ export function RaporListesi({
unitur={p?.unitur}
onClick={(e) => e.stopPropagation()}
/>
+ {/* Burs/dil/öğretim türü: aynı adlı satırları ayırt eder (§4.15 çip) */}
+ {p?.varyant ? (
+ {p.varyant}
+ ) : null}
{p?.il ? (
;
};
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 5433355..f6d24f9 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -5,6 +5,7 @@ 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,
@@ -37,20 +38,33 @@ export function getDb(): Database.Database {
// 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`;
+ 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` takma adıyla gelir. */
+/** SELECT_COLS ile okunan ham satır: sıra kolonları `s` takma adıyla,
+ * varyant ise üç ham özellik alanı olarak gelir. */
export type ProgramHamSatiri = Omit<
Program,
- "siraGecmisi" | "efektifSira" | "siraSon"
+ "siraGecmisi" | "efektifSira" | "siraSon" | "varyant"
> &
- Record<`s${number}`, number | null>;
+ 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 {
@@ -72,6 +86,7 @@ export function programSatiri(row: ProgramHamSatiri): Program {
puanSon: row.puanSon,
kontenjanSon: row.kontenjanSon,
yerlesenSon: row.yerlesenSon,
+ varyant: varyantEtiketi(row),
};
}
diff --git a/src/lib/program-varyant.ts b/src/lib/program-varyant.ts
new file mode 100644
index 0000000..c53524f
--- /dev/null
+++ b/src/lib/program-varyant.ts
@@ -0,0 +1,40 @@
+// Program varyant etiketi — saf, DB'siz. `programs.isim` burs/dil/öğretim
+// türünü taşımaz (Başkent Tıp 6 satırda da "Tıp"); ayırt edici alanlar
+// `program_ozellik.burs_orani / ogrenim_dili / ogrenim_turu`. Bu modül o üç
+// alanı satır altında gösterilecek tek etikete çevirir; db.ts `programSatiri`
+// ve rapor-queries (kayıtlı rapor) aynı fonksiyonu kullanır.
+
+export type VaryantGirdisi = {
+ /** "Burslu" | "%50 İndirimli" | "%25 İndirimli" | "Ücretli"; devlette null/boş */
+ bursOrani: string | null;
+ /** "Türkçe" | "İngilizce" | "İngilizce (%30)" | "Almanca" … */
+ ogrenimDili: string | null;
+ /** "Örgün Öğretim" | "Uzaktan Öğretim" | "Açık Öğretim" | "UOLP" … */
+ ogrenimTuru: string | null;
+};
+
+const VARSAYILAN_DIL = "Türkçe";
+const VARSAYILAN_OGRETIM = "Örgün Öğretim";
+const AYRAC = " · ";
+
+function dolu(deger: string | null | undefined): string | null {
+ const metin = deger?.trim();
+ return metin ? metin : null;
+}
+
+/**
+ * Satır altı etiketi: "Burslu · İngilizce", "%50 İndirimli", "İkinci Öğretim",
+ * "Ücretli · Uzaktan Öğretim". Sıra: burs (varsa, DB'deki hâliyle), dil
+ * (yalnız Türkçe değilse), öğretim türü (yalnız örgün değilse). Üçü de
+ * varsayılansa (devlet / Türkçe / örgün) null — satırda hiçbir şey çizilmez.
+ */
+export function varyantEtiketi(g: VaryantGirdisi): string | null {
+ const parcalar: string[] = [];
+ const burs = dolu(g.bursOrani);
+ if (burs) parcalar.push(burs);
+ const dil = dolu(g.ogrenimDili);
+ if (dil && dil !== VARSAYILAN_DIL) parcalar.push(dil);
+ const ogretim = dolu(g.ogrenimTuru);
+ if (ogretim && ogretim !== VARSAYILAN_OGRETIM) parcalar.push(ogretim);
+ return parcalar.length > 0 ? parcalar.join(AYRAC) : null;
+}
diff --git a/src/lib/rapor-havuzu.ts b/src/lib/rapor-havuzu.ts
index 8e0126e..c4d7ad7 100644
--- a/src/lib/rapor-havuzu.ts
+++ b/src/lib/rapor-havuzu.ts
@@ -433,6 +433,9 @@ export function havuzuKompaktJson(havuz: AdayProgram[]): string {
unitur: p.unitur,
il: p.il,
dilim: p.dilim,
+ // Burs/dil/öğretim türü: model aynı adlı satırları (Başkent Tıp
+ // burslu / %50 / ücretli) ayırt etsin, gerekçede "burslu" diyebilsin
+ ...(p.varyant ? { varyant: p.varyant } : {}),
// Anahtarlar yıl taşır (y, kontenjan): model rakamın hangi
// yıla ait olduğunu anahtardan okur.
sira: Object.fromEntries(
diff --git a/src/types/yokatlas.ts b/src/types/yokatlas.ts
index d480ba7..0c395d2 100644
--- a/src/types/yokatlas.ts
+++ b/src/types/yokatlas.ts
@@ -21,6 +21,13 @@ export type Program = {
puanSon: number | null;
kontenjanSon: number | null;
yerlesenSon: number | null;
+ /**
+ * Burs / dil / öğretim türü etiketi ("Burslu · İngilizce", "%50 İndirimli",
+ * "Uzaktan Öğretim"); `program_ozellik`'ten `varyantEtiketi` ile üretilir
+ * (lib/program-varyant). Devlet/Türkçe/örgün programda null. `isim` aynı
+ * olan satırları (Başkent Tıp ×6) listede ayırt eden tek alan.
+ */
+ varyant: string | null;
};
export const PUAN_TURLERI = {