/** * Gerçek ihale verisinden (context'teki tenders dizisi) hesaplanan analizler. * Sabit mock yok — ne yüklüyse ondan üretir. Veri arttıkça (DT worker, sonuç * ilanları) analizler otomatik zenginleşir. */ // "22.800,00 TRY" / "1.234.567,89 TL" gibi metinleri sayıya çevir export function parseTL(v) { if (v == null) return null; if (typeof v === 'number') return isFinite(v) ? v : null; const s = String(v).replace(/[^\d.,]/g, ''); if (!s) return null; const norm = s.replace(/\./g, '').replace(',', '.'); const n = parseFloat(norm); return isFinite(n) ? n : null; } export function formatTL(n) { if (n == null || !isFinite(n)) return '—'; return n.toLocaleString('tr-TR', { maximumFractionDigits: 0 }) + ' ₺'; } // İl plaka koduna göre density için normalize edilmiş il adı export function normProvince(name) { return (name || '').toLocaleUpperCase('tr-TR').trim(); } // --- Kazanan firma / rakip analizi --- export function firmStats(tenders) { const map = new Map(); let awardCount = 0; // Kısmi ihale tuzağı: yaklaşık maliyet TÜM ihale için okunuyor, sözleşme // bedeli ise kısım başına. 11 kısımlık ilaç ihalesinin ilk kısmı 1,6 milyon // tahmine karşı 25 bine verilmişse bu %98 kırım değil, aritmetik kurgudur. // Kırım yalnızca tek sonuç satırı olan (kısımsız) ihalelerde hesaplanır; // yanlış bir oran, hiç oran olmamasından kötüdür. const iknSatir = new Map(); for (const t of tenders) if (t.kazananFirma && t.ikn) iknSatir.set(t.ikn, (iknSatir.get(t.ikn) || 0) + 1); for (const t of tenders) { if (!t.kazananFirma) continue; awardCount++; const name = t.kazananFirma.trim(); const val = parseTL(t.sozlesmeBedeli); const cur = map.get(name) || { name, wins: 0, total: 0, sectors: new Set(), cities: new Set(), // Kazanan istihbaratı: kırım oranı (yaklaşık maliyete göre ne kadar // aşağı verdi) ve rekabet derinliği (kaç istekliye karşı kazandı) kirimToplam: 0, kirimAdet: 0, teklifToplam: 0, teklifAdet: 0, tekTeklif: 0, }; cur.wins++; if (val != null) cur.total += val; const maliyet = parseTL(t.yaklasikMaliyet); const kisimsiz = (iknSatir.get(t.ikn) || 1) === 1; if (kisimsiz && val != null && maliyet > 0 && val <= maliyet * 1.5) { cur.kirimToplam += 1 - val / maliyet; cur.kirimAdet++; } const teklif = Number(t.gecerliTeklifSayisi ?? t.teklifSayisi); if (Number.isFinite(teklif) && teklif > 0) { cur.teklifToplam += teklif; cur.teklifAdet++; if (teklif === 1) cur.tekTeklif++; } if (t.sektor) cur.sectors.add(t.sektor); if (t.sehir) cur.cities.add(t.sehir); map.set(name, cur); } const firms = [...map.values()] .map((f) => ({ ...f, sectors: [...f.sectors], cities: [...f.cities], // yüzde; veri yoksa null — 0 ile karıştırılmasın kirimOrani: f.kirimAdet ? Math.round((f.kirimToplam / f.kirimAdet) * 100) : null, ortTeklif: f.teklifAdet ? Math.round((f.teklifToplam / f.teklifAdet) * 10) / 10 : null, tekTeklifOrani: f.teklifAdet ? Math.round((f.tekTeklif / f.teklifAdet) * 100) : null, })) .sort((a, b) => b.wins - a.wins || b.total - a.total); const totalMarket = firms.reduce((s, f) => s + f.total, 0); return { firms: firms.map((f) => ({ ...f, share: totalMarket ? Math.round((f.total / totalMarket) * 1000) / 10 : 0 })), awardCount, totalMarket, }; } // --- Kalem bazlı fiyat analizi --- export function itemStats(tenders) { const map = new Map(); let itemCount = 0; for (const t of tenders) { for (const it of t.items || []) { const bf = parseTL(it.birimFiyat); if (bf == null) continue; itemCount++; const key = (it.ad || 'Diğer').trim().toLocaleUpperCase('tr-TR'); const cur = map.get(key) || { ad: it.ad, birim: it.birim, count: 0, prices: [] }; cur.count++; cur.prices.push(bf); map.set(key, cur); } } const items = [...map.values()] .map((i) => { const sorted = [...i.prices].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return { ad: i.ad, birim: i.birim, count: i.count, min: sorted[0], max: sorted[sorted.length - 1], median: sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2, avg: i.prices.reduce((s, p) => s + p, 0) / i.prices.length, }; }) .sort((a, b) => b.count - a.count); return { items, itemCount, uniqueItems: items.length }; } // --- İl bazlı yoğunluk (harita için) --- export function provinceDensity(tenders) { const map = new Map(); for (const t of tenders) { const p = normProvince(t.sehir); if (!p) continue; map.set(p, (map.get(p) || 0) + 1); } return map; // İL_ADI(upper) -> sayı } // --- Sektör dağılımı --- export function sectorStats(tenders) { const map = new Map(); for (const t of tenders) { const s = t.sektor || 'Genel'; map.set(s, (map.get(s) || 0) + 1); } return [...map.entries()].map(([sektor, count]) => ({ sektor, count })).sort((a, b) => b.count - a.count); } // "10.08.2026" → Date export function parseDateTR(s) { if (!s) return null; const m = String(s).match(/(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})/); if (!m) return null; return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])); } // --- Firmanın kendi performansı (kullanıcının kanban/takip durumundan) --- const BID_STATES = ['teklif_verildi', 'kazanildi', 'kaybedildi']; export function companyStats(tenders) { const won = tenders.filter((t) => t.kanbanStatus === 'kazanildi'); const lost = tenders.filter((t) => t.kanbanStatus === 'kaybedildi'); const bid = tenders.filter((t) => BID_STATES.includes(t.kanbanStatus)); const wonTotal = won.reduce( (s, t) => s + (parseTL(t.sozlesmeBedeli) ?? parseTL(t.yaklasikMaliyet) ?? 0), 0 ); const resulted = won.length + lost.length; const kanban = {}; for (const st of ['incelenecek', 'inceleniyor', 'teklif_hazirlaniyor', 'teklif_verildi', 'kazanildi', 'kaybedildi']) { kanban[st] = tenders.filter((t) => (t.kanbanStatus || '') === st).length; } return { participated: bid.length, won: won.length, lost: lost.length, resulted, winRate: resulted ? Math.round((won.length / resulted) * 100) : 0, wonTotal, tracked: tenders.filter((t) => t.takipte).length, reviewed: tenders.filter((t) => t.incelendi || (t.kanbanStatus && t.kanbanStatus !== 'incelenecek')).length, contracts: won.length, }; } // --- Aylık kazanım / katılım hacmi (son 7 ay) --- const AY_KISA = ['Oca', 'Şub', 'Mar', 'Nis', 'May', 'Haz', 'Tem', 'Ağu', 'Eyl', 'Eki', 'Kas', 'Ara']; export function monthlyStats(tenders) { const map = new Map(); for (const t of tenders) { if (!BID_STATES.includes(t.kanbanStatus)) continue; const d = parseDateTR(t.tarih); if (!d) continue; const key = `${d.getFullYear()}-${String(d.getMonth()).padStart(2, '0')}`; const cur = map.get(key) || { y: d.getFullYear(), m: d.getMonth(), kazanim: 0, katilim: 0 }; const val = parseTL(t.sozlesmeBedeli) ?? parseTL(t.yaklasikMaliyet) ?? 0; cur.katilim += val; if (t.kanbanStatus === 'kazanildi') cur.kazanim += val; map.set(key, cur); } return [...map.values()] .sort((a, b) => a.y - b.y || a.m - b.m) .slice(-7) .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ı const dtKurum = new Map(); // "kurum||sektor" -> [{t, d}] (doğrudan temin parçalama) 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.', }); } } // 6) Kısa süre: ilan → ihale tarihi 7 günden az (açık ihalelerde) const ilanT = parseDateTR(t.ilanTarihi); const ihaleT = parseDateTR(t.tarih); if (!t.kazananFirma && ilanT && ihaleT && ihaleT > ilanT && (ihaleT - ilanT) / 86400000 < 7) { flags.push({ id: `sure-${t.id}`, seviye: 'orta', tur: 'Kısa süre', tender: t, deger: `${Math.round((ihaleT - ilanT) / 86400000)} gün`, aciklama: 'İlan ile ihale tarihi arasında 7 günden az var — hazırlık süresi yetersiz, katılım daralır.', }); } // 7) Hafta sonu ihale tarihi if (!t.kazananFirma && ihaleT && (ihaleT.getDay() === 0 || ihaleT.getDay() === 6)) { flags.push({ id: `hsonu-${t.id}`, seviye: 'dusuk', tur: 'Hafta sonu tarih', tender: t, deger: ihaleT.getDay() === 6 ? 'Cumartesi' : 'Pazar', aciklama: 'İhale tarihi hafta sonuna düşüyor; idare kapalıyken soru sorulamaz, teslim riski artar.', }); } // 8) Geç sözleşme: ihale tarihinden 180+ gün sonra sözleşme const sozT = parseDateTR(t.sozlesmeTarihi); if (sozT && ihaleT && (sozT - ihaleT) / 86400000 > 180) { flags.push({ id: `gec-${t.id}`, seviye: 'orta', tur: 'Geç sözleşme', tender: t, deger: `${Math.round((sozT - ihaleT) / 86400000)} gün`, aciklama: 'Sözleşme ihaleden 180 günden uzun süre sonra imzalanmış — uzun değerlendirme, teklif geçerlilik ve fiyat riski.', }); } // 9) Kazananı açıklanmamış sonuç if (t.durum === 'Sonuçlandı' && !t.kazananFirma && (bedel != null || t.teklifSayisi != null)) { flags.push({ id: `gizli-${t.id}`, seviye: 'orta', tur: 'Kazananı gizli', tender: t, aciklama: 'Sonuç ilanı var ama kazanan firma adı verilmemiş — şeffaflık eksiği.', }); } // 10) Parçalama şüphesi (sayaç): aynı idare, aynı sektör, 30 günde 3+ doğrudan temin if (/doğrudan|dt\b/i.test(String(t.alimTuru || t.usul || '')) && t.kurum && ihaleT) { const key = `${t.kurum.trim()}||${t.sektor || ''}`; const arr = dtKurum.get(key) || []; arr.push({ t, d: ihaleT }); dtKurum.set(key, arr); } 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ış.`, }); } } // 10) Parçalama: 30 günlük pencerede aynı idare + sektörde 3+ doğrudan temin for (const [key, arr] of dtKurum) { if (arr.length < 3) continue; arr.sort((a, b) => a.d - b.d); for (let i = 0; i + 2 < arr.length; i++) { if ((arr[i + 2].d - arr[i].d) / 86400000 <= 30) { const [kurum, sektor] = key.split('||'); flags.push({ id: `parca-${key}-${arr[i].t.id}`, seviye: 'yuksek', tur: 'Parçalama şüphesi', tender: arr[i].t, kurum, deger: `${arr.length} DT / 30 gün`, aciklama: `${kurum}, ${sektor || 'aynı'} alanında 30 gün içinde ${arr.length} doğrudan temin yapmış — ihaleye çıkmaktan kaçınma (parçalama) şüphesi, 4734 md. 5 ve 22/d sınırı.`, }); break; } } } 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(); for (const t of tenders) { const a = t.alimTuru || 'Diğer'; map.set(a, (map.get(a) || 0) + 1); } return [...map.entries()].map(([tur, count]) => ({ tur, count })).sort((a, b) => b.count - a.count); }