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
+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>
);
};