Files
ihaleist/server/history-store.mjs
T

419 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.
/**
* 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 { sektorKarar } 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).
// Önce OKAS kodu (idarenin kendi sınıflandırması), yoksa ihale adı.
sektor: sektorKarar(okasParse(r.okas_kalemler, null), r.ihale_adi || ''),
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();
}
}