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:
2026-08-28 22:31:40 +03:00
parent e2512be9f7
commit 1838de95f5
9 changed files with 500 additions and 25 deletions
+13 -2
View File
@@ -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">
+117
View File
@@ -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>
);
};
+15 -2
View File
@@ -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">