345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
/**
|
||
* 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';
|
||
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',
|
||
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: 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,
|
||
};
|
||
}
|
||
|
||
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 haftaBas = new Date(Date.now() - 7 * 864e5).toISOString();
|
||
const ref = db
|
||
.prepare(`SELECT AVG(kaynak_toplam) ort, COUNT(kaynak_toplam) adet
|
||
FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`)
|
||
.get(haftaBas) || {};
|
||
|
||
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, { 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;
|
||
|
||
/**
|
||
* Ö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, { kaynakToplam, yuklenen }) {
|
||
const ortalama = ref?.adet >= MIN_OLCUM && ref.ort ? Math.round(ref.ort) : null;
|
||
const sonuc = {
|
||
uyari: null,
|
||
onceki: onceki?.kaynak_toplam ?? null,
|
||
simdi: kaynakToplam ?? null,
|
||
haftalikOrtalama: ortalama,
|
||
olcumAdedi: ref?.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';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
}
|