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
+55 -5
View File
@@ -15,7 +15,7 @@
*/
import {
searchTenders, fetchResults, normalizeCompanyName, DURUM_SONUC_ILANI,
searchTenders, fetchResults, getIhaleDetay, normalizeCompanyName, DURUM_SONUC_ILANI,
} from './ekap-v2.mjs';
import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs';
@@ -40,6 +40,7 @@ if (!bas || !son) {
const LIMIT = Number(arg('limit', 500));
const SAYFA = 50;
const ESZAMANLI = Number(arg('paralel', 6));
const ATLA_KALEM = has('kalemsiz'); // --kalemsiz ile OKAS çekimini kapat (daha hızlı)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function havuz(items, worker, n) {
@@ -56,6 +57,35 @@ async function havuz(items, worker, n) {
return sonuc;
}
// --- Mevcut kayıtlara OKAS kalemi doldur ---
// Eski kayıtlarda EKAP'ın anlık `id` token'ı saklanmadığı için IKN ile yeniden
// arama yapıp taze bir id alınır, sonra detaydan kalemler çekilir.
async function kalemDoldur(db, limit) {
const eksik = db.prepare(
'SELECT ikn FROM sonuclar WHERE okas_kalemler IS NULL GROUP BY ikn LIMIT ?'
).all(limit);
console.log(`[ekap-scan] OKAS kalemi eksik ${eksik.length} ihale işlenecek`);
let ok = 0, bos = 0, hata = 0;
const upd = db.prepare('UPDATE sonuclar SET okas_kalemler=? WHERE ikn=?');
await havuz(eksik, async (row) => {
const [yil, sayi] = String(row.ikn).split('/');
if (!yil || !sayi) { bos++; return; }
const r = await searchTenders({ iknYili: Number(yil), iknSayi: Number(sayi), take: 1 });
const t = r.list[0];
if (!t) { bos++; return; }
const d = await getIhaleDetay(t.id);
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
if (!Array.isArray(okas) || !okas.length) { bos++; return; }
upd.run(JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi }))), row.ikn);
ok++;
if ((ok + bos) % 25 === 0) console.log(`[ekap-scan] kalem doldurma: ${ok} dolduruldu, ${bos} boş`);
}, ESZAMANLI).catch(() => { hata++; });
console.log(`[ekap-scan] KALEM DOLDURMA BİTTİ — ${ok} ihaleye kalem yazıldı, ${bos} kalemsiz, ${hata} hata.`);
}
async function main() {
if (!available()) {
console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.');
@@ -63,12 +93,22 @@ async function main() {
}
const db = openDb({ create: true });
const baslangic = new Date().toISOString();
// Kalem doldurma modu: mevcut kayıtlara OKAS ekler, tarama yapmaz
const doldur = arg('kalem-doldur');
if (doldur) {
await kalemDoldur(db, Number(doldur) || 200);
db.close();
return;
}
console.log(`[ekap-scan] aralık ${bas} → ${son}, limit ${LIMIT}, paralel ${ESZAMANLI}`);
let skip = 0;
let taranan = 0;
let eklenen = 0;
let hata = 0;
let kalemli = 0;
let toplamHavuz = null;
while (taranan < LIMIT) {
@@ -102,17 +142,27 @@ async function main() {
const rs = await fetchResults(t.id);
if (!rs.length) return { bos: true };
for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma);
// OKAS ihtiyaç kalemleri (kategori bazlı fiyat analizi için)
if (!ATLA_KALEM) {
try {
const d = await getIhaleDetay(t.id);
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
if (Array.isArray(okas) && okas.length) {
t.okasJson = JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi })));
}
} catch { /* kalem alınamazsa sonucu yine de kaydet */ }
}
const n = saveResults(db, t, rs);
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma };
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma, kalem: t.okasJson ? 1 : 0 };
}, ESZAMANLI);
for (const s of sonuclar) {
if (s?.hata) hata++;
else if (s?.kayit) eklenen += s.kayit;
else if (s?.kayit) { eklenen += s.kayit; kalemli += s.kalem || 0; }
}
taranan += sayfa.list.length;
skip += sayfa.list.length;
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${atlanan} atlandı · ${hata} hata`);
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${kalemli} kalemli · ${atlanan} atlandı · ${hata} hata`);
if (skip >= toplamHavuz) break;
await sleep(300); // EKAP'ı yormamak için nazik ara
@@ -124,7 +174,7 @@ async function main() {
not: `havuz=${toplamHavuz}`,
});
db.close();
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı yazıldı, ${hata} hata.`);
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı (${kalemli} OKAS kalemli), ${hata} hata.`);
}
main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); });