feat: takip izleme (zeyilname + sonuc bildirimi), hazirlik gorevi, belge sure uyarisi, Telegram, OCR, yasakli sorgu

28 depo taramasindan alinan alti ozellik. Kod kopyalanmadi (depolar
lisanssiz), fikirler uygulandi.

1) Zeyilname / duzeltme ilani takibi (server/zeyilname.mjs): takipteki
   IKN'ler sunucuya yazilir; 3 saatte bir EKAP detayindan ilan sayisi ve
   ihale tarihi karsilastirilir, degisince "ne degisti" bildirimi (uygulama
   ici + e-posta + Telegram). Test: anlik kaydi eskitilince tur "yeni ilan
   yayimlandi; ihale tarihi 01.01.2026 → 08.10.2026" bildirimi uretti.
2) Sonuc bildirimi: takipteki ihale sonuclaninca "X firmasi Y'ye aldi, %Z
   kirim". Yalniz takiptekiler; kismi ihalede oran yazilmaz.
3) Ihaleden goreve (MOBIT-DURT): detayda "Hazirlik Gorevi Ac" → teslim
   tarihi ihale saati, pano "Teklif Hazirlaniyor", derin analizden gelen
   belge kontrol listesi gorevde; Kanban kartinda geri sayim rozeti.
4) Sirket belgesi sure takibi: Belge Kasasi'nda son kullanma tarihi girisi,
   dolmus / 30 gun icinde dolacak uyarisi; girise gunde bir bildirim.
   PATCH /api/me/documents/meta.
5) Telegram (server/telegram.mjs): TELEGRAM_BOT_TOKEN sunucuda; kullanici
   6 haneli kodu bota yazar, "Dogrula" → getUpdates ile chat id baglanir.
   Arayuzde adim adim kurulum rehberi (BotFather → env → Redeploy → kod).
   Ihale ozeti, zeyilname ve sonuc bildirimleri Telegram'a da gider.
6) OCR (tesseract.js, tur): PDF metin katmani bossa (taranmis) ilk 8 sayfa
   OCR. Olcum: sentetik cetvelde guven 87, ~0,3 sn/sayfa; IKN ve kalemler
   dogru okundu. Kalem havuzu ve derin analiz artik taranmis PDF'ten de
   besleniyor.
7) Rakip Radari'nda KIK yasakli sorgusu baglantisi (firma adi panoya
   kopyalanir; EKAP sorguyu API'den vermiyor).

Sinir deger hesaplayici KURULMADI: Kamu Ihale Genel Tebligi'ne ulasilamadi
(mevzuat.gov.tr / ihale.gov.tr bu makineden erisilemiyor); formul kaynaksiz
yazilmadi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 22:58:13 +03:00
co-authored by Claude Fable 5.1
parent 1716b6365c
commit df89eaac33
18 changed files with 802 additions and 10 deletions
+186
View File
@@ -0,0 +1,186 @@
/**
* 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(__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 });
db = new DatabaseSync(DB_PATH);
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.getData(u.email, 'takip');
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 };
}