/** * Takipteki ihaleler için sunucu tarafı izleme: * 1) Zeyilname / düzeltme ilanı: EKAP'ta ilan sayısı ya da ihale tarihi * değişince "ne değişti" bildirimi (ilan tipi 6 = Düzeltme). * 2) Sonuç: takipteki ihale sonuçlanınca "X firması Y'ye aldı, %Z kırım". * Yalnız takipteki ihaleler bildirilir — bülteni ikinci kez göndermek * kimseye yaramaz (fikir: erenulutas0/MOBIT-DURT). * * Bildirimler takip.sqlite'a yazılır; istemci 5 dk'da bir çeker, ayrıca * e-posta ve (bağlıysa) Telegram'a gider. */ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import * as store from './user-store.mjs'; import { findByIkn } from './documents.mjs'; import { getIhaleDetay } from './ekap-v2.mjs'; import { resultsByIkn } from './history-store.mjs'; import { emailConfigured, sendEmail } from './email.mjs'; import { sendTelegram, telegramConfigured, tgKacir } from './telegram.mjs'; let DatabaseSync = null; try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* devre dışı */ } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DB_PATH = path.join(process.env.IHALEIST_USER_DATA_DIR || path.join(__dirname,'data'), 'takip.sqlite'); const SCHEMA = ` CREATE TABLE IF NOT EXISTS takip_anlik ( ikn TEXT PRIMARY KEY, ilan_sayisi INTEGER, son_ilan_tarihi TEXT, ihale_tarih TEXT, ihale_adi TEXT, idare TEXT, zaman TEXT ); CREATE TABLE IF NOT EXISTS bildirimler ( id TEXT PRIMARY KEY, email TEXT NOT NULL, tur TEXT NOT NULL, ikn TEXT, baslik TEXT, metin TEXT, zaman TEXT NOT NULL, okundu INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_bild_email ON bildirimler(email, okundu); CREATE TABLE IF NOT EXISTS sonuc_bildirildi (ikn TEXT, email TEXT, PRIMARY KEY (ikn, email)); `; let db = null; function open() { if (db) return db; if (!DatabaseSync) return null; fs.mkdirSync(path.dirname(DB_PATH), { recursive: true, mode:0o700 }); db = new DatabaseSync(DB_PATH); fs.chmodSync(DB_PATH,0o600); db.exec('PRAGMA journal_mode = WAL;'); db.exec(SCHEMA); return db; } export function available() { return !!DatabaseSync; } const TAKIP_LIMIT = Number(process.env.ZEYILNAME_TUR_LIMIT || 150); let calisiyor = false; let sonTur = null; export function zeyilnameDurumu() { return { calisiyor, sonTur }; } /** Tüm kullanıcıların takip listelerinden İKN → [email] haritası. */ function takipHaritasi() { const harita = new Map(); for (const u of store.listUsers()) { const liste = store.getTrackedTenderKeys(u.email); if (!Array.isArray(liste)) continue; for (const ikn of liste) { const k = String(ikn); if (!/^\d{4}\/\d+$/.test(k)) continue; // yalnızca EKAP İKN'leri if (!harita.has(k)) harita.set(k, new Set()); harita.get(k).add(u.email); } } return harita; } export function bildirimYaz({ email, tur, ikn, baslik, metin }) { const d = open(); if (!d) return null; const id = crypto.randomUUID(); d.prepare('INSERT INTO bildirimler (id,email,tur,ikn,baslik,metin,zaman) VALUES (?,?,?,?,?,?,?)') .run(id, email, tur, ikn || null, baslik, metin, new Date().toISOString()); return id; } export function bildirimleriOku(email, { limit = 50 } = {}) { const d = open(); if (!d) throw new Error('Bildirim deposuna erişilemiyor.'); return d.prepare('SELECT * FROM bildirimler WHERE email=? ORDER BY zaman DESC LIMIT ?').all(email, limit); } export function okunduIsaretle(email, idler) { if(!Array.isArray(idler)||idler.length>200||idler.some(id=>typeof id!=='string'||!id||id.length>100))throw new Error('Geçersiz bildirim kimlikleri.'); const d = open(); if (!d)throw new Error('Bildirim deposuna erişilemiyor.'); if(!idler.length)return 0; const st = d.prepare('UPDATE bildirimler SET okundu=1 WHERE email=? AND id=?'); d.exec('BEGIN IMMEDIATE'); try {let n=0;for(const id of new Set(idler))n+=st.run(email,id).changes;d.exec('COMMIT');return n;} catch(error){d.exec('ROLLBACK');throw error;} } async function gonder(email, baslik, metinHtml, metinDuz) { try { const tg = store.getData(email, 'telegram'); if (telegramConfigured() && tg?.chatId) await sendTelegram(tg.chatId, `${tgKacir(baslik)}\n${tgKacir(metinDuz)}`); } catch (e) { console.warn('[takip] telegram:', e.message); } try { if (emailConfigured()) await sendEmail({ to: email, subject: `İhaleist — ${baslik}`, html: `

${metinHtml}

` }); } catch (e) { console.warn('[takip] e-posta:', e.message); } } /** Zeyilname/düzeltme ilanı ve ihale tarihi değişikliği. */ export async function zeyilnameTara() { const d = open(); if (!d || calisiyor) return { atlandi: true }; calisiyor = true; const bas = Date.now(); let bakilan = 0, degisen = 0, hata = 0; try { const harita = takipHaritasi(); const iknler = [...harita.keys()].slice(0, TAKIP_LIMIT); const oku = d.prepare('SELECT * FROM takip_anlik WHERE ikn=?'); const yaz = d.prepare(`INSERT INTO takip_anlik (ikn,ilan_sayisi,son_ilan_tarihi,ihale_tarih,ihale_adi,idare,zaman) VALUES (?,?,?,?,?,?,?) ON CONFLICT(ikn) DO UPDATE SET ilan_sayisi=excluded.ilan_sayisi, son_ilan_tarihi=excluded.son_ilan_tarihi, ihale_tarih=excluded.ihale_tarih, zaman=excluded.zaman`); for (const ikn of iknler) { try { const t = await findByIkn(ikn); const det = await getIhaleDetay(t.id); const ilanlar = det?.ilanList || []; const yeni = { ilan_sayisi: ilanlar.length, son_ilan_tarihi: ilanlar.map((i) => i.ilanTarihi).filter(Boolean).sort().pop() || null, ihale_tarih: det?.ihaleBilgi?.ihaleTarihSaat || t.ihaleTarihSaat || null, }; const eski = oku.get(ikn); bakilan++; if (eski) { const farklar = []; if (yeni.ilan_sayisi > eski.ilan_sayisi) { const sonIlan = ilanlar[ilanlar.length - 1]; farklar.push(`yeni ilan yayımlandı (${sonIlan?.ilanTipAciklama || 'ilan'}${sonIlan?.ilanTarihi ? ', ' + sonIlan.ilanTarihi : ''})`); } if (yeni.ihale_tarih && eski.ihale_tarih && yeni.ihale_tarih !== eski.ihale_tarih) { farklar.push(`ihale tarihi ${eski.ihale_tarih} → ${yeni.ihale_tarih}`); } if (farklar.length) { degisen++; const baslik = `Zeyilname/düzeltme: ${t.ihaleAdi?.slice(0, 80) || ikn}`; const metin = `${ikn} · ${t.idareAdi || ''}\n${farklar.join('; ')}`; for (const email of harita.get(ikn)) { bildirimYaz({ email, tur: 'zeyilname', ikn, baslik, metin }); await gonder(email, baslik, metin.replace(/\n/g, '
'), metin); } } } yaz.run(ikn, yeni.ilan_sayisi, yeni.son_ilan_tarihi, yeni.ihale_tarih, t.ihaleAdi || null, t.idareAdi || null, new Date().toISOString()); await new Promise((r) => setTimeout(r, 300)); } catch (e) { hata++; } } } finally { calisiyor = false; sonTur = { zaman: new Date().toISOString(), bakilan, degisen, hata, sureSn: Math.round((Date.now() - bas) / 1000) }; console.log(`[takip] zeyilname turu: ${bakilan} ihale, ${degisen} değişiklik, ${hata} hata`); } return sonTur; } /** Takipteki ihale sonuçlandıysa (geçmiş taraması sonuç yazdıysa) bildir. */ export async function sonucTara() { const d = open(); if (!d) return { atlandi: true }; const harita = takipHaritasi(); const bak = d.prepare('SELECT 1 FROM sonuc_bildirildi WHERE ikn=? AND email=?'); const yaz = d.prepare('INSERT OR IGNORE INTO sonuc_bildirildi (ikn,email) VALUES (?,?)'); let bildirilen = 0; for (const [ikn, emailler] of harita) { const sonuclar = resultsByIkn(ikn).filter((r) => r.kazananFirma); if (!sonuclar.length) continue; const r = sonuclar[0]; const bedel = Number(String(r.sozlesmeBedeli || '').replace(/[^\d,]/g, '').replace(',', '.')); const maliyet = Number(String(r.yaklasikMaliyet || '').replace(/[^\d,]/g, '').replace(',', '.')); // Kısmi ihalede maliyet tüm ihale, bedel kısım — oran yazılmaz const kirim = sonuclar.length === 1 && bedel > 0 && maliyet > 0 && bedel <= maliyet * 1.5 ? Math.round((1 - bedel / maliyet) * 100) : null; const baslik = `Takip ettiğiniz ihale sonuçlandı: ${(r.baslik || ikn).slice(0, 70)}`; const metin = `${r.kazananFirma} ${r.sozlesmeBedeli ? r.sozlesmeBedeli + ' bedelle' : ''} aldı` + (kirim != null ? `, %${kirim} kırım` : '') + (sonuclar.length > 1 ? ` (${sonuclar.length} kısım)` : '') + ` · ${ikn}`; for (const email of emailler) { if (bak.get(ikn, email)) continue; bildirimYaz({ email, tur: 'sonuc', ikn, baslik, metin }); await gonder(email, baslik, metin, metin); yaz.run(ikn, email); bildirilen++; } } if (bildirilen) console.log(`[takip] ${bildirilen} sonuç bildirimi`); return { bildirilen }; }