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
+38 -3
View File
@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS sonuclar (
yaklasik_maliyet REAL,
teklif_sayisi INTEGER,
gecerli_teklif_sayisi INTEGER,
okas_kalemler TEXT,
cekilme_zamani TEXT,
PRIMARY KEY (ikn, kisim)
);
@@ -67,6 +68,16 @@ export function openDb({ create = false } = {}) {
}
const db = new DatabaseSync(DB_PATH);
db.exec(SCHEMA);
// Geçiş: eski şemada okas_kalemler yoksa ekle
try {
const kolonlar = db.prepare('PRAGMA table_info(sonuclar)').all().map((c) => c.name);
if (!kolonlar.includes('okas_kalemler')) {
db.exec('ALTER TABLE sonuclar ADD COLUMN okas_kalemler TEXT');
console.log('[history-store] okas_kalemler kolonu eklendi');
}
} catch (e) {
console.warn('[history-store] şema geçişi atlandı:', e.message);
}
return db;
}
@@ -74,12 +85,13 @@ export function saveResults(db, tender, results) {
const stmt = db.prepare(`
INSERT INTO sonuclar (ikn,kisim,ihale_adi,idare_adi,il,ihale_tipi,ihale_tarihi,ilan_tarihi,
kazanan_firma,kazanan_norm,kazanan_adres,sozlesme_bedeli,para_birimi,sozlesme_tarihi,
yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,cekilme_zamani)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,okas_kalemler,cekilme_zamani)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(ikn,kisim) DO UPDATE SET
kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm,
sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet,
teklif_sayisi=excluded.teklif_sayisi, gecerli_teklif_sayisi=excluded.gecerli_teklif_sayisi,
okas_kalemler=COALESCE(excluded.okas_kalemler, sonuclar.okas_kalemler),
cekilme_zamani=excluded.cekilme_zamani
`);
const now = new Date().toISOString();
@@ -92,6 +104,7 @@ export function saveResults(db, tender, results) {
r.kazananFirma || null, r.kazananNorm || null, r.kazananAdres || null,
r.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null,
r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null,
tender.okasJson ?? null,
now
);
n++;
@@ -106,6 +119,28 @@ export function hasIkn(db, ikn) {
// --- Okuma tarafı (API için) --------------------------------------------
// OKAS kalem listesi JSON'unu tender.items biçimine çevirir.
// NOT: teklif birim fiyatları kamuya açık DEĞİL. Burada üretilen 'birimFiyat',
// sözleşme bedelinin kalem sayısına bölünmesiyle elde edilen YAKLAŞIK bir
// göstergedir; kesin birim fiyat değildir.
function okasParse(json, bedel) {
if (!json) return [];
try {
const list = JSON.parse(json);
if (!Array.isArray(list) || !list.length) return [];
const pay = bedel != null && list.length ? Number(bedel) / list.length : null;
return list.map((k, i) => ({
id: `okas-${i}`,
kod: k.kodu || null,
ad: k.adi || k.koduAdi || 'Kalem',
miktar: null,
birim: null,
birimFiyat: pay,
yaklasik: true,
}));
} catch { return []; }
}
function rowToTender(r) {
return {
id: `hist-${r.ikn}-${r.kisim}`,
@@ -125,7 +160,7 @@ function rowToTender(r) {
teklifSayisi: r.teklif_sayisi,
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
durum: 'Sonuçlandı',
items: [],
items: okasParse(r.okas_kalemler, r.sozlesme_bedeli),
// Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan)
eklenmeZamani: 'arşivden',
isYeni: false,