- 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>
359 lines
14 KiB
JavaScript
359 lines
14 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|