feat: geçmiş veri motoru + gerçek AI + KİK kararları + TED + kırmızı bayrak + planlar

Roadmap'in 6 maddesi de gerçek çalışır halde:

1) Geçmiş veri motoru (EKAP v2)
   - server/ekap-v2.mjs: AES-256-CBC imzalı EKAP v2 istemcisi (zero-dep),
     ihale arama + sonuç ilanı parse (kazanan firma, sözleşme bedeli,
     yaklaşık maliyet, teklif sayısı), TR firma adı normalize
   - server/history-store.mjs: SQLite deposu (IKN+kısım anahtarlı, upsert)
   - server/ekap-scan.mjs: tarih aralıklı artımlı tarayıcı (6 paralel)
   - server/scheduler.mjs: sunucu içi periyodik tarama (SCAN_* env ile)
   - /api/tenders artık geçmiş sonuçları da birleştiriyor → kazanan firma,
     rakip ve kırmızı bayrak analizleri gerçek veriyle doluyor

2) Gerçek AI şartname analizi
   - server/ai.mjs: 6 sağlayıcılı fallback zinciri (GLM/DeepSeek/Gemini/
     Grok/GPT/Claude), JSON şemalı çıktı, uydurma yasak promptu
   - pdfjs-dist ile tarayıcıda PDF metin çıkarma (dinamik import)
   - AiSpecAnalyzerModal: sahte sonuç kaldırıldı, gerçek dosya + dürüst hata

3) KİK kurul kararları
   - server/kik.mjs: EKAP v2 JSON API (Sec-1..6 imzalı), uyuşmazlık/
     düzenleyici/mahkeme; karar tam metni (ayrı AES anahtarıyla URL)
   - KikDecisions sekmesi: arama + takipteki idarelerden hızlı öneri

4) Fiyatlandırma + ödeme
   - server/billing.mjs: Stripe Checkout (zero-dep, form-encoded)
   - Pricing sekmesi: 3 plan, aylık/yıllık, yapılandırılmadıysa dürüst uyarı

5) TED (AB ihaleleri)
   - server/ted.mjs: anonim TED v3 API, 3 mod (açık/tüm TR/AB'de kazanan TR)
   - rate-limit korumalı (429 HTML gövdesi ele alınır), çok dilli alan çözümü

6) Kırmızı bayrak & şeffaflık
   - analytics.js: redFlags (tek teklif, düşük kırım, tahmin üstü bedel,
     aşırı düşük teklif, tedarikçi yoğunlaşması) + marketConcentration (HHI)
   - RedFlags sekmesi: seviyeli uyarılar + yöntem şeffaflığı notu
This commit is contained in:
2026-08-28 10:37:50 +03:00
parent 3e129c2988
commit 168052c267
26 changed files with 3121 additions and 219 deletions
+154
View File
@@ -0,0 +1,154 @@
/**
* TED (Tenders Electronic Daily) — AB kamu ihale ilanları, Türkiye odaklı.
*
* Endpoint: POST https://api.ted.europa.eu/v3/notices/search (anonim, API key YOK)
* Doğrulanmış davranış:
* - `fields` boş olamaz (400 verir)
* - limit max 250; page*limit max 15000; alan/sayfa max 10000 ("links" gizli alan da sayılır)
* - Rate limit agresif ve belgelenmemiş: ~1 istek/sn güvenli. 429 gövdesi JSON DEĞİL (nginx HTML)
* - Ülke kodları ISO alpha-3 (TUR); tarih formatı YYYYMMDD
* - Metin alanları çok dilli obje: { eng: [...], tur: [...] }
*
* Bağımlılık yok (Node 18+ fetch).
*/
const TED_URL = 'https://api.ted.europa.eu/v3/notices/search';
const FIELDS = [
'publication-number', 'notice-title', 'notice-type', 'publication-date',
'deadline-receipt-tender-date-lot', 'organisation-name-buyer', 'organisation-city-buyer',
'buyer-country', 'winner-name', 'winner-country',
'estimated-value-lot', 'estimated-value-cur-lot',
'result-value-notice', 'result-value-cur-notice',
'classification-cpv', 'contract-nature', 'description-lot',
];
// Türkiye evreni: TR idarelerin AB ihaleleri + AB'de kazanan TR firmaları + teklif veren TR firmaları
const Q_TURKIYE = '(buyer-country IN (TUR) OR winner-country IN (TUR) OR organisation-country-tenderer IN (TUR))';
// TR firmalarının AB'de kazandıkları (rakip radar için)
const Q_KAZANAN = 'winner-country IN (TUR)';
const cache = new Map(); // key -> {at, data}
const TTL = 30 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Çok dilli alan → tek string
function txt(v) {
if (v == null) return null;
if (typeof v === 'string') return v;
if (Array.isArray(v)) return txt(v[0]);
if (typeof v === 'object') return txt(v.eng ?? v.tur ?? Object.values(v)[0]);
return String(v);
}
const first = (v) => (Array.isArray(v) ? v[0] ?? null : v ?? null);
const uniq = (v) => (Array.isArray(v) ? [...new Set(v)] : v == null ? [] : [v]);
const day = (v) => { const s = first(v); return s ? String(s).slice(0, 10) : null; };
const NATURE_TR = { works: 'Yapım İşi', supplies: 'Mal Alımı', services: 'Hizmet Alımı' };
function mapNotice(n) {
const pn = n['publication-number'];
const bedel = n['result-value-notice'] ?? first(n['estimated-value-lot']);
const cur = n['result-value-cur-notice'] ?? first(n['estimated-value-cur-lot']);
const nature = first(n['contract-nature']);
return {
id: `ted-${pn}`,
kaynak: 'TED',
ikn: String(pn || ''),
baslik: txt(n['notice-title']) || 'Başlıksız ilan',
kurum: txt(n['organisation-name-buyer']) || '—',
sehir: first(n['organisation-city-buyer'])?.trim() || null,
ulke: first(n['buyer-country']) || null,
tarih: day(n['deadline-receipt-tender-date-lot']) || day(n['publication-date']),
yayimTarihi: day(n['publication-date']),
sonTeklifTarihi: day(n['deadline-receipt-tender-date-lot']),
yaklasikMaliyet: bedel != null && bedel !== '' ? Number(bedel) : null,
paraBirimi: cur || null,
kazananFirma: txt(n['winner-name']),
kazananUlke: first(n['winner-country']),
alimTuru: NATURE_TR[nature] || nature || 'AB İhalesi',
ihaleTuru: n['notice-type'],
sonucIlani: String(n['notice-type'] || '').startsWith('can'),
cpv: uniq(n['classification-cpv']),
aciklama: txt(n['description-lot']),
link: n.links?.html?.ENG || (pn ? `https://ted.europa.eu/en/notice/-/detail/${pn}` : null),
};
}
async function tedSearch(body, { retries = 3 } = {}) {
for (let i = 0; ; i++) {
let res;
try {
res = await fetch(TED_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(45000),
});
} catch (e) {
if (i >= retries) throw new Error(`TED bağlantı hatası: ${e.message}`);
await sleep(1000 * 2 ** i);
continue;
}
// 429 gövdesi HTML gelir; her durumda önce text al
const raw = await res.text();
if (res.status === 429 || res.status >= 500) {
if (i >= retries) throw new Error(`TED geçici olarak yanıt vermiyor (HTTP ${res.status}).`);
await sleep(1200 * 2 ** i);
continue;
}
if (!res.ok) {
let msg = raw.slice(0, 300);
try { const j = JSON.parse(raw); msg = j.message || msg; } catch { /* HTML yanıt */ }
throw new Error(`TED API: ${msg}`);
}
return JSON.parse(raw);
}
}
/**
* @param {object} opts
* @param {'turkiye'|'kazanan'|'acik'} [opts.mod]
* @param {string} [opts.query] Ham expert query (verilirse mod yok sayılır)
* @param {string} [opts.since] YYYYMMDD
*/
export async function searchTed({ mod = 'turkiye', query, since = '20240101', page = 1, limit = 25 } = {}) {
let q;
let scope = 'ALL';
if (query && query.trim()) {
q = query.trim();
} else if (mod === 'kazanan') {
q = `${Q_KAZANAN} AND publication-date >= ${since} SORT BY publication-date DESC`;
} else if (mod === 'acik') {
q = `buyer-country IN (TUR) SORT BY publication-date DESC`;
scope = 'ACTIVE';
} else {
q = `${Q_TURKIYE} AND publication-date >= ${since} SORT BY publication-date DESC`;
}
const key = `${q}|${scope}|${limit}|${page}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < TTL) return hit.data;
const data = await tedSearch({
query: q,
fields: FIELDS,
page,
limit: Math.min(limit, 250),
scope,
onlyLatestVersions: true,
paginationMode: 'PAGE_NUMBER',
});
const out = {
tenders: (data.notices || []).map(mapNotice),
totalCount: data.totalNoticeCount ?? (data.notices || []).length,
mod,
query: q,
timedOut: Boolean(data.timedOut),
fetchedAt: new Date().toISOString(),
};
cache.set(key, { at: Date.now(), data: out });
return out;
}