Files
ihaleist/server/history-store.mjs
T
hikmettan 168052c267 feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar
Roadmap'in 6 maddesi de gerçek çalışır halde:

1) Geçmiş veri motoru (EKAP v2)
   - server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
     ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
     yaklaşık maliyet, teklif sayısı), TR firma adı normalize
   - server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
   - server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
   - server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
   - /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
     rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor

2) Gerçek AI şartname analizi
   - server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
     Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
   - pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
   - AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata

3) KİK kurul kararları
   - server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
     düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
   - KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri

4) Fiyatlandırma + ödeme
   - server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
   - Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı

5) TED (AB ihaleleri)
   - server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
   - rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü

6) Kırmızı bayrak & şeffaflık
   - analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
     aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
   - RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
2026-08-28 10:37:50 +03:00

177 lines
6.1 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.
/**
* 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 path from 'node:path';
import { fileURLToPath } from 'node:url';
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,
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 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);
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,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,
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,
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) --------------------------------------------
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: r.il || '',
tarih: r.ihale_tarihi || '',
alimTuru: r.ihale_tipi || 'İhale',
sektor: r.ihale_tipi || 'Genel',
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: [],
};
}
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);
}