Files
ihaleist/server/siniflandir.mjs
T
hikmettan c5662d2e60 CPV/OKAS ham kod sınıflandırması: yayınlanmadan önce tamamlanan bir gate + sidebar filtresi
- server/cpv-classify.mjs (yeni): existing (kalem kodu) → keyword (66 kural, 45 OKAS_BOLUM kodunun tamamı kapsanıyor) → AI (analyzeSpec, sadece sözlükte gerçekten var olan kod kabul edilir) hibrit sınıflandırma
- server/index.js: cache.tenders'a eklenmeden ÖNCE her ihale için CPV sınıflandırması await'leniyor (mevcut "sektör" alanının aksine, sonradan zenginleştirilmiyor) — hem ilk yükleme hem arka plan tamamlama yolunda
- İKN bazlı kalıcı SQLite önbellek (history-store.mjs) — aynı ilan bir daha asla yeniden sınıflandırılmıyor
- /api/meta/filtreler artık cpvBolumler döndürüyor
- Frontend: Sektörler'in hemen altına CPV Kodu filtresi (arama + çoklu seçim), ihale kartında CPV rozeti, landing page'de yeni özellik: "AB uyumlu CPV sınıflandırması — Türkiye'de ilk"
2026-08-30 14:00:56 +03:00

286 lines
15 KiB
JavaScript
Raw 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.
/**
* Sektör ve kurum tipi sınıflandırması — TEK kaynak.
*
* Üç ayrı veri kaynağı (ilan.gov.tr canlı akışı, doğrudan temin, EKAP sonuç
* arşivi) aynı kurallardan geçer. Aksi halde "Sağlık" filtresi yalnızca bir
* kaynağı süzer, diğer iki kaynaktaki ihaleler görünmez olur.
*
* TÜRKÇE BÜYÜK HARF TUZAĞI: ilan başlıklarının yarısı TAMAMEN BÜYÜK HARF ve
* JavaScript'in `i` bayrağı Türkçe İ/I çiftini katlamaz — /inşaat/i, "İNŞAAT"
* ile EŞLEŞMEZ. Bu yüzden metni önce normalleştiriyoruz (Türkçe küçük harf +
* ASCII katlama) ve kuralları da normalleştirilmiş alfabede yazıyoruz:
* desenlerde ı/i/ş/ç/ğ/ö/ü YOKTUR, hepsi ASCII karşılığıdır.
*/
const TR_KATLAMA = {
'İ': 'i', 'I': 'i', 'ı': 'i', 'i': 'i',
'Ş': 's', 'ş': 's', 'Ç': 'c', 'ç': 'c',
'Ğ': 'g', 'ğ': 'g', 'Ö': 'o', 'ö': 'o',
'Ü': 'u', 'ü': 'u', 'Â': 'a', 'â': 'a', 'Î': 'i', 'î': 'i', 'Û': 'u', 'û': 'u',
};
/** Türkçe duyarlı küçültme + ASCII katlama. "İNŞAAT" → "insaat" */
export function normalizeTr(s) {
if (!s) return '';
let out = '';
for (const ch of String(s)) {
out += TR_KATLAMA[ch] ?? ch.toLowerCase();
}
return out.replace(/\s+/g, ' ');
}
// Desenler NORMALLEŞTİRİLMİŞ metne uygulanır: hepsi küçük harf ve ASCII.
// Sıra ÖNEMLİ: ilk eşleşen kazanır, dar kalıplar geniş olanlardan önce gelir
// (ör. "tibbi gaz" → Sağlık, "dogalgaz" → Isıtma).
export const SECTOR_RULES = [
[/\btibbi|\bmedikal|\bsaglik|\bhastane|\bilac\b|\bpoliklinik|\bcerrahi|\bameliyat|\bdiyaliz|\bradyoloji|\brontgen|\btomografi|\bultrason|\bsteril|\benjektor|\beldiven|\bmaske|\bpansuman|\bped\b|\bsonda|\bkateter|\bprotez|\bortez|\bisitme cihaz|\bdis unit|\bdental|\bdis hekim|\bimplant|\bendo motor|\breaktif|\bhemogram|\bserum|\basi alimi|\basilama|\bsedye|\bligasyon|\btest kiti|\bkit alimi|\bmanometre|\boksijen regulator|\bmedikal gaz|\bisitme engelli|\binduksiyon dongu|\bhasta yatak|\btekerlekli sandalye|\botoklav|\baspirator|\bnebulizator|\bpuls oksimetre/i, 'Sağlık / Tıbbi Malzeme'],
[/\byazilim|\bbilisim|\bsunucu|\bdonanim|\bnetwork|\bbilgisayar|\blisans|\bveri merkezi|\bsiber|\byazici alim|\btoner alim|\btablet|\bprojeksiyon|\bkart okuyucu|\bnitelikli sertifika|\be-imza sertifika|\bbarkod|\be-imza|\botomasyon sistem/i, 'Bilişim & Yazılım'],
[/\bguvenlik hizmet|\bozel guvenlik|\bkamera|\bcctv|\balarm|\bturnike|\bx-ray|\bguvenlik sistem|\byangin sondur|\byangin tup|\byangin algila|\bitfaiye/i, 'Güvenlik Sistemleri'],
[/\bgida|\byemek|\bcatering|\bekmek|\bet alim|\bkirmizi et|\bbeyaz et|\bsebze|\bmeyve|\bkumanya|\bsut\b|\bpeynir|\btavuk|\bbakliyat|\bmutfak|\bgazoz|\bicecek|\bsu alimi|\bcips|\bkuru gida|\bdana\b|\bkuzu\b|\bfritoz|\breso\b|\bbenmari|\bbulasik makinesi|\bmutfak ekipman|\bkantin/i, 'Gıda & Catering'],
[/\btemizlik|\bhijyen|\bdezenfek|\bdeterjan|\bcop\b|\batik toplama|\bhasere|\bilaclama|\bsupurge|\bkagit havlu|\btuvalet kagidi|\bkonteyner|\batik depo|\bgeri donusum/i, 'Temizlik & Hijyen'],
[/\basfalt|\bbeton|\byol yapim|\bkilit parke|\bbordur|\bkaldirim|\bkopru|\baltyapi|\bkanalizasyon|\bicme suyu|\bsulama|\bmenfez|\bistinat|\bstabilize|\bdolgu/i, 'Altyapı & Yol'],
[/\binsaat|\byapim isi|\bonarim|\btamirat|\btadilat|\bguclendirme|\bcati|\bcephe|\brestorasyon|\bbakim onarim|\bbakim ve onarim|\bbakim hizmet|\bbakim ve onarim|\bbakim isi\b|\bbakim hizmet|\bboya\b|\bbadana|\bdograma|\bpvc pencere|\bhafriyat|\byikim|\bnalbur|\bhirdavat|\bcivata|\bvida\b|\bcimento|\bdemir alim|\bkereste|\bfayans|\bseramik|\bizolasyon|\bmantolama/i, 'İnşaat & Bakım'],
[/\bkirtasiye|\bmatbaa|\bbaski|\bkagit|\btoner|\bkartus|\bfotokopi|\bburo malzeme|\bdefter|\balbum|\bdosya alim|\bzarf\b|\bbasili evrak|\bform basim|\bkaset|\bklasor/i, 'Kırtasiye & Büro'],
[/\bmobilya|\bmasa\b|\bsandalye|\bdolap|\bokul sirasi|\bburo mobilya|\braf\b|\bperde|\bkoltuk|\byatak\b|\bkaryola/i, 'Mobilya & Donatı'],
[/\bgiyim|\btekstil|\belbise|\buniforma|\bkiyafet|\bayakkabi|\bbot alim|\bcamasir|\bkumas|\bbattaniye|\bhavlu\b|\bnevresim/i, 'Tekstil & Giyim'],
[/\bakaryakit|\bmotorin|\bbenzin|\blpg|\byakit alim|\bodun\b|\bkomur|\bpelet\b/i, 'Akaryakıt & Yakıt'],
[/\barac kirala|\btasit kirala|\bservis hizmet|\bpersonel tasima|\bogrenci tasima|\btasimali egitim|\btasimali ilkogretim|\btasimali ortaogretim|\bogrenci.{0,30}tasin|\bogrenci.{0,30}tasima|\btasinmasi isi|\btasinmasi hizmet|\btasima hizmet|\bnakliye|\blojistik|\btreyler|\bcekici kirala/i, 'Taşımacılık & Kiralama'],
[/\barac\b|\botomotiv|\blastik|\byedek parca|\botobus|\bkamyon|\bis makinesi|\bgreyder|\bekskavator|\bkepce|\btraktor|\btransmisyon|\bmotor yagi|\baku\b|\bplakali|\btasit bakim|\barac bakim|\bbalata|\bamortisor|\brot\b|\bfar alim/i, 'Otomotiv & İş Makinesi'],
[/\belektrik|\btrafo|\benerji|\baydinlatma|\bjenerator|\bgunes|\bsolar|\bkablo|\bpano\b|\bups\b|\barmatur|\bampul|\bled\b/i, 'Elektrik & Enerji'],
[/\bdogalgaz|\bkalorifer|\bkazan\b|\bisitma|\bsogutma|\bklima|\bhavalandirma|\biklimlendirme|\bmekanik tesisat|\bradyator|\bbuzdolabi|\bsogutucu/i, 'Isıtma & İklimlendirme'],
[/\basansor|\byuruyen merdiven|\bplatform lift/i, 'Asansör & Yürüyen Sistem'],
[/\bsu aritma|\baritma tesis|\bpompa|\bsondaj|\bkuyu\b|\bsu deposu|\bklorlama|\bdalgic|\bboru\b|\bvana\b/i, 'Su & Arıtma'],
[/\btarim|\btohum|\bgubre|\bfide|\bzirai|\bhayvan|\byem\b|\bveteriner|\bmama\b|\bkedi\b|\bkopek\b|\bevcil|\bsera\b|\bilaclama zirai|\borman|\bagaclandirma/i, 'Tarım & Hayvancılık'],
[/\bpeyzaj|\bpark bahce|\bagac\b|\bcim\b|\bfidan|\boyun grubu|\bkent mobilya|\bbank alim|\bcicek/i, 'Peyzaj & Park'],
[/\begitim hizmet|\bkurs\b|\bseminer|\bdanismanlik|\bmusavirlik|\betut proje|\bmuhendislik hizmet|\bkontrolluk|\barastirma hizmet|\bsaha arastirma|\banket/i, 'Danışmanlık & Proje'],
[/\breklam|\btanitim|\borganizasyon|\bfuar\b|\bbasin|\bmedya|\bproduksiyon|\bafis|\bbrosur|\bdavetiye|\bpromosyon|\bporselen kupa|\bhediyelik|\bbayrak alim|\bflama/i, 'Reklam & Organizasyon'],
[/\bsigorta|\bzorunlu trafik|\bkasko/i, 'Sigorta'],
[/\bkitap|\byayin|\bkutuphane|\barsiv|\bdijitallestirme|\btercume|\bceviri/i, 'Yayın & Arşiv'],
[/\bspor\b|\bhali saha|\btribun|\bfitness|\bkondisyon|\bstadyum|\byuzme havuz/i, 'Spor Tesisleri'],
[/\blaboratuvar|\banaliz cihaz|\bolcum cihaz|\btest cihaz|\bmikroskop|\bsantrifuj|\bnumune|\bkantar|\btarti\b|\bterazi|\bkalibrasyon|\bsayac\b/i, 'Laboratuvar & Ölçüm'],
[/\btasinmaz|\bparsel|\barsa\b|\bgayrimenkul|\bkiraya veril|\bsatis ihalesi|\birtifak|\becrimisil/i, 'Taşınmaz & Kiralama'],
[/\bpolisaj|\btestere|\btorna|\bkaynak makinesi|\bmatkap|\bjeneratorlu|\batolye|\bel aleti|\bkompresor/i, 'Atölye & Makine Ekipmanı'],
[/\bpersonel calistir|\bisci calistir|\bhizmet alimi personel|\btemizlik personel|\bguvenlik personel|\bdestek personel/i, 'Personel Hizmet Alımı'],
];
export const INSTITUTION_RULES = [
[/\bbuyuksehir belediye/i, 'Büyükşehir Belediyeleri'],
[/\bbelediye/i, 'Belediyeler'],
[/\buniversite/i, 'Üniversiteler'],
[/\bhastane|\bsaglik mudurlugu|\bbashekim/i, 'Eğitim ve Araştırma Hastaneleri'],
[/\bagiz ve dis/i, 'Ağız ve Diş Sağlığı Merkezleri'],
[/\bceza infaz|\bcezaevi|\btevkifevi/i, 'Ceza İnfaz Kurumları'],
[/\bbakanligi|\bbakanlik|\bvaliligi|\bmudurlugu/i, 'Bakanlıklar'],
[/a\.s\.|\bgenel mudurlugu|\bisletme/i, 'Kamu Şirketleri'],
];
/**
* İlan metninden YALNIZCA işin konusunu ayıklar.
*
* İlanın tamamı standart hukuki metinle doludur ("e-imza ile imzalanarak",
* "numune sunulması istenmektedir", "…Sağlık Bakanlığı", "lisans belgesi") ve
* bunlar sınıflandırmayı zehirler: "Kedi ve köpek maması" ilanı e-imza
* cümlesi yüzünden Bilişim çıkar. Bu yüzden yalnızca iki güvenilir bölgeyi
* okuruz:
* 1) Başlangıç → "4734 sayılı" (ilanın konu cümlesi)
* 2) "Niteliği, türü ve miktarı" alanının değeri
*/
export function ilanKonusu(metin) {
if (!metin) return '';
// Arama da dönüş de NORMALLEŞTİRİLMİŞ metin üzerinde: "4734 sayılı" gibi
// işaretleri Türkçe karakterden bağımsız bulabilelim.
const t = normalizeTr(
String(metin).replace(/\{[^}]*\}/g, ' ').replace(/ /g, ' ')
);
const parcalar = [];
// 1) Konu cümlesi: metnin başından "4734 sayılı"ya kadar
const kanun = t.search(/4734\s*sayili|sayili kamu ihale kanunu/i);
parcalar.push(kanun > 0 ? t.slice(0, kanun) : t.slice(0, 300));
// 2) "Niteliği, türü ve miktarı" alanının değeri (bir sonraki madde numarasına kadar)
const nitelik = t.match(/nitelig[iı][^:]{0,40}:\s*([\s\S]{0,400}?)(?=\d+\.\d|\d+-\s|$)/i);
if (nitelik) parcalar.push(nitelik[1]);
return parcalar.join(' ').slice(0, 800);
}
// --- OKAS (CPV) kod sınıflandırması ---------------------------------------
// OKAS, AB'nin CPV numaralandırmasını kullanır ve kodu ihaleyi AÇAN İDARE atar.
// Yani başlıktan tahmin etmekten çok daha güvenilir bir sinyaldir; kod varsa
// metin kurallarından ÖNCE gelir.
//
// İlk iki hane "bölüm"dür. Bazı bölümler karışıktır (39 = hem mobilya hem
// temizlik ürünleri, 79 = hem güvenlik hem matbaa hem danışmanlık); onlar için
// daha uzun ön ek tanımlanır ve uzun olan kazanır.
export const OKAS_GRUP = {
// 39 — mobilya / ev aletleri / temizlik ürünleri aynı bölümde
'3971': 'Isıtma & İklimlendirme', // elektrikli ev aletleri (buzdolabı, klima)
'3922': 'Temizlik & Hijyen', // süpürge, fırça, temizlik gereçleri
'3953': 'Temizlik & Hijyen', // paspas, yer örtüsü
'3983': 'Temizlik & Hijyen', // temizlik ürünleri
'3984': 'Temizlik & Hijyen',
// 33 — tıbbi bölümde hijyenik kâğıt da var
'3376': 'Temizlik & Hijyen', // tuvalet kâğıdı, kâğıt havlu
'3377': 'Temizlik & Hijyen',
// 50 — bakım/onarım hizmetleri: taşıt onarımı ayrılır
'5011': 'Otomotiv & İş Makinesi',
'5012': 'Otomotiv & İş Makinesi',
'5013': 'Otomotiv & İş Makinesi',
'5014': 'Otomotiv & İş Makinesi',
// 03 — tarımsal ürünlerin yenilebilir olanları gıdadır
'0311': 'Gıda & Catering',
'0313': 'Gıda & Catering',
'0314': 'Gıda & Catering',
'0321': 'Gıda & Catering',
'0322': 'Gıda & Catering',
'0331': 'Gıda & Catering',
'0332': 'Gıda & Catering',
// 79 — iş hizmetleri: güvenlik, matbaa, reklam, danışmanlık, personel
'7971': 'Güvenlik Sistemleri',
'7972': 'Güvenlik Sistemleri',
'7981': 'Kırtasiye & Büro', // basım hizmetleri
'7982': 'Reklam & Organizasyon',
'7934': 'Reklam & Organizasyon',
'7995': 'Reklam & Organizasyon', // fuar/festival organizasyonu
'7962': 'Personel Hizmet Alımı',
'7963': 'Personel Hizmet Alımı',
// 34 — taşıt bölümünde yol donanımı da var
'3492': 'Altyapı & Yol', // yol donanımı, sinyalizasyon
'34928': 'Temizlik & Hijyen', // çöp konteyneri
// 37 — spor/oyun ekipmanı
'3753': 'Peyzaj & Park', // oyun grupları
// 44 — yapı malzemesi içinde boru/vana
'4416': 'Su & Arıtma',
// 90 — çevre hizmetleri içinde atık su
'9042': 'Su & Arıtma',
};
export const OKAS_BOLUM = {
'03': 'Tarım & Hayvancılık',
'09': 'Akaryakıt & Yakıt',
'14': 'İnşaat & Bakım',
'15': 'Gıda & Catering',
'16': 'Tarım & Hayvancılık',
'18': 'Tekstil & Giyim',
'19': 'Tekstil & Giyim',
'22': 'Kırtasiye & Büro',
'24': 'İnşaat & Bakım',
'30': 'Bilişim & Yazılım',
'31': 'Elektrik & Enerji',
'32': 'Bilişim & Yazılım',
'33': 'Sağlık / Tıbbi Malzeme',
'34': 'Otomotiv & İş Makinesi',
'35': 'Güvenlik Sistemleri',
'37': 'Spor Tesisleri',
'38': 'Laboratuvar & Ölçüm',
'39': 'Mobilya & Donatı',
'41': 'Su & Arıtma',
'42': 'Atölye & Makine Ekipmanı',
'43': 'Otomotiv & İş Makinesi',
'44': 'İnşaat & Bakım',
'45': 'İnşaat & Bakım',
'48': 'Bilişim & Yazılım',
'50': 'İnşaat & Bakım',
'51': 'İnşaat & Bakım',
'55': 'Gıda & Catering',
'60': 'Taşımacılık & Kiralama',
'63': 'Taşımacılık & Kiralama',
'64': 'Bilişim & Yazılım',
'65': 'Elektrik & Enerji',
'66': 'Sigorta',
'70': 'Taşınmaz & Kiralama',
'71': 'Danışmanlık & Proje',
'72': 'Bilişim & Yazılım',
'73': 'Danışmanlık & Proje',
'75': 'Danışmanlık & Proje',
'76': 'Akaryakıt & Yakıt',
'77': 'Peyzaj & Park',
'79': 'Danışmanlık & Proje',
'80': 'Danışmanlık & Proje',
'85': 'Sağlık / Tıbbi Malzeme',
'90': 'Temizlik & Hijyen',
'92': 'Spor Tesisleri',
'98': 'Danışmanlık & Proje',
};
/** Tek bir OKAS kodundan sektör. En uzun ön ek kazanır. */
export function okasSektor(kod) {
const s = String(kod || '').replace(/\D/g, '');
if (s.length < 2) return null;
for (const n of [5, 4]) {
const hit = OKAS_GRUP[s.slice(0, n)];
if (hit) return hit;
}
return OKAS_BOLUM[s.slice(0, 2)] || null;
}
/**
* Kalem listesinden sektör — ÇOĞUNLUK oyu.
* Bir ihalede farklı bölümlerden kalemler olabilir (ör. hem inşaat hem
* elektrik); en çok tekrar eden bölüm ihalenin ağırlığını temsil eder.
*/
export function okasSektorListe(kalemler) {
if (!Array.isArray(kalemler) || !kalemler.length) return null;
const sayac = new Map();
for (const k of kalemler) {
const sek = okasSektor(k?.kod ?? k);
if (sek) sayac.set(sek, (sayac.get(sek) || 0) + 1);
}
if (!sayac.size) return null;
return [...sayac.entries()].sort((a, b) => b[1] - a[1])[0][0];
}
/** Kuralları NORMALLEŞTİRİLMİŞ metne uygular. */
export function classify(rules, text, fallback) {
const t = normalizeTr(text);
for (const [re, label] of rules) if (re.test(t)) return label;
return fallback;
}
/**
* İhale metninden sektör. Parçalar ÖNCELİK sırasına göre TEK TEK denenir.
*
* Hepsini birleştirip taramak yanlış sonuç verir: kural sırası metin içindeki
* konumu ezer. "Gıda ürünleri alınacaktır" + "X DEVLET HASTANESİ" birleştirilirse
* Sağlık kuralı (1. sıra) Gıda kuralından (4. sıra) önce eşleşir ve gıda ihalesi
* Sağlık görünür. Bu yüzden önce başlığa bakarız; ancak başlık sessizse
* yardımcı metinlere (kalem adları, ilan konusu, kurum adı) düşeriz.
*/
export function sektorBul(...parcalar) {
for (const p of parcalar) {
if (!p) continue;
const s = classify(SECTOR_RULES, p, null);
if (s) return s;
}
return 'Genel';
}
/**
* Sektör kararı: önce OKAS kodu (idarenin kendi sınıflandırması), yoksa metin.
* @param {Array} kalemler {kod, ad} listesi
* @param {...string} parcalar başlık vb. yedek metin
*/
export function sektorKarar(kalemler, ...parcalar) {
const koddan = okasSektorListe(kalemler);
if (koddan) return koddan;
const adlar = (kalemler || []).map((k) => k?.ad || '').join(' ');
return sektorBul(...parcalar, adlar);
}
export function kurumTipiBul(ad) {
return classify(INSTITUTION_RULES, ad || '', 'Diğer Kamu Kurumları');
}
/** Arayüzün kullandığı listeler — kurallardan türetilir, elle tutulmaz. */
// 'Genel' bir kural değil, hiçbir kalıba uymayanların düştüğü kova. Yine de
// listeye eklenir: aksi halde bu kayıtlar sektör filtresiyle hiç bulunamaz.
export const SEKTOR_ETIKETLERI = [
...[...new Set(SECTOR_RULES.map(([, ad]) => ad))].sort((a, b) => a.localeCompare(b, 'tr')),
'Genel',
];
export const KURUM_ETIKETLERI = [...new Set(INSTITUTION_RULES.map(([, ad]) => ad))]
.sort((a, b) => a.localeCompare(b, 'tr'));