Files
ihaleist/src/services/analytics.js
T
hikmettanandClaude Fable 5.1 08c0a0af7e Kullanıcı testi hataları: sabit tarihler, kişisel varsayılanlar, kılavuz düğmesi, mobil başlık, AI taslak çökmesi
- Günlük Bülten ve İhale Takvimi sabit "10.08.2026" yerine bugünden hesaplar;
  takvim 7 günü gerçek ihale tarihleriyle sayar
- Ekip/üye/firma varsayılanları oturumdaki kullanıcıdan; sahte "Ekip Notları"
  ve sabit "%94 Uyum" rozeti kaldırıldı (gerçek uyum skoru varsa gösterilir)
- Kılavuz düğmesi document.body'ye portal (ekran dışına düşüyordu)
- Detay modalı havuzdaki güncel kaydı okur: Takip Et / Görev aç etiketleri güncellenir
- Mobil: başlıkta ikincil düğmeler md altında gizli, arama formu küçülebilir
- PWA sayfası: sahte önbellek sayıları yerine Cache API ve bellek sayımı
- Sözleşme sorgula pazar payı bir ondalık (hep %0 görünüyordu)
- Rakip Radar "SMS" vaadi düzeltildi; sektör=CPV etiketi tekrarı gizlendi;
  Stripe kurulum uyarısı yalnız yöneticiye
- Açılışta 7 gereksiz PUT /api/me/data: ilk yükleme yazımı atlanır
- server: teklif taslağı çökmesi (kalemOku nesne döndürür), denetim yanıtından
  model adı kaldırıldı, gereklilikler satır bazlı; derin analiz 12k token +
  kesik JSON onarımı

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 09:43:41 +03:00

359 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}