/** * Geçmiş ihale sonuçları deposu (SQLite). * ekap-scan.mjs doldurur; server/index.js okur. * * Kalıcı anahtar: IKN (EKAP'ın döndürdüğü `id` per-response token'dır, saklanmaz). */ 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'; let DatabaseSync = null; try { ({ DatabaseSync } = await import('node:sqlite')); } catch { console.warn('[history-store] node:sqlite yok — geçmiş veri deposu devre dışı'); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.join(__dirname, 'data'); const DB_PATH = path.join(DATA_DIR, 'history.sqlite'); const SCHEMA = ` CREATE TABLE IF NOT EXISTS sonuclar ( ikn TEXT NOT NULL, kisim INTEGER NOT NULL DEFAULT 0, ihale_adi TEXT, idare_adi TEXT, il TEXT, ihale_tipi TEXT, ihale_tarihi TEXT, ilan_tarihi TEXT, kazanan_firma TEXT, kazanan_norm TEXT, kazanan_adres TEXT, sozlesme_bedeli REAL, para_birimi TEXT, sozlesme_tarihi TEXT, yaklasik_maliyet REAL, teklif_sayisi INTEGER, gecerli_teklif_sayisi INTEGER, okas_kalemler TEXT, cekilme_zamani TEXT, PRIMARY KEY (ikn, kisim) ); CREATE INDEX IF NOT EXISTS idx_kazanan ON sonuclar(kazanan_norm); CREATE INDEX IF NOT EXISTS idx_idare ON sonuclar(idare_adi); CREATE INDEX IF NOT EXISTS idx_il ON sonuclar(il); CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi); CREATE TABLE IF NOT EXISTS havuz_olcum ( id INTEGER PRIMARY KEY AUTOINCREMENT, zaman TEXT NOT NULL, kaynak_toplam INTEGER, -- ilan.gov.tr'nin bildirdiği numFound yuklenen INTEGER, -- bizim havuzumuza giren kayıt eksik INTEGER, -- kaynak_toplam - yuklenen dt INTEGER, -- doğrudan temin kaydı arsiv INTEGER -- geçmiş sonuç arşivi ); CREATE INDEX IF NOT EXISTS idx_havuz_zaman ON havuz_olcum(zaman); CREATE TABLE IF NOT EXISTS tarama_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, baslangic TEXT, bitis TEXT, aralik_bas TEXT, aralik_son TEXT, taranan INTEGER, eklenen INTEGER, hata INTEGER, not_ TEXT ); `; export function available() { return Boolean(DatabaseSync); } export function openDb({ create = false } = {}) { if (!DatabaseSync) return null; if (!fs.existsSync(DB_PATH)) { if (!create) return null; fs.mkdirSync(DATA_DIR, { recursive: true }); } const db = new DatabaseSync(DB_PATH); db.exec(SCHEMA); // Geçiş: eski şemada okas_kalemler yoksa ekle try { const kolonlar = db.prepare('PRAGMA table_info(sonuclar)').all().map((c) => c.name); if (!kolonlar.includes('okas_kalemler')) { db.exec('ALTER TABLE sonuclar ADD COLUMN okas_kalemler TEXT'); console.log('[history-store] okas_kalemler kolonu eklendi'); } } catch (e) { console.warn('[history-store] şema geçişi atlandı:', e.message); } return db; } export function saveResults(db, tender, results) { const stmt = db.prepare(` INSERT INTO sonuclar (ikn,kisim,ihale_adi,idare_adi,il,ihale_tipi,ihale_tarihi,ilan_tarihi, kazanan_firma,kazanan_norm,kazanan_adres,sozlesme_bedeli,para_birimi,sozlesme_tarihi, yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,okas_kalemler,cekilme_zamani) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(ikn,kisim) DO UPDATE SET kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm, sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet, teklif_sayisi=excluded.teklif_sayisi, gecerli_teklif_sayisi=excluded.gecerli_teklif_sayisi, okas_kalemler=COALESCE(excluded.okas_kalemler, sonuclar.okas_kalemler), cekilme_zamani=excluded.cekilme_zamani `); const now = new Date().toISOString(); let n = 0; for (const r of results) { stmt.run( r.ikn || tender.ikn, r.kisimIndex ?? 0, tender.ihaleAdi || null, tender.idareAdi || null, tender.ihaleIlAdi || null, tender.ihaleTipAciklama || null, tender.ihaleTarihSaat || null, r.ilanTarihi || null, r.kazananFirma || null, r.kazananNorm || null, r.kazananAdres || null, r.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null, r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null, tender.okasJson ?? null, now ); n++; } return n; } export function hasIkn(db, ikn) { const row = db.prepare('SELECT 1 FROM sonuclar WHERE ikn=? LIMIT 1').get(ikn); return Boolean(row); } // --- Okuma tarafı (API için) -------------------------------------------- // OKAS kalem listesi JSON'unu tender.items biçimine çevirir. // NOT: teklif birim fiyatları kamuya açık DEĞİL. Burada üretilen 'birimFiyat', // sözleşme bedelinin kalem sayısına bölünmesiyle elde edilen YAKLAŞIK bir // göstergedir; kesin birim fiyat değildir. function okasParse(json, bedel) { if (!json) return []; try { const list = JSON.parse(json); if (!Array.isArray(list) || !list.length) return []; const pay = bedel != null && list.length ? Number(bedel) / list.length : null; return list.map((k, i) => ({ id: `okas-${i}`, kod: k.kodu || null, ad: k.adi || k.koduAdi || 'Kalem', miktar: null, birim: null, birimFiyat: pay, yaklasik: true, })); } catch { return []; } } function rowToTender(r) { return { id: `hist-${r.ikn}-${r.kisim}`, kaynak: 'EKAP Sonuç İlanı', ikn: r.ikn, baslik: r.ihale_adi || 'İhale', kurum: r.idare_adi || '—', sehir: titleCaseTr(r.il), tarih: r.ihale_tarihi || '', alimTuru: r.ihale_tipi || 'İhale', // 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, sozlesmeTarihi: r.sozlesme_tarihi, yaklasikMaliyet: r.yaklasik_maliyet, teklifSayisi: r.teklif_sayisi, gecerliTeklifSayisi: r.gecerli_teklif_sayisi, durum: 'Sonuçlandı', items: okasParse(r.okas_kalemler, r.sozlesme_bedeli), // Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan) eklenmeZamani: 'arşivden', isYeni: false, aciliyetBadge: null, usul: r.ihale_tipi || 'İhale', kurumTipi: '', brans: '', bolge: '', aciklama: '', yeterlikKriterleri: [], kanbanStatus: null, atananPersonel: null, takipte: false, incelendi: false, }; } // 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 }; try { const where = []; const args = []; if (firma) { where.push('kazanan_norm LIKE ?'); args.push(`%${firma}%`); } if (idare) { where.push('idare_adi LIKE ?'); args.push(`%${idare}%`); } if (il) { where.push('il LIKE ?'); args.push(`%${il}%`); } const w = where.length ? `WHERE ${where.join(' AND ')}` : ''; const total = db.prepare(`SELECT COUNT(*) c FROM sonuclar ${w}`).all(...args)[0]?.c ?? 0; const rows = db.prepare(`SELECT * FROM sonuclar ${w} ORDER BY cekilme_zamani DESC LIMIT ?`).all(...args, limit); return { tenders: rows.map(rowToTender), totalCount: total, available: true }; } catch (e) { console.warn('[history-store] okuma hatası:', e.message); return { tenders: [], totalCount: 0, available: false }; } finally { db.close(); } } export function historyStats() { const db = openDb(); if (!db) return { available: false }; try { const c = (q) => db.prepare(q).all()[0] || {}; const toplam = c('SELECT COUNT(*) v FROM sonuclar').v ?? 0; const firma = c('SELECT COUNT(DISTINCT kazanan_norm) v FROM sonuclar WHERE kazanan_norm IS NOT NULL').v ?? 0; const idare = c('SELECT COUNT(DISTINCT idare_adi) v FROM sonuclar').v ?? 0; const hacim = c('SELECT SUM(sozlesme_bedeli) v FROM sonuclar').v ?? 0; const tekTeklif = c('SELECT COUNT(*) v FROM sonuclar WHERE gecerli_teklif_sayisi=1').v ?? 0; const sonTarama = db.prepare('SELECT * FROM tarama_log ORDER BY id DESC LIMIT 1').all()[0] || null; return { available: true, toplam, firma, idare, hacim, tekTeklif, sonTarama }; } catch { return { available: false }; } finally { db.close(); } } export function logScan(db, row) { db.prepare(`INSERT INTO tarama_log (baslangic,bitis,aralik_bas,aralik_son,taranan,eklenen,hata,not_) VALUES (?,?,?,?,?,?,?,?)`) .run(row.baslangic, row.bitis, row.aralikBas, row.aralikSon, row.taranan, row.eklenen, row.hata, row.not || null); } // --- Havuz izleme ---------------------------------------------------------- // Havuz büyüklüğü kaynağa (ilan.gov.tr) bağlı ve gün içinde ciddi dalgalanıyor. // Ölçümleri saklarız ki "veri mi kayboldu, kaynak mı düştü" sorusu bir daha // tahmine kalmasın. /** Bir ölçüm kaydeder ve önceki ölçüme göre ani düşüş olup olmadığını döndürür. */ export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) { const db = openDb({ create: true }); if (!db) return null; try { const onceki = db .prepare('SELECT zaman, kaynak_toplam, yuklenen FROM havuz_olcum ORDER BY id DESC LIMIT 1') .get() || null; // 7 günlük referans — tek bir ölçüme değil, ortalamaya kıyaslarız ki // havuz yavaş yavaş erirse de yakalayalım. const ortalamaAl = (gun) => { const bas = new Date(Date.now() - gun * 864e5).toISOString(); return db .prepare(`SELECT AVG(kaynak_toplam) ort, COUNT(kaynak_toplam) adet FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`) .get(bas) || {}; }; // 7 gün: günlük dalgalanma. 30 gün: kalıcı erime — 7 günlük ortalama // düşüşe ayak uydurup alarmı susturduğunda bunu ancak 30 gün yakalar. const ref = ortalamaAl(7); const ref30 = ortalamaAl(30); db.prepare( `INSERT INTO havuz_olcum (zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv) VALUES (?,?,?,?,?,?)` ).run( new Date().toISOString(), kaynakToplam ?? null, yuklenen ?? null, kaynakToplam != null && yuklenen != null ? kaynakToplam - yuklenen : null, dt, arsiv ); // 90 günden eski ölçümleri at — bu tablo sınırsız büyümemeli. const sinir = new Date(Date.now() - 90 * 864e5).toISOString(); db.prepare('DELETE FROM havuz_olcum WHERE zaman < ?').run(sinir); return degerlendir(onceki, ref, ref30, { kaynakToplam, yuklenen }); } finally { db.close(); } } // Ortalamanın anlamlı olması için gereken en az ölçüm sayısı. const MIN_OLCUM = 5; // Bu oranın altına düşerse uyarılır (7 günlük ortalamanın %60'ı). const DUSUS_ESIGI = 0.6; // 30 günlük referans için: daha çok ölçüm ister, eşiği daha toleranslıdır — // amaç ani dalgalanma değil, kalıcı erimeyi görmek. const MIN_OLCUM_30 = 20; const DUSUS_ESIGI_30 = 0.7; /** * Ölçümü 7 GÜNLÜK ORTALAMAYA kıyaslar; yeterli geçmiş yoksa bir önceki * ölçüme düşer. Uyarı gerekiyorsa açıklamasını döndürür. */ function degerlendir(onceki, ref, ref30, { kaynakToplam, yuklenen }) { const ortalama = ref?.adet >= MIN_OLCUM && ref.ort ? Math.round(ref.ort) : null; const ortalama30 = ref30?.adet >= MIN_OLCUM_30 && ref30.ort ? Math.round(ref30.ort) : null; const sonuc = { uyari: null, onceki: onceki?.kaynak_toplam ?? null, simdi: kaynakToplam ?? null, haftalikOrtalama: ortalama, aylikOrtalama: ortalama30, olcumAdedi: ref?.adet ?? 0, olcumAdedi30: ref30?.adet ?? 0, }; // 1) Kaynak düştü mü? (bizim hatamız değil ama bilmek isteriz) if (kaynakToplam != null) { if (ortalama > 0) { const oran = kaynakToplam / ortalama; sonuc.ortalamayaOran = Number(oran.toFixed(3)); if (oran < DUSUS_ESIGI) { sonuc.uyari = `Kaynak havuzu 7 günlük ortalamanın %${Math.round((1 - oran) * 100)} altında: ` + `ortalama ${ortalama}, şimdi ${kaynakToplam} ilan (ilan.gov.tr tarafı).`; sonuc.tur = 'kaynak_dusus'; } } else if (onceki?.kaynak_toplam > 0) { // Henüz ortalama için yeterli ölçüm yok — geçici olarak son ölçüme bakarız. const oran = kaynakToplam / onceki.kaynak_toplam; if (oran < 0.5) { sonuc.uyari = `Kaynak havuzu %${Math.round((1 - oran) * 100)} düştü: ` + `${onceki.kaynak_toplam} → ${kaynakToplam} ilan (ilan.gov.tr tarafı). ` + `[ortalama için henüz ${ref?.adet ?? 0}/${MIN_OLCUM} ölçüm var]`; sonuc.tur = 'kaynak_dusus'; } } } // 1b) Kalıcı erime: 7 günlük ortalama düşüşe ayak uydurduğu için sustuysa // bile, 30 günlük referans hâlâ çok yukarıdaysa bunu ayrıca bildiririz. if (kaynakToplam != null && ortalama30 > 0) { const oran30 = kaynakToplam / ortalama30; sonuc.aylikOrtalamayaOran = Number(oran30.toFixed(3)); if (oran30 < DUSUS_ESIGI_30 && !sonuc.uyari) { sonuc.uyari = `Kaynak havuzu 30 günlük ortalamanın %${Math.round((1 - oran30) * 100)} altında ` + `(kalıcı düşüş): ortalama ${ortalama30}, şimdi ${kaynakToplam} ilan.`; sonuc.tur = 'kalici_dusus'; } else if (oran30 < DUSUS_ESIGI_30 && sonuc.uyari) { // Zaten haftalık uyarı var; 30 günlük bağlamı mesaja ekle. sonuc.uyari += ` (30 günlük ortalama ${ortalama30}, oran %${Math.round(oran30 * 100)})`; } } // 2) Biz kaynağın gerisinde mi kaldık? (bu BİZİM hatamız olur) if (!sonuc.uyari && kaynakToplam > 0 && yuklenen != null && yuklenen < kaynakToplam * 0.9) { sonuc.uyari = `Havuz eksik yüklendi: kaynakta ${kaynakToplam}, bizde ${yuklenen} ilan.`; sonuc.tur = 'eksik_yukleme'; } return sonuc; } /** Son ölçümler — /api/health/havuz ve arayüz için. */ export function havuzGecmisi(limit = 60) { const db = openDb(); if (!db) return []; try { return db .prepare('SELECT zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv FROM havuz_olcum ORDER BY id DESC LIMIT ?') .all(Math.min(Number(limit) || 60, 500)); } finally { db.close(); } } /** * Havuz özeti — 7 ve 30 günlük ortalamalar SQL'de hesaplanır; döndürülen * satır sayısından bağımsızdır (60 kayıtlık pencereye sıkışmaz). */ export function havuzOzet() { const db = openDb(); if (!db) return null; try { const pencere = (gun) => { const bas = new Date(Date.now() - gun * 864e5).toISOString(); const r = db .prepare(`SELECT AVG(kaynak_toplam) ort, MAX(kaynak_toplam) zirve, MIN(kaynak_toplam) dip, COUNT(kaynak_toplam) adet FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`) .get(bas) || {}; return { ortalama: r.ort ? Math.round(r.ort) : null, zirve: r.zirve ?? null, dip: r.dip ?? null, adet: r.adet ?? 0, }; }; const son = db .prepare('SELECT zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv FROM havuz_olcum ORDER BY id DESC LIMIT 1') .get() || null; return { son, hafta: pencere(7), ay: pencere(30) }; } finally { db.close(); } }