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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user