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:
@@ -240,6 +240,14 @@ function kalemAdlari(ham) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Bir İKN'nin sonuç satırları (kısım başına) — takipteki ihale sonuçlandı mı? */
|
||||
export function resultsByIkn(ikn) {
|
||||
const db = openDb();
|
||||
if (!db || !ikn) return [];
|
||||
try {
|
||||
return db.prepare('SELECT * FROM sonuclar WHERE ikn=? ORDER BY kisim').all(ikn).map(rowToTender);
|
||||
} catch { return []; } finally { db.close(); }
|
||||
}
|
||||
export function readHistory(limit = 500, { firma, idare, il } = {}) {
|
||||
const db = openDb();
|
||||
if (!db) return { tenders: [], totalCount: 0, available: false };
|
||||
|
||||
@@ -26,6 +26,8 @@ import { kalemleriCikar } from './kalem-cikar.mjs';
|
||||
import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs';
|
||||
import { iknCikar } from './kalem-cikar.mjs';
|
||||
import { bonusHesapla, gommeDurumu, arkaPlandaIsit } from './uyum-gomme.mjs';
|
||||
import { telegramConfigured, botAdi, kodIleBagla, sendTelegram } from './telegram.mjs';
|
||||
import { bildirimleriOku, okunduIsaretle, zeyilnameDurumu, zeyilnameTara, sonucTara } from './zeyilname.mjs';
|
||||
|
||||
// Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma.
|
||||
const urunAramaCache = new Map();
|
||||
@@ -818,6 +820,52 @@ const server = http.createServer(async (req, res) => {
|
||||
if (url.pathname === '/api/health/uyum') {
|
||||
return json(res, 200, { success: true, ...gommeDurumu() });
|
||||
}
|
||||
// --- Telegram bağlantısı (kullanıcı kodu bota yazar, biz getUpdates'te görürüz) ---
|
||||
if (url.pathname.startsWith('/api/me/telegram')) {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
const tg = store.getData(user.email, 'telegram');
|
||||
if (req.method === 'GET') {
|
||||
return json(res, 200, { success: true, yapilandirilmis: telegramConfigured(), bot: await botAdi(), bagli: !!tg?.chatId, ad: tg?.ad || null, baglanma: tg?.baglanma || null });
|
||||
}
|
||||
if (req.method === 'DELETE') { store.setData(user.email, 'telegram', null); return json(res, 200, { success: true }); }
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
if (body.islem === 'test') {
|
||||
if (!tg?.chatId) return json(res, 400, { success: false, error: 'Telegram bağlı değil' });
|
||||
await sendTelegram(tg.chatId, '✅ İhaleist bağlantı testi — bildirimler bu sohbete gelecek.');
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
const kod = String(body.kod || '').replace(/\D/g, '');
|
||||
if (kod.length !== 6) return json(res, 400, { success: false, error: '6 haneli kod gerekli' });
|
||||
const bulunan = await kodIleBagla(kod);
|
||||
if (!bulunan) return json(res, 404, { success: false, error: 'Kod bota gönderilmiş görünmüyor. Önce bota yazın, sonra doğrulayın.' });
|
||||
store.setData(user.email, 'telegram', { ...bulunan, baglanma: new Date().toISOString() });
|
||||
await sendTelegram(bulunan.chatId, `✅ İhaleist hesabınız (${user.email}) bu sohbete bağlandı.`).catch(() => {});
|
||||
return json(res, 200, { success: true, ad: bulunan.ad });
|
||||
} catch (e) { return json(res, 502, { success: false, error: e.message }); }
|
||||
}
|
||||
}
|
||||
// --- Takip bildirimleri (zeyilname / sonuç) ---
|
||||
if (url.pathname === '/api/me/bildirimler') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
return json(res, 200, { success: true, okundu: okunduIsaretle(user.email, body.idler || []) });
|
||||
}
|
||||
return json(res, 200, { success: true, bildirimler: bildirimleriOku(user.email) });
|
||||
}
|
||||
if (url.pathname === '/api/health/takip') {
|
||||
return json(res, 200, { success: true, ...zeyilnameDurumu(), telegram: telegramConfigured() });
|
||||
}
|
||||
if (url.pathname === '/api/admin/takip/tara' && req.method === 'POST') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici gerekli' });
|
||||
const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]);
|
||||
return json(res, 200, { success: true, zeyilname: z, sonuc: so });
|
||||
}
|
||||
if (url.pathname === '/api/health/kalem') {
|
||||
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
|
||||
}
|
||||
@@ -971,6 +1019,20 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 500, { success: false, error: 'dosya okunamadı' });
|
||||
}
|
||||
}
|
||||
// Belge üst verisi: son kullanma tarihi (süre takibi) / kategori
|
||||
if (url.pathname === '/api/me/documents/meta' && req.method === 'PATCH') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
if (!body.id) return json(res, 400, { success: false, error: 'id gerekli' });
|
||||
const sonKullanma = body.sonKullanma ? String(body.sonKullanma).slice(0, 10) : null;
|
||||
if (sonKullanma && !/^\d{4}-\d{2}-\d{2}$/.test(sonKullanma)) return json(res, 400, { success: false, error: 'tarih YYYY-AA-GG olmalı' });
|
||||
const ok = store.updateDocument(user.email, String(body.id), { sonKullanma, kategori: body.kategori ? String(body.kategori).slice(0, 60) : null });
|
||||
if (!ok) return json(res, 404, { success: false, error: 'belge bulunamadı' });
|
||||
return json(res, 200, { success: true, belgeler: store.listDocuments(user.email) });
|
||||
} catch (e) { return json(res, 400, { success: false, error: e.message }); }
|
||||
}
|
||||
// Belge metni — docx/xlsx/zip için. PDF tarayıcıda (pdfjs) okunuyor;
|
||||
// bu türler için tarayıcıda çözücü yok, baytlar zaten burada duruyor.
|
||||
if (url.pathname === '/api/me/documents/text') {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
import { list, markSent, available } from './notify-store.mjs';
|
||||
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs';
|
||||
import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs';
|
||||
import { getData } from './user-store.mjs';
|
||||
|
||||
const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR');
|
||||
|
||||
@@ -96,6 +98,14 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) {
|
||||
subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`,
|
||||
html,
|
||||
});
|
||||
// Telegram bağlıysa aynı özet oraya da (kısa biçim)
|
||||
try {
|
||||
const tg = telegramConfigured() ? getData(a.email, 'telegram') : null;
|
||||
if (tg?.chatId) {
|
||||
const satirlar = gonderilecek.slice(0, 10).map((t) => `• ${tgKacir(t.baslik)}\n ${tgKacir(t.kurum || '')} · ${tgKacir(t.tarih || '')} · ${tgKacir(t.ikn || '')}`);
|
||||
await sendTelegram(tg.chatId, `<b>İhaleist — ${gonderilecek.length} yeni ihale</b> (${tgKacir(kriterMetni)})\n\n${satirlar.join('\n')}${gonderilecek.length > 10 ? `\n… +${gonderilecek.length - 10} ihale` : ''}`);
|
||||
}
|
||||
} catch (e) { console.warn(`[notifier] telegram ${a.email}: ${e.message}`); }
|
||||
markSent(a.id, eslesen.map((t) => t.id));
|
||||
gonderilen++;
|
||||
console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`);
|
||||
|
||||
@@ -15,6 +15,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { historyStats } from './history-store.mjs';
|
||||
import { bildirimleriIsle } from './notifier.mjs';
|
||||
import { zeyilnameTara, sonucTara } from './zeyilname.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -179,6 +180,16 @@ export function startScheduler({ tenderGetir } = {}) {
|
||||
setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.();
|
||||
}
|
||||
|
||||
// Takipteki ihaleler: zeyilname/düzeltme ilanı + sonuç bildirimi
|
||||
if (process.env.TAKIP_ENABLED === '0') {
|
||||
console.log('[scheduler] takip izleme devre dışı (TAKIP_ENABLED=0)');
|
||||
} else {
|
||||
const tSaat = Number(process.env.TAKIP_INTERVAL_SAAT || 3);
|
||||
const takipTuru = () => Promise.all([zeyilnameTara(), sonucTara()]).catch((e) => console.warn('[scheduler] takip:', e.message));
|
||||
console.log(`[scheduler] takip izleme (zeyilname/sonuç) her ${tSaat} saatte bir`);
|
||||
setTimeout(takipTuru, 8 * 60 * 1000).unref?.();
|
||||
setInterval(takipTuru, Math.max(1, tSaat) * 3600 * 1000).unref?.();
|
||||
}
|
||||
// Otomatik e-posta bildirimi
|
||||
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
|
||||
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Telegram bildirim kanalı — Bot API, bağımlılık yok.
|
||||
*
|
||||
* KURULUM (kullanıcı/yönetici): BotFather'da bot oluşturulur, tokenı
|
||||
* TELEGRAM_BOT_TOKEN env'ine girilir. Her kullanıcı uygulamadan aldığı 6 haneli
|
||||
* kodu bota yazar; sunucu getUpdates ile kodu görüp chat id'yi kullanıcıya
|
||||
* bağlar. Token bizde tutulmaz, sohbet kimliği kullanıcı verisinde durur.
|
||||
*/
|
||||
|
||||
const API = () => `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`;
|
||||
|
||||
export function telegramConfigured() {
|
||||
return Boolean(process.env.TELEGRAM_BOT_TOKEN);
|
||||
}
|
||||
|
||||
let botOnbellek = { ad: null, zaman: 0 };
|
||||
/** Bot kullanıcı adı (t.me/<ad>) — arayüzde bağlantı vermek için. */
|
||||
export async function botAdi() {
|
||||
if (!telegramConfigured()) return null;
|
||||
if (botOnbellek.ad && Date.now() - botOnbellek.zaman < 3600e3) return botOnbellek.ad;
|
||||
try {
|
||||
const r = await fetch(`${API()}/getMe`, { signal: AbortSignal.timeout(10000) });
|
||||
const d = await r.json();
|
||||
if (d.ok) { botOnbellek = { ad: d.result.username, zaman: Date.now() }; return d.result.username; }
|
||||
} catch { /* aşağıda null */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function sendTelegram(chatId, text) {
|
||||
if (!telegramConfigured() || !chatId) return false;
|
||||
const r = await fetch(`${API()}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: chatId, text: String(text).slice(0, 4000), parse_mode: 'HTML', disable_web_page_preview: true }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!d.ok) throw new Error(d.description || `Telegram HTTP ${r.status}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcının bota yazdığı 6 haneli kodu son güncellemelerde arar.
|
||||
* Bulursa { chatId, ad } döner. Long-polling yok; kullanıcı "doğrula" dediğinde
|
||||
* tek getUpdates çağrısı yeterli (Telegram son 24 saati tutar).
|
||||
*/
|
||||
export async function kodIleBagla(kod) {
|
||||
if (!telegramConfigured()) throw new Error('Sunucuda TELEGRAM_BOT_TOKEN tanımlı değil');
|
||||
const r = await fetch(`${API()}/getUpdates?limit=100`, { signal: AbortSignal.timeout(15000) });
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!d.ok) throw new Error(d.description || 'getUpdates başarısız');
|
||||
const aranan = String(kod).trim();
|
||||
for (const u of [...(d.result || [])].reverse()) {
|
||||
const m = u.message || u.edited_message;
|
||||
if (!m?.text) continue;
|
||||
if (m.text.replace(/\D/g, '').includes(aranan)) {
|
||||
return { chatId: String(m.chat.id), ad: [m.chat.first_name, m.chat.last_name].filter(Boolean).join(' ') || m.chat.username || null };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** HTML kaçışı — ihale adları '<' '&' içerebiliyor. */
|
||||
export const tgKacir = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||||
@@ -155,6 +155,10 @@ const IZINLI_ANAHTARLAR = new Set([
|
||||
'favori_kurumlar', 'ekip', 'saved_filters', 'reminder',
|
||||
'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban',
|
||||
'iptal_geri_bildirim',
|
||||
'firma_profil', // yetkinlikler + uyum eşiği
|
||||
'takip', // takipteki İKN'ler — zeyilname/sonuç izleme sunucuda çalışır
|
||||
'gorevler', // ihaleden açılan hazırlık görevleri
|
||||
'telegram', // { chatId, ad, baglanma }
|
||||
]);
|
||||
|
||||
export function getData(email, anahtar) {
|
||||
@@ -244,6 +248,20 @@ export function getDocument(email, id) {
|
||||
return withDb((db) => db.prepare('SELECT * FROM belgeler WHERE id=? AND email=?').get(id, email) || null);
|
||||
}
|
||||
|
||||
/** Belge üst verisi: son kullanma / kategori (süre takibi için). */
|
||||
export function updateDocument(email, id, { sonKullanma, kategori } = {}) {
|
||||
return withDb((db) => {
|
||||
const r = db.prepare(`UPDATE belgeler SET
|
||||
son_kullanma = COALESCE(?, son_kullanma),
|
||||
kategori = COALESCE(?, kategori)
|
||||
WHERE id=? AND email=?`).run(sonKullanma ?? null, kategori ?? null, id, email);
|
||||
return r.changes > 0;
|
||||
}, false);
|
||||
}
|
||||
/** Arka plan işleri için tüm kullanıcılar (zeyilname/sonuç izleme). */
|
||||
export function listUsers() {
|
||||
return withDb((db) => db.prepare('SELECT email, ad FROM kullanicilar').all(), []);
|
||||
}
|
||||
export function deleteDocument(email, id) {
|
||||
const rec = getDocument(email, id);
|
||||
if (!rec) return false;
|
||||
|
||||
@@ -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