Files
ihaleist/server/zeyilname.mjs
T

188 lines
8.5 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.
/**
* 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) return [];
return d.prepare('SELECT * FROM bildirimler WHERE email=? ORDER BY zaman DESC LIMIT ?').all(email, limit);
}
export function okunduIsaretle(email, idler) {
const d = open(); if (!d || !idler?.length) return 0;
const st = d.prepare('UPDATE bildirimler SET okundu=1 WHERE email=? AND id=?');
let n = 0; for (const id of idler) n += st.run(email, id).changes; return n;
}
async function gonder(email, baslik, metinHtml, metinDuz) {
try {
const tg = store.getData(email, 'telegram');
if (telegramConfigured() && tg?.chatId) await sendTelegram(tg.chatId, `<b>${tgKacir(baslik)}</b>\n${tgKacir(metinDuz)}`);
} catch (e) { console.warn('[takip] telegram:', e.message); }
try {
if (emailConfigured()) await sendEmail({ to: email, subject: `İhaleist — ${baslik}`, html: `<p style="font-family:Arial,sans-serif">${metinHtml}</p>` });
} 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, '<br>'), 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 };
}