feat: ihale dokümanları + OKAS kalem verisi + doğrudan temin (Playwright) + kalıcı arşiv
1) İhale evrakları (yeni)
- server/documents.mjs: EKAP doküman URL üretimi (imzalı), ilan metni +
OKAS kalem çıkarımı, Playwright ile doküman metni (varsa)
- /api/documents/url · /ilan · /text · /config uçları
- TenderDocuments paneli: 'EKAP Dokümanlarını Aç' + 'İlan Metnini AI ile
Analiz Et' (11K+ karakter gerçek ilan metni doğrudan AI'ya gider)
- NOT: eski EKAP host'u bot isteklerine bağlantı kesiyor; bağlantı
kullanıcının tarayıcısında açılır, sunucu tarafı metin için Chromium gerekir
2) Kalem verisi gerçek oldu
- ekap-scan: her ihale için IhaleDetay'dan OKAS ihtiyaç kalemleri çekilir
- history-store: okas_kalemler kolonu + otomatik şema geçişi
- --kalem-doldur modu: mevcut kayıtlara IKN'den çözerek kalem ekler
- scheduler: tarama sonrası kalem doldurmayı zincirler
- PriceAnalysis: yöntem şeffaflığı notu (birim fiyatlar kamuya açık değil;
kategori bazlı yaklaşık değer olduğu açıkça yazıyor)
3) Doğrudan temin (Playwright)
- Dockerfile: node:22-slim + nginx + Playwright/Chromium
- scheduler: DT worker periyodik (DT_INTERVAL_SAAT, varsayılan 4 saat)
- Bellek koruması: boş bellek DT_MIN_BOS_MB altındaysa tarama atlanır ve
uyarı loglanır; DT_ENABLED=0 ile tamamen kapatılabilir
4) Kalıcı arşiv
- Coolify'da /app/server/data volume bağlandı; arşiv artık deploy'lar
arası korunuyor
Ayrıca: TenderDetailModal'daki sabit '3-5 Firma' tahmini kaldırıldı, yerine
gerçek geçerli teklif sayısı gösteriliyor.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { TenderDocuments } from './TenderDocuments';
|
||||
import {
|
||||
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
|
||||
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
|
||||
@@ -460,8 +461,16 @@ END:VCALENDAR`;
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1">
|
||||
<span className="text-slate-300 text-xs font-semibold block">Tahmini Rekabet</span>
|
||||
<span className="text-lg font-black text-blue-300">3 - 5 Firma</span>
|
||||
<span className="text-slate-300 text-xs font-semibold block">
|
||||
{tender.gecerliTeklifSayisi != null || tender.teklifSayisi != null ? 'Katılan Firma' : 'Rekabet Verisi'}
|
||||
</span>
|
||||
<span className="text-lg font-black text-blue-300">
|
||||
{tender.gecerliTeklifSayisi != null
|
||||
? `${tender.gecerliTeklifSayisi} geçerli teklif`
|
||||
: tender.teklifSayisi != null
|
||||
? `${tender.teklifSayisi} teklif`
|
||||
: 'Sonuçlanınca gelir'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -476,6 +485,8 @@ END:VCALENDAR`;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TenderDocuments tender={tender} />
|
||||
|
||||
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
|
||||
<h4 className="font-bold text-sm text-slate-900">Kritik Yeterlik Şartları</h4>
|
||||
<ul className="space-y-2 text-xs text-slate-700">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { getToken } from '../services/auth';
|
||||
import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* İhale dokümanları paneli.
|
||||
* - EKAP doküman bağlantısını üretip kullanıcının tarayıcısında açar
|
||||
* - İlan metnini (ilan + OKAS kalemleri) çeker ve doğrudan AI analizine verir
|
||||
*/
|
||||
export const TenderDocuments = ({ tender }) => {
|
||||
const { analyzeSpecPdfWithGemini, setIsAiSpecAnalyzerOpen } = useApp();
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [bilgi, setBilgi] = useState(null);
|
||||
|
||||
const ikn = tender?.ikn;
|
||||
const ekapIhalesi = /^\d{4}\/\d+$/.test(String(ikn || ''));
|
||||
|
||||
const dokumaniAc = async () => {
|
||||
setBusy('url'); setError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
setBilgi({ dokumanSayisi: d.dokumanSayisi });
|
||||
window.open(d.url, '_blank', 'noopener,noreferrer');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const ilaniAnalizEt = async () => {
|
||||
setBusy('analiz'); setError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.');
|
||||
setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi });
|
||||
setIsAiSpecAnalyzerOpen(true);
|
||||
await analyzeSpecPdfWithGemini({ text: d.metin, fileName: `${d.ikn} — EKAP ilan metni`, tender });
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
|
||||
<h4 className="font-bold text-sm text-slate-900 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-blue-600" /> İhale Dokümanları
|
||||
</h4>
|
||||
|
||||
{!ekapIhalesi ? (
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Bu kayıt EKAP İKN formatında değil; doküman bağlantısı yalnızca EKAP ihaleleri için üretilebilir.
|
||||
{tender?.kaynakUrl && (
|
||||
<> İlanın kaynağını{' '}
|
||||
<a href={tender.kaynakUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-semibold hover:underline">
|
||||
buradan açabilirsiniz
|
||||
</a>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
İhale dokümanları EKAP üzerinde tutulur. Aşağıdaki bağlantı sizi doğrudan bu ihalenin
|
||||
EKAP doküman sayfasına götürür; ilan metnini ise analiz için buradan çekebilirsiniz.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={dokumaniAc}
|
||||
disabled={busy !== null}
|
||||
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
|
||||
>
|
||||
{busy === 'url' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5" />}
|
||||
EKAP Dokümanlarını Aç
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={ilaniAnalizEt}
|
||||
disabled={busy !== null}
|
||||
className="bg-amber-400 hover:bg-amber-300 disabled:opacity-40 text-slate-950 font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
|
||||
>
|
||||
{busy === 'analiz' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||
İlan Metnini AI ile Analiz Et
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bilgi && (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{bilgi.dokumanSayisi != null && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. </>}
|
||||
{bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi.</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-red-800 leading-relaxed">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed flex items-start gap-1.5">
|
||||
<Download className="w-3 h-3 shrink-0 mt-0.5" />
|
||||
EKAP, doküman indirmeyi kendi oturumu üzerinden yaptırır; bağlantı sizin tarayıcınızda açılır.
|
||||
Şartname dosyanız elinizdeyse AI analiz penceresinden doğrudan yükleyebilirsiniz.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -52,7 +52,7 @@ export const PriceAnalysis = () => {
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-slate-900">Kalem Bazlı Fiyat Analizi</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
Toplanan ihalelerdeki gerçek birim fiyatlardan hesaplanır · <strong>{uniqueItems}</strong> farklı kalem, <strong>{itemCount}</strong> fiyat kaydı.
|
||||
EKAP sonuç ilanlarındaki OKAS ihtiyaç kalemleri ve sözleşme bedellerinden hesaplanır · <strong>{uniqueItems}</strong> farklı kalem kategorisi, <strong>{itemCount}</strong> kayıt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,11 +64,24 @@ export const PriceAnalysis = () => {
|
||||
<Download className="w-4 h-4" /> CSV İndir
|
||||
</button>
|
||||
</div>
|
||||
{/* Yöntem şeffaflığı */}
|
||||
{items.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3.5 flex gap-2.5">
|
||||
<span className="text-blue-600 font-bold shrink-0">ℹ</span>
|
||||
<p className="text-[11px] text-blue-900 leading-relaxed">
|
||||
<strong>Yöntem:</strong> Türkiye'de ihale <em>teklif birim fiyatları</em> kamuya açık değildir.
|
||||
Buradaki tutarlar, EKAP sonuç ilanındaki <strong>sözleşme bedelinin</strong> ihalenin OKAS
|
||||
ihtiyaç kalemlerine bölünmesiyle elde edilen <strong>kategori bazlı yaklaşık</strong> değerlerdir —
|
||||
kesin birim fiyat değildir. Aynı OKAS kategorisindeki ihaleler arasında karşılaştırma için kullanın.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-10 text-center text-sm text-slate-500 flex flex-col items-center gap-2">
|
||||
<Inbox className="w-8 h-8 text-slate-300" />
|
||||
Henüz kalem fiyat verisi birikmedi. Kalem/birim fiyat içeren ihaleler (doğrudan temin sözleşme kalemleri) geldikçe bu analiz gerçek fiyatlarla otomatik dolar.
|
||||
Henüz kalem verisi birikmedi. EKAP sonuç ilanları ve OKAS ihtiyaç kalemleri arşive eklendikçe bu analiz otomatik dolar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
Reference in New Issue
Block a user