feat: İhaleAB'den taşınan özellikler — MCP sunucusu, RSS, haftalık brief, teklif asistanı, yeterlilik rozeti, 5 yeni kırmızı bayrak
- /mcp (JSON-RPC, ih_live_ anahtarı): search_tenders, get_tender, search_results, search_items, upcoming_deadlines - /api/feeds/tenders.rss (anahtarlı) ve /api/feeds/sonuc.rss (açık) - Haftalık brief (Pazartesi 08:00, Telegram + e-posta), /api/me/brief tercihi + önizleme, sağ panelde yaklaşan ihale tarihleri - Detayda Teklif Asistanı sekmesi: belgeye sor (atıflı), taslak denetimi, taslak üretimi (analyzeSpec zinciri) - Derin analiz belge listesi × Doküman Deposu eşleştirme rozeti - Kırmızı bayrak: kısa süre, hafta sonu tarih, geç sözleşme, kazananı gizli, DT parçalama şüphesi - Kullanım kılavuzu güncellendi Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
// Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar,
|
||||
// geçen hafta sonuçlanan takipliler, takiplilere gelen zeyilname sayısı.
|
||||
// Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur;
|
||||
// saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval).
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as store from './user-store.mjs';
|
||||
import { resultsByIkn } from './history-store.mjs';
|
||||
import { emailConfigured, sendEmail } from './email.mjs';
|
||||
import { sendTelegram, telegramConfigured } from './telegram.mjs';
|
||||
import { yaklasanIhaleler, trTarih } from './feeds-mcp.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DURUM_DOSYASI = path.join(__dirname, 'data', 'brief-son.json');
|
||||
|
||||
function sonGonderim() {
|
||||
try { return JSON.parse(fs.readFileSync(DURUM_DOSYASI, 'utf8')); } catch { return {}; }
|
||||
}
|
||||
function sonGonderimYaz(d) {
|
||||
try { fs.mkdirSync(path.dirname(DURUM_DOSYASI), { recursive: true }); fs.writeFileSync(DURUM_DOSYASI, JSON.stringify(d)); } catch { /* yok */ }
|
||||
}
|
||||
|
||||
const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`);
|
||||
|
||||
/** Bir kullanıcı için brief içeriği. */
|
||||
export function briefOlustur(email, tenders) {
|
||||
const takip = (store.getData(email, 'takip') || []).map(String);
|
||||
const yaklasan = yaklasanIhaleler(email, tenders, 7);
|
||||
const birHafta = Date.now() - 7 * 86400000;
|
||||
const sonuclar = [];
|
||||
for (const ikn of takip.slice(0, 200)) {
|
||||
const r = resultsByIkn(ikn) || [];
|
||||
for (const s of r) {
|
||||
const d = trTarih(s.sozlesmeTarihi) || trTarih(s.tarih);
|
||||
if (d && d.getTime() >= birHafta) sonuclar.push({ ikn, baslik: s.baslik, kazanan: s.kazananFirma, bedel: s.sozlesmeBedeli, birim: s.paraBirimi, teklif: s.teklifSayisi });
|
||||
}
|
||||
}
|
||||
const hafta = new Date().toLocaleDateString('tr-TR', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length };
|
||||
}
|
||||
|
||||
export function briefMetni(b) {
|
||||
const s = [`📋 Haftalık İhale Brief — ${b.hafta}`];
|
||||
if (b.yaklasan.length) {
|
||||
s.push('', `⏰ Bu hafta yapılacak takipli ihaleler (${b.yaklasan.length}):`);
|
||||
for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih}${t.saat ? ' ' + t.saat : ''})`);
|
||||
}
|
||||
if (b.sonuclar.length) {
|
||||
s.push('', `🏁 Geçen hafta sonuçlanan takipliler (${b.sonuclar.length}):`);
|
||||
for (const r of b.sonuclar) s.push(`• ${r.baslik} → ${r.kazanan || 'kazanan açıklanmadı'}${r.bedel != null ? ' · ' + paraTR(r.bedel, r.birim) : ''}${r.teklif != null ? ' · ' + r.teklif + ' teklif' : ''}`);
|
||||
}
|
||||
s.push('', 'ihaleist.com');
|
||||
return s.join('\n');
|
||||
}
|
||||
|
||||
export function briefHtml(b) {
|
||||
const esc = (x) => String(x ?? '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||
const bolum = (baslik, satirlar) => satirlar.length
|
||||
? `<h3 style="margin:18px 0 6px;font-size:15px;color:#0f172a">${baslik}</h3><ul style="margin:0;padding-left:18px;color:#334155;font-size:14px">${satirlar.map((x) => `<li style="margin:4px 0">${x}</li>`).join('')}</ul>` : '';
|
||||
return `<div style="font-family:Inter,Arial,sans-serif;max-width:640px;margin:auto;padding:20px;color:#0f172a">
|
||||
<h2 style="margin:0 0 4px;font-size:20px">📋 Haftalık İhale Brief</h2><div style="color:#64748b;font-size:13px">${esc(b.hafta)}</div>
|
||||
${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `<b>${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'}</b> — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)})`))}
|
||||
${bolum('🏁 Geçen hafta sonuçlanan takipliler', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))}
|
||||
<p style="margin-top:20px;font-size:12px;color:#94a3b8">Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.</p></div>`;
|
||||
}
|
||||
|
||||
export async function haftalikBriefGonder(tenderGetir, { zorla = false } = {}) {
|
||||
const tenders = await tenderGetir();
|
||||
const r = { kullanici: 0, gonderilen: 0, bos: 0, hata: 0 };
|
||||
for (const u of store.listUsers()) {
|
||||
r.kullanici++;
|
||||
try {
|
||||
const tercih = store.getData(u.email, 'brief');
|
||||
if (tercih && tercih.kapali && !zorla) continue;
|
||||
const b = briefOlustur(u.email, tenders);
|
||||
if (b.bos) { r.bos++; continue; }
|
||||
const tg = store.getData(u.email, 'telegram');
|
||||
let gitti = false;
|
||||
if (telegramConfigured() && tg?.chatId) gitti = await sendTelegram(tg.chatId, briefMetni(b).slice(0, 3900)).then(() => true).catch(() => false);
|
||||
if (emailConfigured()) gitti = (await sendEmail({ to: u.email, subject: `İhaleist — Haftalık Brief (${b.hafta})`, html: briefHtml(b), text: briefMetni(b) }).then(() => true).catch(() => false)) || gitti;
|
||||
if (gitti) r.gonderilen++;
|
||||
} catch (e) {
|
||||
r.hata++;
|
||||
console.warn('[brief]', u.email, e.message);
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Saatlik kontrol: Pazartesi ≥08:00 ve bu hafta gönderilmediyse gönder. */
|
||||
export function briefZamanlayici(tenderGetir) {
|
||||
if (process.env.BRIEF_ENABLED === '0') { console.log('[brief] haftalık brief devre dışı (BRIEF_ENABLED=0)'); return; }
|
||||
const kontrol = async () => {
|
||||
const simdi = new Date();
|
||||
if (simdi.getDay() !== 1 || simdi.getHours() < 8) return;
|
||||
const anahtar = `${simdi.getFullYear()}-${simdi.getMonth()}-${simdi.getDate()}`;
|
||||
const d = sonGonderim();
|
||||
if (d.son === anahtar) return;
|
||||
sonGonderimYaz({ son: anahtar });
|
||||
const r = await haftalikBriefGonder(tenderGetir).catch((e) => ({ hata: e.message }));
|
||||
console.log('[brief] haftalık brief:', JSON.stringify(r));
|
||||
};
|
||||
setTimeout(kontrol, 3 * 60 * 1000).unref?.();
|
||||
setInterval(kontrol, 60 * 60 * 1000).unref?.();
|
||||
console.log('[brief] haftalık brief zamanlandı (Pazartesi 08:00, saatlik kontrol)');
|
||||
}
|
||||
Reference in New Issue
Block a user