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"
This commit is contained in:
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* CPV / OKAS ham kod sınıflandırması — YAYINLANMADAN ÖNCE tamamlanması
|
||||||
|
* GEREKEN alan seti (tender.cpv / cpvBolum / cpvLabel / cpvSource).
|
||||||
|
*
|
||||||
|
* siniflandir.mjs'teki OKAS_BOLUM/OKAS_GRUP zaten "sektör etiketi" tahmini
|
||||||
|
* için CPV kodlarını kullanıyordu; bu dosya AYNI sözlükleri kullanarak HAM
|
||||||
|
* kodu da tender nesnesine taşır. Üç kademe, sırayla ve İLKİ eşleşince durur:
|
||||||
|
*
|
||||||
|
* 1) existing — kalem listesinde (tender.items[].kod) zaten gerçek bir
|
||||||
|
* OKAS/CPV kodu varsa (DT / geçmiş sonuç kaynakları) onu
|
||||||
|
* kullan. En güvenilir kaynak: idarenin kendi kodu.
|
||||||
|
* 2) keyword — kalem yoksa başlık/kurum metnine CPV_KEYWORD_RULES
|
||||||
|
* uygulanır. I/O yok, anında sonuç.
|
||||||
|
* 3) ai — ikisi de boşsa (yalnızca ilan.gov.tr canlı akışında
|
||||||
|
* görülür) LLM'e OKAS_BOLUM listesi verilip seçim
|
||||||
|
* yaptırılır; modelin ürettiği kod sözlükte GERÇEKTEN VAR
|
||||||
|
* MI diye doğrulanır, yoksa reddedilir.
|
||||||
|
*
|
||||||
|
* Uydurma kod ilkesi: keyword kuralları ve AI çıktısı OKAS_BOLUM/OKAS_GRUP
|
||||||
|
* dışına ASLA kod üretmez/kabul etmez. "existing" kaynağı buna istisnadır —
|
||||||
|
* idarenin kendi verdiği kod zaten gerçek, bizim sözlüğümüzde etiketi
|
||||||
|
* olmasa bile reddedilmez (yalnızca cpvLabel 'Genel' kalır).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OKAS_BOLUM, OKAS_GRUP, normalizeTr, okasSektor } from './siniflandir.mjs';
|
||||||
|
import { analyzeSpec as defaultAnalyzeSpec } from './ai.mjs';
|
||||||
|
|
||||||
|
function bolumGecerliMi(kod) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(OKAS_BOLUM, kod);
|
||||||
|
}
|
||||||
|
function grupGecerliMi(kod) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(OKAS_GRUP, kod);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1) Kalemlerde zaten var olan OKAS/CPV kodu ----------------------------
|
||||||
|
function existingCpvFromItems(tender) {
|
||||||
|
const items = Array.isArray(tender?.items) ? tender.items : [];
|
||||||
|
const kodlar = [...new Set(
|
||||||
|
items
|
||||||
|
.map((it) => String(it?.kod ?? '').replace(/\D/g, ''))
|
||||||
|
.filter((k) => k.length >= 2)
|
||||||
|
)];
|
||||||
|
if (!kodlar.length) return null;
|
||||||
|
|
||||||
|
const bolumler = [...new Set(kodlar.map((k) => k.slice(0, 2)))];
|
||||||
|
const sayac = new Map();
|
||||||
|
for (const k of kodlar) {
|
||||||
|
const sek = okasSektor(k);
|
||||||
|
if (sek) sayac.set(sek, (sayac.get(sek) || 0) + 1);
|
||||||
|
}
|
||||||
|
const label = sayac.size ? [...sayac.entries()].sort((a, b) => b[1] - a[1])[0][0] : 'Genel';
|
||||||
|
|
||||||
|
return { cpv: kodlar, cpvBolum: bolumler, cpvLabel: label, cpvSource: 'existing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) Anahtar kelime kuralları --------------------------------------------
|
||||||
|
// Her satır: [regex, bolumKod, grupKod?]. regex NORMALLEŞTİRİLMİŞ metne
|
||||||
|
// (normalizeTr) uygulanır. Sıra ÖNEMLİ — dar/özel grup eşleşmeleri önce,
|
||||||
|
// geniş bölüm eşleşmeleri sonra gelir (ilk eşleşen kazanır).
|
||||||
|
// OKAS_BOLUM'un 45 anahtarının TAMAMI için en az bir kural vardır.
|
||||||
|
export const CPV_KEYWORD_RULES = [
|
||||||
|
// --- Grup (4-5 hane) düzeyinde belirgin eşleşmeler ---
|
||||||
|
[/\btemizlik urun|\bdeterjan alim|\bdezenfek(tan)? alim/i, '39', '3983'],
|
||||||
|
[/\bcop konteyner|\batik konteyner/i, '34', '34928'],
|
||||||
|
[/\btuvalet kagidi|\bkagit havlu/i, '33', '3376'],
|
||||||
|
[/\bpaspas|\byer ortusu/i, '39', '3953'],
|
||||||
|
[/\bsupurge|\bfirca alim/i, '39', '3922'],
|
||||||
|
[/\biklimlendirme cihaz|\bklima alim|\bbuzdolabi alim/i, '39', '3971'],
|
||||||
|
[/\btasit onarim|\barac onarim hizmet|\bbakim onarim.{0,15}(arac|tasit|otobus|kamyon)/i, '50', '5012'],
|
||||||
|
[/\bet alim|\bkirmizi et|\bdana\b|\bkuzu\b/i, '03', '0311'],
|
||||||
|
[/\btavuk alim|\bpiliç alim|\bpilic alim/i, '03', '0314'],
|
||||||
|
[/\bsut alim|\bpeynir alim/i, '03', '0331'],
|
||||||
|
[/\bekmek alim|\bunlu mamul/i, '03', '0321'],
|
||||||
|
[/\bsebze alim|\bmeyve alim/i, '03', '0313'],
|
||||||
|
[/\bguvenlik personel|\bozel guvenlik hizmet/i, '79', '7971'],
|
||||||
|
[/\bkamera sistem|\bcctv|\balarm sistem/i, '79', '7972'],
|
||||||
|
[/\bmatbaa hizmet|\bbaski hizmet|\bform basim/i, '79', '7981'],
|
||||||
|
[/\breklam hizmet|\btanitim hizmet/i, '79', '7982'],
|
||||||
|
[/\bfuar\b|\bfestival organizasyon/i, '79', '7995'],
|
||||||
|
[/\bpersonel calistir|\bisci calistir|\bdestek personel hizmet/i, '79', '7962'],
|
||||||
|
[/\boyun grubu|\bcocuk oyun/i, '37', '3753'],
|
||||||
|
[/\bboru\b|\bvana\b/i, '44', '4416'],
|
||||||
|
[/\batik su|\bkanalizasyon aritma/i, '90', '9042'],
|
||||||
|
|
||||||
|
// --- Bölüm (2 hane) düzeyinde genel eşleşmeler ---
|
||||||
|
[/\btarim makine|\btraktor alim|\bbicerdover|\bpulluk/i, '16'],
|
||||||
|
[/\btarim|\btohum|\bgubre|\bfide|\bzirai|\bhayvan|\byem\b|\bveteriner|\bmama\b|\bsera\b|\borman|\bagaclandirma/i, '03'],
|
||||||
|
[/\bpetrol cikarma|\bdogalgaz cikarma|\bmaden cikar/i, '76'],
|
||||||
|
[/\bakaryakit|\bmotorin|\bbenzin|\blpg|\byakit alim|\bodun\b|\bkomur|\bpelet\b/i, '09'],
|
||||||
|
[/\bhafriyat|\btas ocagi|\bkum cakil/i, '14'],
|
||||||
|
[/\bgida|\byemek|\bcatering|\bkumanya|\bbakliyat|\bmutfak|\bgazoz|\bicecek|\bsu alimi|\bcips|\bkuru gida/i, '15'],
|
||||||
|
[/\bderi\b|\bkosele|\bayakkabi|\bbot alim/i, '19'],
|
||||||
|
[/\bgiyim|\btekstil|\belbise|\buniforma|\bkiyafet|\bcamasir|\bkumas|\bbattaniye|\bhavlu\b|\bnevresim/i, '18'],
|
||||||
|
[/\bkirtasiye|\bmatbaa|\bbaski|\bkagit|\btoner|\bkartus|\bfotokopi|\bburo malzeme|\bdefter|\bdosya alim|\bzarf\b|\bbasili evrak|\bklasor/i, '22'],
|
||||||
|
[/\bkimyasal madde|\basit\b|\bsolvent\b/i, '24'],
|
||||||
|
[/\bbilgisayar alim|\byazici alim|\bofis makine|\btablet alim|\bprojeksiyon/i, '30'],
|
||||||
|
[/\belektrik|\btrafo|\benerji|\baydinlatma|\bjenerator|\bgunes|\bsolar|\bkablo|\bpano\b|\bups\b|\barmatur|\bampul|\bled\b/i, '31'],
|
||||||
|
[/\btelsiz|\bhaberlesme cihaz|\bswitch\b|\brouter\b|\bag cihaz/i, '32'],
|
||||||
|
[/\btibbi|\bmedikal|\bsaglik malzeme|\bilac\b|\bhastane malzeme|\bpoliklinik|\bcerrahi|\bameliyat|\bdiyaliz|\bradyoloji|\brontgen|\btomografi|\bultrason|\bsteril|\benjektor|\beldiven|\bmaske|\bpansuman|\bsonda|\bkateter|\bprotez|\bortez|\bdis unit|\bdental|\bimplant|\bhemogram|\bserum|\baspirator|\bnebulizator/i, '33'],
|
||||||
|
[/\bis makinesi|\bgreyder|\bekskavator|\bkepce/i, '43'],
|
||||||
|
// Taşımacılık HİZMETİ, genel taşıt/otomotiv kuralından ÖNCE gelir — aksi
|
||||||
|
// halde "öğrenci taşıma hizmeti" gibi başlıklar salt "araç" kelimesi
|
||||||
|
// yüzünden yanlışlıkla taşıt ALIMI (34) sanılır (bkz. SECTOR_RULES'taki
|
||||||
|
// aynı öncelik: Taşımacılık & Kiralama, Otomotiv & İş Makinesi'nden önce).
|
||||||
|
[/\barac kirala|\btasit kirala|\bservis hizmet|\bpersonel tasima|\btasimali\b|\bogrenci.{0,40}tasi|\btasima merkezi|\btasinmasi isi|\btasima hizmet|\bnakliye/i, '60'],
|
||||||
|
[/\barac\b|\botomotiv|\blastik|\byedek parca|\botobus|\bkamyon|\bbalata|\bamortisor|\baku\b/i, '34'],
|
||||||
|
[/\bguvenlik sistem|\byangin sondur|\byangin tup|\byangin algila|\bitfaiye|\bturnike|\bx-ray/i, '35'],
|
||||||
|
[/\bspor malzeme|\bmuzik alet|\bhali saha/i, '37'],
|
||||||
|
[/\blaboratuvar|\banaliz cihaz|\bolcum cihaz|\btest cihaz|\bmikroskop|\bsantrifuj|\bkantar|\btarti\b|\bterazi|\bkalibrasyon|\bsayac\b/i, '38'],
|
||||||
|
[/\bmobilya|\bmasa\b|\bsandalye|\bdolap|\bokul sirasi|\braf\b|\bperde|\bkoltuk|\byatak\b|\bkaryola/i, '39'],
|
||||||
|
[/\bsu aritma|\baritma tesis|\bpompa|\bsondaj|\bkuyu\b|\bsu deposu|\bklorlama|\bdalgic/i, '41'],
|
||||||
|
[/\bpolisaj|\btestere|\btorna|\bkaynak makinesi|\bmatkap|\batolye|\bel aleti|\bkompresor/i, '42'],
|
||||||
|
[/\byapi malzeme|\btugla\b|\bcimento|\bkereste\b|\bfayans|\bseramik/i, '44'],
|
||||||
|
[/\binsaat|\byapim isi|\bbina\b|\bguclendirme|\bcati\b|\bcephe|\brestorasyon|\byikim|\bizolasyon|\bmantolama|\basfalt|\bkilit parke|\bbordur|\bkaldirim|\bkopru|\baltyapi|\bicme suyu|\bsulama sistem/i, '45'],
|
||||||
|
[/\byazilim|\blisans alim|\buygulama yazilim/i, '48'],
|
||||||
|
[/\bbakim onarim|\btamirat|\btadilat|\bbakim hizmet|\bboya\b|\bbadana|\bdograma|\bpvc pencere/i, '50'],
|
||||||
|
[/\bmontaj hizmet|\bkurulum hizmet/i, '51'],
|
||||||
|
[/\bkonaklama|\botel hizmet|\byemek hizmet/i, '55'],
|
||||||
|
[/\blojistik|\bdepo hizmet/i, '63'],
|
||||||
|
[/\btelekomunikasyon hizmet|\binternet hizmet|\bhat kiralama/i, '64'],
|
||||||
|
[/\belektrik dagitim|\bdogalgaz dagitim/i, '65'],
|
||||||
|
[/\bsigorta|\bzorunlu trafik|\bkasko/i, '66'],
|
||||||
|
[/\btasinmaz|\bparsel|\barsa\b|\bgayrimenkul|\bkiraya veril|\bsatis ihalesi|\becrimisil/i, '70'],
|
||||||
|
[/\bmuhendislik hizmet|\betut proje|\bmimarlik hizmet|\bkontrolluk/i, '71'],
|
||||||
|
[/\bbilisim hizmet|\byazilim gelistirme|\bbilgisayar hizmet/i, '72'],
|
||||||
|
[/\barastirma hizmet|\barge\b|\bar-ge/i, '73'],
|
||||||
|
[/\bidari hizmet|\bkamu yonetimi/i, '75'],
|
||||||
|
[/\bpeyzaj|\bpark bahce|\bagac\b|\bcim\b|\bfidan|\bkent mobilya|\bbank alim|\bcicek/i, '77'],
|
||||||
|
[/\bdanismanlik|\bmusavirlik|\bis hizmet/i, '79'],
|
||||||
|
[/\begitim hizmet|\bkurs\b|\bseminer/i, '80'],
|
||||||
|
[/\bsaglik hizmet|\bhastane hizmet/i, '85'],
|
||||||
|
[/\btemizlik|\bhijyen|\bilaclama|\batik toplama|\bgeri donusum/i, '90'],
|
||||||
|
[/\bkultur hizmet|\beglence hizmet|\bspor hizmet/i, '92'],
|
||||||
|
[/\bsosyal hizmet|\btoplum hizmet/i, '98'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function keywordMatch(tender) {
|
||||||
|
const metin = normalizeTr(`${tender?.baslik || ''} ${tender?.kurum || ''}`);
|
||||||
|
for (const [re, bolum, grup] of CPV_KEYWORD_RULES) {
|
||||||
|
if (!re.test(metin)) continue;
|
||||||
|
if (!bolumGecerliMi(bolum)) continue; // güvenlik: sözlükte olmayan kod asla döndürülmez
|
||||||
|
const grupGecerli = grup && grupGecerliMi(grup);
|
||||||
|
return {
|
||||||
|
cpv: [grupGecerli ? grup : bolum],
|
||||||
|
cpvBolum: [bolum],
|
||||||
|
cpvLabel: grupGecerli ? OKAS_GRUP[grup] : OKAS_BOLUM[bolum],
|
||||||
|
cpvSource: 'keyword',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalemlerde gerçek kod var mı, yoksa başlık/kurum metnine kural uygulanır.
|
||||||
|
* Hiçbiri eşleşmezse null döner (çağıran AI'a düşer).
|
||||||
|
*/
|
||||||
|
export function classifyCpvByKeyword(tender) {
|
||||||
|
return existingCpvFromItems(tender) || keywordMatch(tender);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3) AI ile sınıflandırma -------------------------------------------------
|
||||||
|
const OKAS_BOLUM_LISTESI = Object.entries(OKAS_BOLUM)
|
||||||
|
.map(([kod, etiket]) => `${kod}: ${etiket}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
function cpvSistemPrompt() {
|
||||||
|
return `Sen Türkiye kamu ihaleleri için OKAS/CPV (Ortak Kamu Alımları Sözlüğü / Avrupa CPV) sınıflandırma uzmanısın.
|
||||||
|
Aşağıda GEÇERLİ OKAS bölüm kodları (2 haneli) ve etiketleri listelenmiştir:
|
||||||
|
${OKAS_BOLUM_LISTESI}
|
||||||
|
|
||||||
|
Görevin: verilen ihale başlığı ve idare adına göre bu listeden EN UYGUN bölüm kodunu seçmek.
|
||||||
|
KURALLAR:
|
||||||
|
- Yalnızca yukarıdaki listede TAM OLARAK yer alan bir "cpvBolum" kodu seç. Listede olmayan veya uydurma bir kod ASLA üretme.
|
||||||
|
- Eğer 4-5 haneli daha alt kırılımlı bir OKAS/CPV kodu tahmin edebiliyorsan "cpvGrup" alanına yaz (örn. "4523"); emin değilsen null bırak. cpvGrup, seçtiğin cpvBolum ile aynı iki haneyle BAŞLAMALIDIR.
|
||||||
|
- Hiçbir kod uygun değilse veya emin değilsen "cpvBolum" alanını null yap.
|
||||||
|
SADECE şu JSON şemasında yanıt ver, başka hiçbir açıklama/markdown ekleme:
|
||||||
|
{"cpvBolum": "45" veya null, "cpvGrup": "4523" veya null, "guven": 0.0 ile 1.0 arası sayı}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM'e OKAS_BOLUM listesini verip ihale için en uygun kodu seçtirir.
|
||||||
|
* Modelin ürettiği kod sözlükte gerçekten yoksa reddedilir (cpvSource:'none').
|
||||||
|
*/
|
||||||
|
export async function classifyCpvWithAI(tender, { analyzeSpec, chain } = {}) {
|
||||||
|
const analyze = analyzeSpec || defaultAnalyzeSpec;
|
||||||
|
const none = { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = `İhale başlığı: ${tender?.baslik || '-'}\nİdare: ${tender?.kurum || '-'}`;
|
||||||
|
const { result } = await analyze({
|
||||||
|
text,
|
||||||
|
tender,
|
||||||
|
chain,
|
||||||
|
systemOverride: cpvSistemPrompt(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bolum = result?.cpvBolum != null ? String(result.cpvBolum).replace(/\D/g, '') : '';
|
||||||
|
if (!bolum || !bolumGecerliMi(bolum)) return none;
|
||||||
|
|
||||||
|
const grupHam = result?.cpvGrup != null ? String(result.cpvGrup).replace(/\D/g, '') : '';
|
||||||
|
// grup kodu hem sözlükte gerçekten var olmalı hem de seçilen bölümle tutarlı olmalı
|
||||||
|
const grupGecerli = grupHam && grupGecerliMi(grupHam) && grupHam.slice(0, 2) === bolum;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpv: [grupGecerli ? grupHam : bolum],
|
||||||
|
cpvBolum: [bolum],
|
||||||
|
cpvLabel: grupGecerli ? OKAS_GRUP[grupHam] : OKAS_BOLUM[bolum],
|
||||||
|
cpvSource: 'ai',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tam sınıflandırma akışı: önce ücretsiz/hızlı yollar (kalem kodu, anahtar
|
||||||
|
* kelime), yalnızca ikisi de boşsa AI'a düşer. Kalıcı önbellek (ikn bazlı)
|
||||||
|
* BU FONKSİYONUN DIŞINDA, çağıran tarafta (server/index.js) uygulanır —
|
||||||
|
* böylece aynı ilan iki farklı kaynaktan (ör. arka plan doldurma + ilk
|
||||||
|
* yükleme) geçse bile AI yalnızca bir kez çağrılır.
|
||||||
|
*/
|
||||||
|
export async function classifyTenderCpv(tender, deps = {}) {
|
||||||
|
const hizli = classifyCpvByKeyword(tender);
|
||||||
|
if (hizli) return hizli;
|
||||||
|
return classifyCpvWithAI(tender, deps);
|
||||||
|
}
|
||||||
+11
-1
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { sektorKarar } from './siniflandir.mjs';
|
import { sektorKarar } from './siniflandir.mjs';
|
||||||
|
import { classifyCpvByKeyword } from './cpv-classify.mjs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { titleCaseTr } from './tr-text.mjs';
|
import { titleCaseTr } from './tr-text.mjs';
|
||||||
@@ -85,7 +86,7 @@ function classifyKurum(text) {
|
|||||||
function mapDtRow(row) {
|
function mapDtRow(row) {
|
||||||
const det = parseDetail(row.detail_json);
|
const det = parseDetail(row.detail_json);
|
||||||
const kurum = det.idareTam || row.idare_adi || '';
|
const kurum = det.idareTam || row.idare_adi || '';
|
||||||
return {
|
const tender = {
|
||||||
id: `dt-${row.dogrudan_temin_id}`,
|
id: `dt-${row.dogrudan_temin_id}`,
|
||||||
kaynak: 'EKAP · Doğrudan Temin',
|
kaynak: 'EKAP · Doğrudan Temin',
|
||||||
kaynakUrl: 'https://ekap.kik.gov.tr/EKAP/YeniIhaleArama.aspx',
|
kaynakUrl: 'https://ekap.kik.gov.tr/EKAP/YeniIhaleArama.aspx',
|
||||||
@@ -138,6 +139,15 @@ function mapDtRow(row) {
|
|||||||
sozlesmeBedeli: det.sozlesmeBedeli || null,
|
sozlesmeBedeli: det.sozlesmeBedeli || null,
|
||||||
kazananFirma: det.kazananFirma || null,
|
kazananFirma: det.kazananFirma || null,
|
||||||
};
|
};
|
||||||
|
// CPV/OKAS — YAYINLANMADAN ÖNCE tamamlanır (bkz. cpv-classify.mjs).
|
||||||
|
// BransKodList zaten gerçek OKAS kodu taşır; 'existing' dalı bunu anında
|
||||||
|
// kullanır, AI'a nadiren düşer.
|
||||||
|
const cpv = classifyCpvByKeyword(tender) || { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' };
|
||||||
|
tender.cpv = cpv.cpv;
|
||||||
|
tender.cpvBolum = cpv.cpvBolum;
|
||||||
|
tender.cpvLabel = cpv.cpvLabel;
|
||||||
|
tender.cpvSource = cpv.cpvSource;
|
||||||
|
return tender;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readDtTenders(limit = 300) {
|
export function readDtTenders(limit = 300) {
|
||||||
|
|||||||
+113
-2
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { sektorKarar } from './siniflandir.mjs';
|
import { sektorKarar } from './siniflandir.mjs';
|
||||||
|
import { classifyCpvByKeyword } from './cpv-classify.mjs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { titleCaseTr } from './tr-text.mjs';
|
import { titleCaseTr } from './tr-text.mjs';
|
||||||
@@ -57,6 +58,15 @@ CREATE TABLE IF NOT EXISTS sektor_zengin (
|
|||||||
zaman TEXT
|
zaman TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cpv_cache (
|
||||||
|
ikn TEXT PRIMARY KEY, -- İhale Kayıt Numarası — CPV sınıflandırmasının kalıcı anahtarı
|
||||||
|
cpv TEXT, -- JSON dizi: eşleşen tam CPV/OKAS kodları
|
||||||
|
cpv_bolum TEXT, -- JSON dizi: 2 haneli bölüm kodları
|
||||||
|
cpv_label TEXT, -- birincil gösterim etiketi
|
||||||
|
cpv_source TEXT, -- 'existing' | 'keyword' | 'ai' | 'none'
|
||||||
|
denenme_zamani INTEGER -- epoch ms
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS havuz_olcum (
|
CREATE TABLE IF NOT EXISTS havuz_olcum (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
zaman TEXT NOT NULL,
|
zaman TEXT NOT NULL,
|
||||||
@@ -170,7 +180,8 @@ function okasParse(json, bedel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rowToTender(r) {
|
function rowToTender(r) {
|
||||||
return {
|
const items = okasParse(r.okas_kalemler, r.sozlesme_bedeli);
|
||||||
|
const tender = {
|
||||||
id: `hist-${r.ikn}-${r.kisim}`,
|
id: `hist-${r.ikn}-${r.kisim}`,
|
||||||
kaynak: 'EKAP Sonuç İlanı',
|
kaynak: 'EKAP Sonuç İlanı',
|
||||||
ikn: r.ikn,
|
ikn: r.ikn,
|
||||||
@@ -190,7 +201,7 @@ function rowToTender(r) {
|
|||||||
teklifSayisi: r.teklif_sayisi,
|
teklifSayisi: r.teklif_sayisi,
|
||||||
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
|
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
|
||||||
durum: 'Sonuçlandı',
|
durum: 'Sonuçlandı',
|
||||||
items: okasParse(r.okas_kalemler, r.sozlesme_bedeli),
|
items,
|
||||||
// Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan)
|
// Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan)
|
||||||
eklenmeZamani: 'arşivden',
|
eklenmeZamani: 'arşivden',
|
||||||
isYeni: false,
|
isYeni: false,
|
||||||
@@ -206,6 +217,15 @@ function rowToTender(r) {
|
|||||||
takipte: false,
|
takipte: false,
|
||||||
incelendi: false,
|
incelendi: false,
|
||||||
};
|
};
|
||||||
|
// CPV/OKAS — YAYINLANMADAN ÖNCE tamamlanır (bkz. cpv-classify.mjs). Bu
|
||||||
|
// kaynakta kalemler (items[].kod) OKAS kodunu genelde zaten taşır, bu
|
||||||
|
// yüzden 'existing' dalı çoğunlukla anında sonuç verir; AI'a düşmez.
|
||||||
|
const cpv = classifyCpvByKeyword(tender) || { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' };
|
||||||
|
tender.cpv = cpv.cpv;
|
||||||
|
tender.cpvBolum = cpv.cpvBolum;
|
||||||
|
tender.cpvLabel = cpv.cpvLabel;
|
||||||
|
tender.cpvSource = cpv.cpvSource;
|
||||||
|
return tender;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OKAS kalemlerinden ad listesi — sektör tahminini güçlendirir.
|
// OKAS kalemlerinden ad listesi — sektör tahminini güçlendirir.
|
||||||
@@ -508,3 +528,94 @@ export function sektorZenginSayi() {
|
|||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- CPV / OKAS kalıcı önbelleği --------------------------------------------
|
||||||
|
// AYNI ihale (IKN ile) bir daha ASLA yeniden sınıflandırılmasın — özellikle
|
||||||
|
// AI çağrısı gerektiren durumlarda (maliyet + gecikme). sektor_zengin ile
|
||||||
|
// aynı desen: id → sonuç haritası, toplu okuma/yazma, dosya başına tek
|
||||||
|
// açma/kapama.
|
||||||
|
|
||||||
|
function cpvJsonDizi(ham) {
|
||||||
|
if (!ham) return [];
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(ham);
|
||||||
|
return Array.isArray(v) ? v : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ikn → CPV sonucu haritası. Yalnızca istenen ikn'ler okunur. */
|
||||||
|
export function cpvCacheOkuCoklu(iknler) {
|
||||||
|
const harita = new Map();
|
||||||
|
const gecerli = [...new Set((iknler || []).filter(Boolean))];
|
||||||
|
if (!gecerli.length) return harita;
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return harita;
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < gecerli.length; i += 400) {
|
||||||
|
const parca = gecerli.slice(i, i + 400);
|
||||||
|
const yer = parca.map(() => '?').join(',');
|
||||||
|
const satirlar = db
|
||||||
|
.prepare(`SELECT ikn, cpv, cpv_bolum, cpv_label, cpv_source FROM cpv_cache WHERE ikn IN (${yer})`)
|
||||||
|
.all(...parca);
|
||||||
|
for (const r of satirlar) {
|
||||||
|
harita.set(r.ikn, {
|
||||||
|
cpv: cpvJsonDizi(r.cpv),
|
||||||
|
cpvBolum: cpvJsonDizi(r.cpv_bolum),
|
||||||
|
cpvLabel: r.cpv_label || 'Genel',
|
||||||
|
cpvSource: r.cpv_source || 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return harita;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tek bir ikn için kayıtlı CPV sonucu — yoksa null. */
|
||||||
|
export function cpvCacheOku(ikn) {
|
||||||
|
if (!ikn) return null;
|
||||||
|
return cpvCacheOkuCoklu([ikn]).get(ikn) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toplu yazma: [{ikn, cpv, cpvBolum, cpvLabel, cpvSource}, ...] */
|
||||||
|
export function cpvCacheYazCoklu(kayitlar) {
|
||||||
|
if (!kayitlar?.length) return 0;
|
||||||
|
const db = openDb({ create: true });
|
||||||
|
if (!db) return 0;
|
||||||
|
try {
|
||||||
|
const st = db.prepare(`
|
||||||
|
INSERT INTO cpv_cache (ikn, cpv, cpv_bolum, cpv_label, cpv_source, denenme_zamani)
|
||||||
|
VALUES (?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(ikn) DO UPDATE SET
|
||||||
|
cpv=excluded.cpv, cpv_bolum=excluded.cpv_bolum, cpv_label=excluded.cpv_label,
|
||||||
|
cpv_source=excluded.cpv_source, denenme_zamani=excluded.denenme_zamani
|
||||||
|
`);
|
||||||
|
const now = Date.now();
|
||||||
|
let n = 0;
|
||||||
|
for (const k of kayitlar) {
|
||||||
|
if (!k?.ikn) continue;
|
||||||
|
st.run(
|
||||||
|
k.ikn,
|
||||||
|
JSON.stringify(k.cpv || []),
|
||||||
|
JSON.stringify(k.cpvBolum || []),
|
||||||
|
k.cpvLabel || 'Genel',
|
||||||
|
k.cpvSource || 'none',
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tek bir ikn için CPV sonucunu kaydeder. */
|
||||||
|
export function cpvCacheYaz(ikn, sonuc) {
|
||||||
|
if (!ikn || !sonuc) return false;
|
||||||
|
return cpvCacheYazCoklu([{ ikn, ...sonuc }]) > 0;
|
||||||
|
}
|
||||||
|
|||||||
+50
-4
@@ -17,16 +17,18 @@ import crypto from 'node:crypto';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { readDtTenders } from './dt-store.mjs';
|
import { readDtTenders } from './dt-store.mjs';
|
||||||
import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI, KURUM_ETIKETLERI, ilanKonusu } from './siniflandir.mjs';
|
import { SECTOR_RULES, INSTITUTION_RULES, classify, sektorBul, SEKTOR_ETIKETLERI, KURUM_ETIKETLERI, ilanKonusu, OKAS_BOLUM } from './siniflandir.mjs';
|
||||||
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
import { loginWithGoogle, userFromAuthHeader } from './auth.mjs';
|
||||||
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
|
import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email.mjs';
|
||||||
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
|
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
|
||||||
|
import { classifyTenderCpv } from './cpv-classify.mjs';
|
||||||
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs';
|
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs';
|
||||||
import * as store from './user-store.mjs';
|
import * as store from './user-store.mjs';
|
||||||
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs';
|
||||||
import { searchTed } from './ted.mjs';
|
import { searchTed } from './ted.mjs';
|
||||||
import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet,
|
import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet,
|
||||||
sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi } from './history-store.mjs';
|
sektorZenginOku, sektorZenginYaz, sektorZenginDenenmis, sektorZenginSayi,
|
||||||
|
cpvCacheOku, cpvCacheYaz } from './history-store.mjs';
|
||||||
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
import { startScheduler, schedulerStatus } from './scheduler.mjs';
|
||||||
import { searchKik, kararMetni } from './kik.mjs';
|
import { searchKik, kararMetni } from './kik.mjs';
|
||||||
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
||||||
@@ -262,7 +264,44 @@ async function ilanSayfalari(baslangic, adet) {
|
|||||||
return Promise.all(istekler);
|
return Promise.all(istekler);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ilanlariBirlestir(mevcut, yeniAds) {
|
// --- CPV/OKAS sınıflandırma GATE'i -----------------------------------------
|
||||||
|
// "sektör" alanının aksine (bkz. sektorZenginlestir — önce yayınla, sonra
|
||||||
|
// arka planda zenginleştir), CPV alanı bir ihale cache.tenders'a GİRMEDEN
|
||||||
|
// ÖNCE tamamlanmış olmalı. Bu yüzden bu fonksiyon her zaman ilanlariBirlestir
|
||||||
|
// / getTenders içinde, sonuç diziye eklenmeden HEMEN ÖNCE await'lenir.
|
||||||
|
const CPV_ESZAMANLI = Number(process.env.CPV_ESZAMANLI || 4); // AI'ı yormamak için sınırlı paralellik
|
||||||
|
|
||||||
|
async function cpvUygula(t) {
|
||||||
|
const ikn = t.ikn || '';
|
||||||
|
if (ikn) {
|
||||||
|
const onbellek = cpvCacheOku(ikn);
|
||||||
|
if (onbellek) {
|
||||||
|
Object.assign(t, onbellek);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sonuc;
|
||||||
|
try {
|
||||||
|
sonuc = await classifyTenderCpv(t, { analyzeSpec });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[cpv-classify] ${t.id} sınıflandırılamadı:`, e.message);
|
||||||
|
sonuc = { cpv: [], cpvBolum: [], cpvLabel: 'Genel', cpvSource: 'none' };
|
||||||
|
}
|
||||||
|
Object.assign(t, sonuc);
|
||||||
|
if (ikn) cpvCacheYaz(ikn, sonuc);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir tender listesini, sınırlı paralellikle, CPV işlenmiş hale getirir. */
|
||||||
|
async function cpvUygulaListe(tenders) {
|
||||||
|
for (let i = 0; i < tenders.length; i += CPV_ESZAMANLI) {
|
||||||
|
const grup = tenders.slice(i, i + CPV_ESZAMANLI);
|
||||||
|
await Promise.all(grup.map((t) => cpvUygula(t)));
|
||||||
|
}
|
||||||
|
return tenders;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ilanlariBirlestir(mevcut, yeniAds) {
|
||||||
const seen = new Set(mevcut.map((t) => t.id));
|
const seen = new Set(mevcut.map((t) => t.id));
|
||||||
const eklenen = [];
|
const eklenen = [];
|
||||||
for (const ad of yeniAds) {
|
for (const ad of yeniAds) {
|
||||||
@@ -271,6 +310,9 @@ function ilanlariBirlestir(mevcut, yeniAds) {
|
|||||||
seen.add(t.id);
|
seen.add(t.id);
|
||||||
eklenen.push(t);
|
eklenen.push(t);
|
||||||
}
|
}
|
||||||
|
// GATE: CPV sınıflandırması tamamlanmadan hiçbir ilan çağırana dönmez —
|
||||||
|
// bu dizi doğrudan arkaPlandaTamamla() içinde cache.tenders'a eklenir.
|
||||||
|
await cpvUygulaListe(eklenen);
|
||||||
return eklenen;
|
return eklenen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +331,7 @@ async function arkaPlandaTamamla(numFound, ilkListe) {
|
|||||||
const sonuclar = await ilanSayfalari(page, grup);
|
const sonuclar = await ilanSayfalari(page, grup);
|
||||||
const ads = sonuclar.flatMap((r) => r.ads || []);
|
const ads = sonuclar.flatMap((r) => r.ads || []);
|
||||||
if (!ads.length) break;
|
if (!ads.length) break;
|
||||||
const eklenen = ilanlariBirlestir(yeni, ads);
|
const eklenen = await ilanlariBirlestir(yeni, ads);
|
||||||
if (eklenen.length) yeni.push(...eklenen);
|
if (eklenen.length) yeni.push(...eklenen);
|
||||||
page += grup;
|
page += grup;
|
||||||
await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için
|
await new Promise((r) => setTimeout(r, 120)); // kaynağı yormamak için
|
||||||
@@ -420,6 +462,9 @@ async function getTenders(force = false) {
|
|||||||
const ilkListe = ads
|
const ilkListe = ads
|
||||||
.filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id)))
|
.filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id)))
|
||||||
.map(mapAd);
|
.map(mapAd);
|
||||||
|
// GATE: CPV sınıflandırması tamamlanmadan bu liste ne hemen servis edilir
|
||||||
|
// ne de arkaPlandaTamamla()'nın temeli olur (bkz. cpvUygula yorumu).
|
||||||
|
await cpvUygulaListe(ilkListe);
|
||||||
|
|
||||||
cache.totalCount = numFound || ilkListe.length;
|
cache.totalCount = numFound || ilkListe.length;
|
||||||
cache.fetchedAt = Date.now();
|
cache.fetchedAt = Date.now();
|
||||||
@@ -1027,6 +1072,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
success: true,
|
success: true,
|
||||||
sektorler: SEKTOR_ETIKETLERI,
|
sektorler: SEKTOR_ETIKETLERI,
|
||||||
kurumTipleri: KURUM_ETIKETLERI,
|
kurumTipleri: KURUM_ETIKETLERI,
|
||||||
|
cpvBolumler: Object.entries(OKAS_BOLUM).map(([kod, etiket]) => ({ kod, etiket })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function ilanKonusu(metin) {
|
|||||||
// temizlik ürünleri, 79 = hem güvenlik hem matbaa hem danışmanlık); onlar için
|
// 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.
|
// daha uzun ön ek tanımlanır ve uzun olan kazanır.
|
||||||
|
|
||||||
const OKAS_GRUP = {
|
export const OKAS_GRUP = {
|
||||||
// 39 — mobilya / ev aletleri / temizlik ürünleri aynı bölümde
|
// 39 — mobilya / ev aletleri / temizlik ürünleri aynı bölümde
|
||||||
'3971': 'Isıtma & İklimlendirme', // elektrikli ev aletleri (buzdolabı, klima)
|
'3971': 'Isıtma & İklimlendirme', // elektrikli ev aletleri (buzdolabı, klima)
|
||||||
'3922': 'Temizlik & Hijyen', // süpürge, fırça, temizlik gereçleri
|
'3922': 'Temizlik & Hijyen', // süpürge, fırça, temizlik gereçleri
|
||||||
@@ -158,7 +158,7 @@ const OKAS_GRUP = {
|
|||||||
'9042': 'Su & Arıtma',
|
'9042': 'Su & Arıtma',
|
||||||
};
|
};
|
||||||
|
|
||||||
const OKAS_BOLUM = {
|
export const OKAS_BOLUM = {
|
||||||
'03': 'Tarım & Hayvancılık',
|
'03': 'Tarım & Hayvancılık',
|
||||||
'09': 'Akaryakıt & Yakıt',
|
'09': 'Akaryakıt & Yakıt',
|
||||||
'14': 'İnşaat & Bakım',
|
'14': 'İnşaat & Bakım',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ArrowRight, Bell, Building2, CalendarDays, ChartNoAxesCombined, CheckCircle2,
|
ArrowRight, Bell, Building2, CalendarDays, ChartNoAxesCombined, CheckCircle2,
|
||||||
FileSearch, Gavel, Kanban, MapPin, Radar, Sparkles, Timer, TrendingUp, Zap,
|
FileSearch, Gavel, Kanban, MapPin, Radar, Sparkles, Tag, Timer, TrendingUp, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/* ---------- yardımcılar ---------- */
|
/* ---------- yardımcılar ---------- */
|
||||||
@@ -62,6 +62,7 @@ const OZELLIKLER = [
|
|||||||
{ icon: Radar, baslik: 'Rakip radarı', metin: 'Hangi firma hangi ihaleyi, hangi bedelle kazandı? Rakiplerinizin izini sürün, stratejinizi veriyle kurun.', renk: 'from-rose-400/20 to-rose-400/0', ikon: 'text-rose-300' },
|
{ icon: Radar, baslik: 'Rakip radarı', metin: 'Hangi firma hangi ihaleyi, hangi bedelle kazandı? Rakiplerinizin izini sürün, stratejinizi veriyle kurun.', renk: 'from-rose-400/20 to-rose-400/0', ikon: 'text-rose-300' },
|
||||||
{ icon: TrendingUp, baslik: 'Fiyat & piyasa analizi', metin: 'Geçmiş sonuçlardan yaklaşık maliyet ve kırım oranı analizi. Teklifinizi tahminle değil, veriyle verin.', renk: 'from-emerald-400/20 to-emerald-400/0', ikon: 'text-emerald-300' },
|
{ icon: TrendingUp, baslik: 'Fiyat & piyasa analizi', metin: 'Geçmiş sonuçlardan yaklaşık maliyet ve kırım oranı analizi. Teklifinizi tahminle değil, veriyle verin.', renk: 'from-emerald-400/20 to-emerald-400/0', ikon: 'text-emerald-300' },
|
||||||
{ icon: Bell, baslik: 'Günlük bülten & uyarılar', metin: 'Kriterlerinize uyan yeni ilanlar her sabah e-postanızda. Son teklif tarihi yaklaşınca otomatik hatırlatma.', renk: 'from-sky-400/20 to-sky-400/0', ikon: 'text-sky-300' },
|
{ icon: Bell, baslik: 'Günlük bülten & uyarılar', metin: 'Kriterlerinize uyan yeni ilanlar her sabah e-postanızda. Son teklif tarihi yaklaşınca otomatik hatırlatma.', renk: 'from-sky-400/20 to-sky-400/0', ikon: 'text-sky-300' },
|
||||||
|
{ icon: Tag, baslik: 'AB uyumlu CPV sınıflandırması', metin: 'Türkiye\'de bir ilk: her ihale, Avrupa Birliği standardı CPV koduna göre otomatik sınıflandırılır. Sektörünüze uyan ihaleleri AB standardında filtreleyin.', renk: 'from-teal-400/20 to-teal-400/0', ikon: 'text-teal-300' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ---------- bileşenler ---------- */
|
/* ---------- bileşenler ---------- */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useApp } from '../context/AppContext';
|
|||||||
import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
|
import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants';
|
||||||
import {
|
import {
|
||||||
Compass, Zap, Wrench, Calendar, Eye, EyeOff, FileText, CheckCircle2, XCircle, Archive,
|
Compass, Zap, Wrench, Calendar, Eye, EyeOff, FileText, CheckCircle2, XCircle, Archive,
|
||||||
Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star
|
Bot, PlusCircle, Trash2, ChevronDown, ChevronUp, Filter, MapPin, Building2, Tag, Star, Hash
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const LeftSidebar = () => {
|
export const LeftSidebar = () => {
|
||||||
@@ -20,6 +20,10 @@ export const LeftSidebar = () => {
|
|||||||
setSelectedSektorler,
|
setSelectedSektorler,
|
||||||
toggleSektor,
|
toggleSektor,
|
||||||
sektorListesi,
|
sektorListesi,
|
||||||
|
selectedCpvler,
|
||||||
|
setSelectedCpvler,
|
||||||
|
toggleCpv,
|
||||||
|
cpvBolumListesi,
|
||||||
selectedKurumTipi,
|
selectedKurumTipi,
|
||||||
setSelectedKurumTipi,
|
setSelectedKurumTipi,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
@@ -28,9 +32,11 @@ export const LeftSidebar = () => {
|
|||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const [sektorAra, setSektorAra] = useState('');
|
const [sektorAra, setSektorAra] = useState('');
|
||||||
|
const [cpvAra, setCpvAra] = useState('');
|
||||||
const [openSections, setOpenSections] = useState({
|
const [openSections, setOpenSections] = useState({
|
||||||
alimTuru: true,
|
alimTuru: true,
|
||||||
sektorler: true,
|
sektorler: true,
|
||||||
|
cpv: false,
|
||||||
kurumTipleri: false,
|
kurumTipleri: false,
|
||||||
iller: false
|
iller: false
|
||||||
});
|
});
|
||||||
@@ -62,7 +68,7 @@ export const LeftSidebar = () => {
|
|||||||
<span className="font-bold text-xs uppercase tracking-wider text-slate-700 flex items-center gap-1.5">
|
<span className="font-bold text-xs uppercase tracking-wider text-slate-700 flex items-center gap-1.5">
|
||||||
<Compass className="w-4 h-4 text-blue-600" /> Hızlı Erişim
|
<Compass className="w-4 h-4 text-blue-600" /> Hızlı Erişim
|
||||||
</span>
|
</span>
|
||||||
{(quickFilter !== 'all' || selectedSektorler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && (
|
{(quickFilter !== 'all' || selectedSektorler.length || selectedCpvler.length || selectedCity || selectedAlimTuru || selectedKurumTipi) && (
|
||||||
<button
|
<button
|
||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
className="text-[11px] font-medium text-blue-600 hover:underline"
|
className="text-[11px] font-medium text-blue-600 hover:underline"
|
||||||
@@ -242,6 +248,60 @@ export const LeftSidebar = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CPV Kodu */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('cpv')}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between text-xs font-bold text-slate-800 bg-slate-50/50 hover:bg-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hash className="w-4 h-4 text-blue-600" /> CPV Kodu
|
||||||
|
</span>
|
||||||
|
{openSections.cpv ? <ChevronUp className="w-4 h-4 text-slate-400" /> : <ChevronDown className="w-4 h-4 text-slate-400" />}
|
||||||
|
</button>
|
||||||
|
{openSections.cpv && (
|
||||||
|
<div className="p-3 space-y-1 text-xs">
|
||||||
|
<p className="text-[11px] text-slate-500 mb-2 leading-relaxed">
|
||||||
|
CPV, AB'nin ihale konusunu sınıflandırdığı standart koddur. Türkiye'deki NACE/faaliyet kodunun yerine geçmez — NACE firmanızı, CPV ihalenin konusunu sınıflandırır.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
value={cpvAra}
|
||||||
|
onChange={(e) => setCpvAra(e.target.value)}
|
||||||
|
placeholder="CPV ara…"
|
||||||
|
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 mb-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500/30"
|
||||||
|
/>
|
||||||
|
<div className="max-h-56 overflow-y-auto space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedCpvler([]); setActiveTab('list'); }}
|
||||||
|
className={`w-full text-left px-2 py-1 rounded font-medium ${!selectedCpvler.length ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
Tüm CPV Kodları {selectedCpvler.length > 0 && <span className="text-slate-400">({selectedCpvler.length} seçili)</span>}
|
||||||
|
</button>
|
||||||
|
{/* Çoklu seçim: kutucuklar birikir, liste kapanmaz */}
|
||||||
|
{cpvBolumListesi
|
||||||
|
.filter((c) => c.kod.includes(cpvAra) || c.etiket.toLocaleLowerCase('tr').includes(cpvAra.toLocaleLowerCase('tr')))
|
||||||
|
.map(c => {
|
||||||
|
const secili = selectedCpvler.includes(c.kod);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={c.kod}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded cursor-pointer font-medium ${secili ? 'bg-blue-50 text-blue-700 font-bold' : 'text-slate-600 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={secili}
|
||||||
|
onChange={() => { toggleCpv(c.kod); setActiveTab('list'); }}
|
||||||
|
className="accent-blue-600 shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{c.kod} — {c.etiket}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Kurum Tipleri */}
|
{/* Kurum Tipleri */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export const TenderCard = ({ tender }) => {
|
|||||||
<span className="bg-slate-100 text-slate-700 font-medium text-[11px] px-2.5 py-1 rounded-md border border-slate-200">
|
<span className="bg-slate-100 text-slate-700 font-medium text-[11px] px-2.5 py-1 rounded-md border border-slate-200">
|
||||||
{tender.sektor}
|
{tender.sektor}
|
||||||
</span>
|
</span>
|
||||||
|
{tender.cpvLabel && tender.cpvLabel !== 'Genel' && (
|
||||||
|
<span className="bg-sky-50 text-sky-700 font-medium text-[11px] px-2.5 py-1 rounded-md border border-sky-200">
|
||||||
|
{tender.cpvLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{tender.aciliyetBadge && (
|
{tender.aciliyetBadge && (
|
||||||
<span className="bg-red-500 text-white font-extrabold text-[11px] px-2 py-1 rounded-md flex items-center gap-1 shadow-2xs">
|
<span className="bg-red-500 text-white font-extrabold text-[11px] px-2 py-1 rounded-md flex items-center gap-1 shadow-2xs">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const TenderList = () => {
|
|||||||
selectedCity,
|
selectedCity,
|
||||||
selectedAlimTuru,
|
selectedAlimTuru,
|
||||||
selectedSektorler,
|
selectedSektorler,
|
||||||
|
selectedCpvler,
|
||||||
selectedKurumTipi,
|
selectedKurumTipi,
|
||||||
quickFilter,
|
quickFilter,
|
||||||
onlyValidOnly,
|
onlyValidOnly,
|
||||||
@@ -53,6 +54,7 @@ export const TenderList = () => {
|
|||||||
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
|
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
|
||||||
if (selectedAlimTuru && tender.alimTuru !== selectedAlimTuru) return false;
|
if (selectedAlimTuru && tender.alimTuru !== selectedAlimTuru) return false;
|
||||||
if (selectedSektorler.length && !selectedSektorler.includes(tender.sektor)) return false;
|
if (selectedSektorler.length && !selectedSektorler.includes(tender.sektor)) return false;
|
||||||
|
if (selectedCpvler.length && !(tender.cpvBolum || []).some(b => selectedCpvler.includes(b))) return false;
|
||||||
if (selectedKurumTipi && tender.kurumTipi !== selectedKurumTipi) return false;
|
if (selectedKurumTipi && tender.kurumTipi !== selectedKurumTipi) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -62,7 +64,7 @@ export const TenderList = () => {
|
|||||||
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedKurumTipi, quickFilter, sortBy]);
|
}, [tenders, searchQuery, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredTenders.length / itemsPerPage));
|
const totalPages = Math.max(1, Math.ceil(filteredTenders.length / itemsPerPage));
|
||||||
const paginatedTenders = filteredTenders.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
const paginatedTenders = filteredTenders.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
|
||||||
@@ -70,7 +72,7 @@ export const TenderList = () => {
|
|||||||
const handleExportExcel = () => {
|
const handleExportExcel = () => {
|
||||||
confetti({ particleCount: 80, spread: 60, origin: { y: 0.6 } });
|
confetti({ particleCount: 80, spread: 60, origin: { y: 0.6 } });
|
||||||
|
|
||||||
const headers = ['IKN', 'Baslik', 'Kurum', 'Sehir', 'Tarih', 'Alim Turu', 'Sektor'];
|
const headers = ['IKN', 'Baslik', 'Kurum', 'Sehir', 'Tarih', 'Alim Turu', 'Sektor', 'CPV'];
|
||||||
const rows = filteredTenders.map(t => [
|
const rows = filteredTenders.map(t => [
|
||||||
t.ikn,
|
t.ikn,
|
||||||
`"${t.baslik.replace(/"/g, '""')}"`,
|
`"${t.baslik.replace(/"/g, '""')}"`,
|
||||||
@@ -78,7 +80,8 @@ export const TenderList = () => {
|
|||||||
t.sehir,
|
t.sehir,
|
||||||
t.tarih,
|
t.tarih,
|
||||||
t.alimTuru,
|
t.alimTuru,
|
||||||
t.sektor
|
t.sektor,
|
||||||
|
`"${(t.cpvLabel || (t.cpv || []).join(', ') || '').replace(/"/g, '""')}"`
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
const csvContent = 'data:text/csv;charset=utf-8,\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||||
|
|||||||
@@ -60,15 +60,23 @@ export const AppProvider = ({ children }) => {
|
|||||||
const toggleSektor = (sek) =>
|
const toggleSektor = (sek) =>
|
||||||
setSelectedSektorler((prev) => (prev.includes(sek) ? prev.filter((s) => s !== sek) : [...prev, sek]));
|
setSelectedSektorler((prev) => (prev.includes(sek) ? prev.filter((s) => s !== sek) : [...prev, sek]));
|
||||||
|
|
||||||
|
// CPV filtresi (bölüm kodu bazlı, çoklu seçim) — sektör deseniyle birebir aynı.
|
||||||
|
const [selectedCpvler, setSelectedCpvler] = useState([]);
|
||||||
|
const toggleCpv = (kod) =>
|
||||||
|
setSelectedCpvler((prev) => (prev.includes(kod) ? prev.filter((k) => k !== kod) : [...prev, kod]));
|
||||||
|
|
||||||
// Sektör/kurum listeleri sunucudan gelir; sabit liste yalnızca yedektir.
|
// Sektör/kurum listeleri sunucudan gelir; sabit liste yalnızca yedektir.
|
||||||
const [sektorListesi, setSektorListesi] = useState(SECTORS_LIST);
|
const [sektorListesi, setSektorListesi] = useState(SECTORS_LIST);
|
||||||
const [kurumTipiListesi, setKurumTipiListesi] = useState(INSTITUTION_TYPES_LIST);
|
const [kurumTipiListesi, setKurumTipiListesi] = useState(INSTITUTION_TYPES_LIST);
|
||||||
|
// CPV bölüm listesi için statik bir yedek yok — yalnızca sunucudan gelir.
|
||||||
|
const [cpvBolumListesi, setCpvBolumListesi] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/meta/filtreler')
|
fetch('/api/meta/filtreler')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (d?.sektorler?.length) setSektorListesi(d.sektorler);
|
if (d?.sektorler?.length) setSektorListesi(d.sektorler);
|
||||||
if (d?.kurumTipleri?.length) setKurumTipiListesi(d.kurumTipleri);
|
if (d?.kurumTipleri?.length) setKurumTipiListesi(d.kurumTipleri);
|
||||||
|
if (d?.cpvBolumler?.length) setCpvBolumListesi(d.cpvBolumler);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -468,6 +476,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
setSelectedAlimTuru('');
|
setSelectedAlimTuru('');
|
||||||
setSelectedKurumTipi('');
|
setSelectedKurumTipi('');
|
||||||
setSelectedSektorler([]);
|
setSelectedSektorler([]);
|
||||||
|
setSelectedCpvler([]);
|
||||||
setQuickFilter('all');
|
setQuickFilter('all');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,6 +514,10 @@ export const AppProvider = ({ children }) => {
|
|||||||
toggleSektor,
|
toggleSektor,
|
||||||
sektorListesi,
|
sektorListesi,
|
||||||
kurumTipiListesi,
|
kurumTipiListesi,
|
||||||
|
selectedCpvler,
|
||||||
|
setSelectedCpvler,
|
||||||
|
toggleCpv,
|
||||||
|
cpvBolumListesi,
|
||||||
onlyValidOnly,
|
onlyValidOnly,
|
||||||
setOnlyValidOnly,
|
setOnlyValidOnly,
|
||||||
quickFilter,
|
quickFilter,
|
||||||
|
|||||||
Reference in New Issue
Block a user