From 62d1b8844efb5b83b9fbbe9b3e08c02827dff7c7 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 30 Aug 2026 00:28:32 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20DT=20ve=20ar=C5=9Fiv=20kay=C4=B1tlar?= =?UTF-8?q?=C4=B1=20sekt=C3=B6r=20alan=C4=B1na=20ihale=20t=C3=BCr=C3=BC=20?= =?UTF-8?q?yaz=C4=B1yordu=20=E2=80=94=20sekt=C3=B6r=20filtresi=20bu=20kay?= =?UTF-8?q?=C4=B1tlar=C4=B1=20bulam=C4=B1yordu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/dt-store.mjs | 4 ++- server/history-store.mjs | 16 +++++++++- server/index.js | 57 +-------------------------------- server/siniflandir.mjs | 69 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 58 deletions(-) create mode 100644 server/siniflandir.mjs diff --git a/server/dt-store.mjs b/server/dt-store.mjs index b08d78a..13bdd46 100644 --- a/server/dt-store.mjs +++ b/server/dt-store.mjs @@ -4,6 +4,7 @@ */ import fs from 'node:fs'; +import { sektorBul } from './siniflandir.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { titleCaseTr } from './tr-text.mjs'; @@ -73,7 +74,8 @@ function mapDtRow(row) { saat: '', alimTuru: 'Doğrudan Temin', usul: det.yasaMaddesi ? `Doğrudan Temin (4734 ${det.yasaMaddesi})` : 'Doğrudan Temin (4734 22/d)', - sektor: row.turu || 'Genel', + // Sektör ihale TÜRÜ değildir; başlık + kalem adlarından çıkarılır. + sektor: sektorBul(row.isin_adi || '', (det.bransKodlari || []).join(' '), row.turu || ''), brans: (det.bransKodlari || []).join(', ') || row.turu || 'Genel', kurumTipi: classifyKurum(kurum), eklenmeZamani: row.yayim_tarihi ? `Yayım: ${row.yayim_tarihi}` : '', diff --git a/server/history-store.mjs b/server/history-store.mjs index 8962709..06ee580 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -6,6 +6,7 @@ */ import fs from 'node:fs'; +import { sektorBul } from './siniflandir.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { titleCaseTr } from './tr-text.mjs'; @@ -163,7 +164,9 @@ function rowToTender(r) { sehir: titleCaseTr(r.il), tarih: r.ihale_tarihi || '', alimTuru: r.ihale_tipi || 'İhale', - sektor: r.ihale_tipi || 'Genel', + // Sektör ihale TÜRÜ değildir ('Mal Alımı' bir sektör değil); + // ihale adından ve kalem adlarından çıkarılır. + sektor: sektorBul(r.ihale_adi || '', kalemAdlari(r.okas_kalemler)), kazananFirma: r.kazanan_firma, sozlesmeBedeli: r.sozlesme_bedeli, paraBirimi: r.para_birimi, @@ -190,6 +193,17 @@ function rowToTender(r) { }; } +// OKAS kalemlerinden ad listesi — sektör tahminini güçlendirir. +function kalemAdlari(ham) { + if (!ham) return ''; + try { + const k = JSON.parse(ham); + return Array.isArray(k) ? k.map((x) => x?.ad || '').join(' ') : ''; + } catch { + return ''; + } +} + export function readHistory(limit = 500, { firma, idare, il } = {}) { const db = openDb(); if (!db) return { tenders: [], totalCount: 0, available: false }; diff --git a/server/index.js b/server/index.js index 7ff1838..c09b198 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { readDtTenders } from './dt-store.mjs'; +import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI, KURUM_ETIKETLERI } from './siniflandir.mjs'; import { loginWithGoogle, userFromAuthHeader } from './auth.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs'; import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; @@ -79,62 +80,6 @@ for (const [region, cities] of Object.entries(REGIONS)) { // --------------------------------------------------------------------------- // Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre) // --------------------------------------------------------------------------- -// Sektör sınıflandırması — sıra ÖNEMLİ: ilk eşleşen kazanır, bu yüzden -// dar/özel kalıplar geniş olanlardan önce gelir (ör. "tıbbi gaz" → Sağlık, -// "doğalgaz" → Elektrik & Enerji). -// UYARI: buradaki etiketler src/data/constants.js SECTORS_LIST ile birebir -// aynı olmalı. Sürüklenmeyi önlemek için liste /api/meta/sektorler ile sunulur. -const SECTOR_RULES = [ - [/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş |protez|ortez|görüntüleme|röntgen|tomografi|steril|enjektör|eldiven|maske|reaktif|kit alım/i, 'Sağlık / Tıbbi Malzeme'], - [/yazılım|bilişim|sunucu|donanım|network|bilgisayar|lisans|veri merkezi|siber|yazıcı alım|tablet|projeksiyon/i, 'Bilişim & Yazılım'], - [/güvenlik hizmet|özel güvenlik|kamera|cctv|alarm|turnike|x-ray|güvenlik sistem/i, 'Güvenlik Sistemleri'], - [/gıda|yemek|catering|ekmek|et alım|sebze|meyve|kumanya|süt|tavuk|bakliyat|mutfak/i, 'Gıda & Catering'], - [/temizlik|hijyen|dezenfek|deterjan|çöp|atık toplama|haşere|ilaçlama/i, 'Temizlik & Hijyen'], - [/asfalt|beton|yol yapım|köprü|altyapı|kanalizasyon|içme suyu|sulama|menfez|istinat/i, 'Altyapı & Yol'], - [/inşaat|yapım işi|onarım|tadilat|güçlendirme|çatı|cephe|restorasyon|bakım onarım/i, 'İnşaat & Bakım'], - [/kırtasiye|matbaa|baskı|kağıt|toner|kartuş|fotokopi|büro malzeme/i, 'Kırtasiye & Büro'], - [/mobilya|masa|sandalye|dolap|okul sırası|büro mobilya|raf /i, 'Mobilya & Donatı'], - [/giyim|tekstil|elbise|üniforma|kıyafet|ayakkabı|çamaşır|kumaş|battaniye/i, 'Tekstil & Giyim'], - [/akaryakıt|motorin|benzin|lpg|yakıt alım/i, 'Akaryakıt & Yakıt'], - [/araç kirala|taşıt kirala|servis hizmet|personel taşıma|öğrenci taşıma|nakliye|lojistik/i, 'Taşımacılık & Kiralama'], - [/araç|otomotiv|lastik|yedek parça|otobüs|kamyon|iş makinesi|greyder|ekskavatör/i, 'Otomotiv & İş Makinesi'], - [/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş|solar|kablo|pano |ups /i, 'Elektrik & Enerji'], - [/doğalgaz|kalorifer|kazan|ısıtma|soğutma|klima|havalandırma|iklimlendirme|mekanik tesisat/i, 'Isıtma & İklimlendirme'], - [/asansör|yürüyen merdiven/i, 'Asansör & Yürüyen Sistem'], - [/su arıtma|arıtma tesis|pompa|sondaj|kuyu |su deposu/i, 'Su & Arıtma'], - [/tarım|tohum|gübre|fide|zirai|hayvan|yem |veteriner|sera /i, 'Tarım & Hayvancılık'], - [/peyzaj|park bahçe|ağaç|çim |fidan|oyun grubu|kent mobilya/i, 'Peyzaj & Park'], - [/eğitim hizmet|kurs|seminer|danışmanlık|müşavirlik|etüt proje|mühendislik hizmet|kontrollük/i, 'Danışmanlık & Proje'], - [/reklam|tanıtım|organizasyon|fuar|basın|medya|prodüksiyon/i, 'Reklam & Organizasyon'], - [/sigorta|zorunlu trafik|kasko/i, 'Sigorta'], - [/kitap|yayın|kütüphane|arşiv|dijitalleştirme/i, 'Yayın & Arşiv'], - [/spor|halı saha|tribün|fitness|kondisyon/i, 'Spor Tesisleri'], - [/laboratuvar cihaz|analiz cihaz|ölçüm cihaz|test cihaz/i, 'Laboratuvar & Ölçüm'], -]; - - -const INSTITUTION_RULES = [ - [/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'], - [/belediye/i, 'Belediyeler'], - [/üniversite/i, 'Üniversiteler'], - [/hastane|sağlık müdürlüğü|başhekim/i, 'Eğitim ve Araştırma Hastaneleri'], - [/ağız ve diş/i, 'Ağız ve Diş Sağlığı Merkezleri'], - [/ceza infaz|cezaevi|tevkifevi/i, 'Ceza İnfaz Kurumları'], - [/bakanlığı|bakanlık|valiliği|müdürlüğü/i, 'Bakanlıklar'], - [/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'], -]; - -// Arayüzün kullandığı sektör listesi buradan türetilir — elle tutulan ikinci -// bir liste tutmayız, böylece iki taraf birbirinden ayrı düşemez. -const SEKTOR_ETIKETLERI = [...new Set(SECTOR_RULES.map(([, ad]) => ad))].sort((a, b) => - a.localeCompare(b, 'tr')); -const KURUM_ETIKETLERI = [...new Set(INSTITUTION_RULES.map(([, ad]) => ad))].sort((a, b) => - a.localeCompare(b, 'tr')); - -function classify(rules, text, fallback) { - for (const [re, label] of rules) if (re.test(text)) return label; - return fallback; -} function titleCaseTr(s) { if (!s) return ''; diff --git a/server/siniflandir.mjs b/server/siniflandir.mjs new file mode 100644 index 0000000..0c89dfd --- /dev/null +++ b/server/siniflandir.mjs @@ -0,0 +1,69 @@ +/** + * 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. + * + * Sıra ÖNEMLİ: ilk eşleşen kazanır, bu yüzden dar/özel kalıplar geniş + * olanlardan önce gelir (ör. "tıbbi gaz" → Sağlık, "doğalgaz" → Enerji). + */ + +export const SECTOR_RULES = [ + [/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş |protez|ortez|görüntüleme|röntgen|tomografi|steril|enjektör|eldiven|maske|reaktif|kit alım/i, 'Sağlık / Tıbbi Malzeme'], + [/yazılım|bilişim|sunucu|donanım|network|bilgisayar|lisans|veri merkezi|siber|yazıcı alım|tablet|projeksiyon/i, 'Bilişim & Yazılım'], + [/güvenlik hizmet|özel güvenlik|kamera|cctv|alarm|turnike|x-ray|güvenlik sistem/i, 'Güvenlik Sistemleri'], + [/gıda|yemek|catering|ekmek|et alım|sebze|meyve|kumanya|süt|tavuk|bakliyat|mutfak/i, 'Gıda & Catering'], + [/temizlik|hijyen|dezenfek|deterjan|çöp|atık toplama|haşere|ilaçlama/i, 'Temizlik & Hijyen'], + [/asfalt|beton|yol yapım|köprü|altyapı|kanalizasyon|içme suyu|sulama|menfez|istinat/i, 'Altyapı & Yol'], + [/inşaat|yapım işi|onarım|tadilat|güçlendirme|çatı|cephe|restorasyon|bakım onarım/i, 'İnşaat & Bakım'], + [/kırtasiye|matbaa|baskı|kağıt|toner|kartuş|fotokopi|büro malzeme/i, 'Kırtasiye & Büro'], + [/mobilya|masa|sandalye|dolap|okul sırası|büro mobilya|raf /i, 'Mobilya & Donatı'], + [/giyim|tekstil|elbise|üniforma|kıyafet|ayakkabı|çamaşır|kumaş|battaniye/i, 'Tekstil & Giyim'], + [/akaryakıt|motorin|benzin|lpg|yakıt alım/i, 'Akaryakıt & Yakıt'], + [/araç kirala|taşıt kirala|servis hizmet|personel taşıma|öğrenci taşıma|nakliye|lojistik/i, 'Taşımacılık & Kiralama'], + [/araç|otomotiv|lastik|yedek parça|otobüs|kamyon|iş makinesi|greyder|ekskavatör/i, 'Otomotiv & İş Makinesi'], + [/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş|solar|kablo|pano |ups /i, 'Elektrik & Enerji'], + [/doğalgaz|kalorifer|kazan|ısıtma|soğutma|klima|havalandırma|iklimlendirme|mekanik tesisat/i, 'Isıtma & İklimlendirme'], + [/asansör|yürüyen merdiven/i, 'Asansör & Yürüyen Sistem'], + [/su arıtma|arıtma tesis|pompa|sondaj|kuyu |su deposu/i, 'Su & Arıtma'], + [/tarım|tohum|gübre|fide|zirai|hayvan|yem |veteriner|sera /i, 'Tarım & Hayvancılık'], + [/peyzaj|park bahçe|ağaç|çim |fidan|oyun grubu|kent mobilya/i, 'Peyzaj & Park'], + [/eğitim hizmet|kurs|seminer|danışmanlık|müşavirlik|etüt proje|mühendislik hizmet|kontrollük/i, 'Danışmanlık & Proje'], + [/reklam|tanıtım|organizasyon|fuar|basın|medya|prodüksiyon/i, 'Reklam & Organizasyon'], + [/sigorta|zorunlu trafik|kasko/i, 'Sigorta'], + [/kitap|yayın|kütüphane|arşiv|dijitalleştirme/i, 'Yayın & Arşiv'], + [/spor|halı saha|tribün|fitness|kondisyon/i, 'Spor Tesisleri'], + [/laboratuvar cihaz|analiz cihaz|ölçüm cihaz|test cihaz/i, 'Laboratuvar & Ölçüm'], +]; + +export const INSTITUTION_RULES = [ + [/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'], + [/belediye/i, 'Belediyeler'], + [/üniversite/i, 'Üniversiteler'], + [/hastane|sağlık müdürlüğü|başhekim/i, 'Eğitim ve Araştırma Hastaneleri'], + [/ağız ve diş/i, 'Ağız ve Diş Sağlığı Merkezleri'], + [/ceza infaz|cezaevi|tevkifevi/i, 'Ceza İnfaz Kurumları'], + [/bakanlığı|bakanlık|valiliği|müdürlüğü/i, 'Bakanlıklar'], + [/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'], +]; + +export function classify(rules, text, fallback) { + for (const [re, label] of rules) if (re.test(text)) return label; + return fallback; +} + +/** İhale metninden sektör. Kaynak ne olursa olsun aynı sonucu verir. */ +export function sektorBul(...parcalar) { + return classify(SECTOR_RULES, parcalar.filter(Boolean).join(' '), 'Genel'); +} + +export function kurumTipiBul(ad) { + return classify(INSTITUTION_RULES, ad || '', 'Diğer Kamu Kurumları'); +} + +/** Arayüzün kullandığı listeler — kurallardan türetilir, elle tutulmaz. */ +export const SEKTOR_ETIKETLERI = [...new Set(SECTOR_RULES.map(([, ad]) => ad))] + .sort((a, b) => a.localeCompare(b, 'tr')); +export const KURUM_ETIKETLERI = [...new Set(INSTITUTION_RULES.map(([, ad]) => ad))] + .sort((a, b) => a.localeCompare(b, 'tr'));