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:
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Gerçek AI şartname analizi istemcisi (backend /api/ai üzerinden).
|
||||
*/
|
||||
import { getToken } from './auth';
|
||||
|
||||
export async function getAiConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/ai/config');
|
||||
if (!res.ok) return { configured: false, providers: [] };
|
||||
return await res.json();
|
||||
} catch {
|
||||
return { configured: false, providers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeSpecText({ text, fileName, tender, chain }) {
|
||||
const res = await fetch('/api/ai/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ text, fileName, tender, chain }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!data.success) throw new Error(data.error || `Analiz başarısız (HTTP ${res.status})`);
|
||||
return data;
|
||||
}
|
||||
@@ -164,6 +164,98 @@ export function monthlyStats(tenders) {
|
||||
.map((x) => ({ month: AY_KISA[x.m], kazanim: x.kazanim, katilim: x.katilim }));
|
||||
}
|
||||
|
||||
// --- Kırmızı bayrak / şeffaflık analizi ---
|
||||
// Kaynak veride bulunan alanlardan hesaplanır; alan yoksa o bayrak üretilmez.
|
||||
// (Uyarı niteliğindedir — usulsüzlük iddiası değil, incelenmeye değer sinyaldir.)
|
||||
export function redFlags(tenders) {
|
||||
const flags = [];
|
||||
|
||||
// Firma-kurum yoğunlaşması için sayaçlar
|
||||
const firmKurum = new Map(); // "firma||kurum" -> sayı
|
||||
const kurumToplam = new Map(); // kurum -> sonuçlanan ihale sayısı
|
||||
|
||||
for (const t of tenders) {
|
||||
const bedel = parseTL(t.sozlesmeBedeli);
|
||||
const yaklasik = parseTL(t.yaklasikMaliyet);
|
||||
const teklif = t.teklifSayisi != null ? Number(t.teklifSayisi) : null;
|
||||
|
||||
// 1) Tek teklifli ihale
|
||||
if (teklif === 1) {
|
||||
flags.push({
|
||||
id: `tek-${t.id}`, seviye: 'yuksek', tur: 'Tek teklif',
|
||||
tender: t,
|
||||
aciklama: 'İhaleye yalnızca bir geçerli teklif verilmiş — rekabet oluşmamış.',
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Yaklaşık maliyete çok yakın sözleşme (düşük kırım)
|
||||
if (bedel != null && yaklasik != null && yaklasik > 0) {
|
||||
const kirim = 1 - bedel / yaklasik;
|
||||
if (kirim >= 0 && kirim < 0.03) {
|
||||
flags.push({
|
||||
id: `kirim-${t.id}`, seviye: 'orta', tur: 'Düşük kırım',
|
||||
tender: t, deger: `%${(kirim * 100).toFixed(1)} kırım`,
|
||||
aciklama: 'Sözleşme bedeli yaklaşık maliyete neredeyse eşit — fiyat rekabeti sınırlı.',
|
||||
});
|
||||
}
|
||||
// 3) Yaklaşık maliyetin üzerinde sözleşme
|
||||
if (bedel > yaklasik) {
|
||||
flags.push({
|
||||
id: `ustu-${t.id}`, seviye: 'yuksek', tur: 'Tahmin üstü bedel',
|
||||
tender: t, deger: `%${(((bedel - yaklasik) / yaklasik) * 100).toFixed(1)} üzerinde`,
|
||||
aciklama: 'Sözleşme bedeli idarenin yaklaşık maliyetini aşıyor.',
|
||||
});
|
||||
}
|
||||
// 4) Aşırı düşük teklif riski
|
||||
if (kirim > 0.4) {
|
||||
flags.push({
|
||||
id: `asiri-${t.id}`, seviye: 'orta', tur: 'Aşırı düşük teklif',
|
||||
tender: t, deger: `%${(kirim * 100).toFixed(1)} kırım`,
|
||||
aciklama: 'Yaklaşık maliyete göre çok yüksek kırım — sürdürülebilirlik/ifa riski.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (t.kazananFirma && t.kurum) {
|
||||
const key = `${t.kazananFirma.trim()}||${t.kurum.trim()}`;
|
||||
firmKurum.set(key, (firmKurum.get(key) || 0) + 1);
|
||||
kurumToplam.set(t.kurum.trim(), (kurumToplam.get(t.kurum.trim()) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Kurum-firma yoğunlaşması: aynı firma bir kurumun ihalelerinin çoğunu almış
|
||||
for (const [key, adet] of firmKurum) {
|
||||
const [firma, kurum] = key.split('||');
|
||||
const toplam = kurumToplam.get(kurum) || 0;
|
||||
if (toplam >= 3 && adet / toplam >= 0.6) {
|
||||
flags.push({
|
||||
id: `yogun-${key}`, seviye: 'yuksek', tur: 'Tedarikçi yoğunlaşması',
|
||||
firma, kurum, deger: `${adet}/${toplam} ihale`,
|
||||
aciklama: `${firma}, bu idarenin sonuçlanan ihalelerinin %${Math.round((adet / toplam) * 100)}'ini almış.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const siraDeger = { yuksek: 0, orta: 1, dusuk: 2 };
|
||||
flags.sort((a, b) => siraDeger[a.seviye] - siraDeger[b.seviye]);
|
||||
return {
|
||||
flags,
|
||||
yuksek: flags.filter((f) => f.seviye === 'yuksek').length,
|
||||
orta: flags.filter((f) => f.seviye === 'orta').length,
|
||||
// Analiz edilebilen (sonuç verisi olan) ihale sayısı — şeffaflık için
|
||||
analiz: tenders.filter((t) => t.kazananFirma || t.sozlesmeBedeli != null || t.teklifSayisi != null).length,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Pazar yoğunlaşması (HHI) ---
|
||||
export function marketConcentration(tenders) {
|
||||
const { firms, totalMarket } = firmStats(tenders);
|
||||
if (!firms.length || !totalMarket) return { hhi: null, seviye: null, firms: [] };
|
||||
const hhi = firms.reduce((s, f) => s + Math.pow((f.total / totalMarket) * 100, 2), 0);
|
||||
const seviye = hhi < 1500 ? 'Rekabetçi' : hhi < 2500 ? 'Orta yoğunlaşma' : 'Yüksek yoğunlaşma';
|
||||
return { hhi: Math.round(hhi), seviye, firms };
|
||||
}
|
||||
|
||||
// --- Alım türü dağılımı ---
|
||||
export function alimTuruStats(tenders) {
|
||||
const map = new Map();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* PDF'ten metin çıkarma (tarayıcı içinde, pdfjs-dist ile).
|
||||
* Dinamik import edilir — ana bundle'a girmez.
|
||||
*/
|
||||
|
||||
export async function extractPdfText(file, { maxPages = 40 } = {}) {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
// Worker'ı Vite'ın bundle ettiği dosyadan al (CDN'e çıkmaz)
|
||||
const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
|
||||
const buf = await file.arrayBuffer();
|
||||
const doc = await pdfjs.getDocument({ data: buf }).promise;
|
||||
const pages = Math.min(doc.numPages, maxPages);
|
||||
const parts = [];
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const content = await page.getTextContent();
|
||||
parts.push(content.items.map((it) => it.str).join(' '));
|
||||
}
|
||||
const text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim();
|
||||
return { text, pageCount: doc.numPages, usedPages: pages };
|
||||
}
|
||||
|
||||
// Metin dosyaları için basit okuyucu
|
||||
export async function extractPlainText(file) {
|
||||
const text = await file.text();
|
||||
return { text: text.trim(), pageCount: 1, usedPages: 1 };
|
||||
}
|
||||
|
||||
export async function extractText(file) {
|
||||
const name = (file.name || '').toLowerCase();
|
||||
if (name.endsWith('.pdf') || file.type === 'application/pdf') return extractPdfText(file);
|
||||
return extractPlainText(file);
|
||||
}
|
||||
Reference in New Issue
Block a user