diff --git a/Dockerfile b/Dockerfile index 71b5699..7b28168 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,42 @@ # Stage 1: Vite SPA build -FROM node:22-alpine AS build +FROM node:22-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build -# Stage 2: nginx (frontend) + node (canlı veri backend) tek container'da -FROM node:22-alpine -RUN apk add --no-cache nginx +# Stage 2: nginx (frontend) + node (canlı veri backend) + Playwright/Chromium +# NOT: Chromium yalnızca doğrudan temin (EKAP anti-bot) ve ihale dokümanı +# indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir; +# uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır. +FROM node:22-slim +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends nginx ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Playwright + Chromium (yalnızca chromium; sistem bağımlılıklarıyla) +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN npm init -y >/dev/null \ + && npm i --omit=dev playwright@1.62.1 \ + && npx playwright install --with-deps chromium \ + && npm cache clean --force # Frontend COPY --from=build /app/dist /usr/share/nginx/html -COPY --from=build /app/nginx.conf /etc/nginx/http.d/default.conf +COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default -# Backend (yalnızca Node builtin'leri kullanır — npm bağımlılığı yok) -WORKDIR /app +# Backend (Node builtin'leri + playwright) COPY --from=build /app/server ./server -# Geçmiş sonuç veritabanı (SQLite). Deploy'lar arası kalıcılık için Coolify'da -# /app/server/data yoluna bir volume bağlanmalı; bağlanmazsa tarayıcı veriyi -# her yeniden dağıtımdan sonra yeniden biriktirir. +# Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı) RUN mkdir -p /app/server/data VOLUME ["/app/server/data"] -# Başlangıç: Node backend'i (sabit 3001) arka planda + nginx (80) ön planda. -# Coolify container'a PORT=80 verir; backend'i 3001'e sabitleyip nginx ile -# çakışmasını önlüyoruz (nginx /api → 127.0.0.1:3001 proxy'ler). +# Başlangıç: Node backend (3001) arka planda + nginx (80) ön planda. RUN printf '#!/bin/sh\nset -e\nPORT=3001 node /app/server/index.js &\nexec nginx -g "daemon off;"\n' > /start.sh \ && chmod +x /start.sh diff --git a/server/documents.mjs b/server/documents.mjs new file mode 100644 index 0000000..ab948df --- /dev/null +++ b/server/documents.mjs @@ -0,0 +1,134 @@ +/** + * İhale dokümanları — EKAP doküman URL'i üretimi + (Playwright varsa) metin çıkarma. + * + * Neden Playwright: doküman URL'i EKAP v2 API'sinden imzalı olarak üretilebiliyor, + * ancak eski EKAP host'u (ekap.kik.gov.tr) otomatik istemcilere bağlantıyı kesiyor + * (BIG-IP anti-bot → ECONNRESET). Kullanıcı tarayıcısında link sorunsuz açılır; + * sunucu tarafında metin çıkarmak için gerçek tarayıcı gerekir. + * + * Chromium yoksa: URL yine döner (kullanıcı kendi tarayıcısında açar), + * yalnızca sunucu tarafı metin çıkarma devre dışı kalır. + */ + +import { searchTenders, ekapPost, getIhaleDetay } from './ekap-v2.mjs'; + +const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; + +/** IKN'den taze bir EKAP kaydı bulur (id token anlıktır, saklanamaz). */ +export async function findByIkn(ikn) { + const [yil, sayi] = String(ikn || '').split('/'); + if (!yil || !sayi) throw new Error('Geçersiz İKN'); + const r = await searchTenders({ iknYili: Number(yil), iknSayi: Number(sayi), take: 1 }); + const t = r.list[0]; + if (!t) throw new Error('İhale EKAP’ta bulunamadı'); + return t; +} + +/** İhale dokümanlarının EKAP görüntüleme/indirme adresini üretir. */ +export async function documentUrl(ikn) { + const t = await findByIkn(ikn); + const d = await ekapPost('/b_ihalearama/api/EkapDokumanYonlendirme/GetDokumanUrl', { + islemId: '1', + ihaleId: t.id, + }); + if (!d?.success || !d?.url) { + throw new Error(d?.message || 'Bu ihale için doküman bağlantısı üretilemedi'); + } + return { + url: d.url, + ikn: t.ikn, + ihaleAdi: t.ihaleAdi, + idareAdi: t.idareAdi, + dokumanSayisi: t.dokumanSayisi ?? 0, + dokumanListe: t.dokumanListe || [], + }; +} + +/** İhale ilan metni + kalem listesi (dokümana gerek kalmadan analiz için). */ +export async function ilanMetni(ikn) { + const t = await findByIkn(ikn); + const d = await getIhaleDetay(t.id); + const bilgi = d?.ihaleBilgi || {}; + const okas = d?.ihtiyacKalemiOkasList || []; + const parcalar = [ + `İhale adı: ${d?.ihaleAdi || t.ihaleAdi || ''}`, + `İKN: ${d?.ikn || t.ikn || ''}`, + `İdare: ${d?.idareAdi || t.idareAdi || ''}`, + `Usul: ${bilgi.ihaleUsulAciklama || d?.ihaleUsul || ''}`, + `Tür: ${bilgi.ihaleTipiAciklama || ''}`, + `İhale tarihi: ${bilgi.ihaleTarihSaat || ''}`, + `İşin yapılacağı yer: ${bilgi.isinYapilacagiYer || ''}`, + `İhale yeri: ${bilgi.ihaleYeri || ''}`, + bilgi.itirazenSikayetBasvuruBedeli ? `İtirazen şikâyet bedeli: ${bilgi.itirazenSikayetBasvuruBedeli}` : '', + okas.length ? `İhtiyaç kalemleri (OKAS):\n` + okas.map((k) => ` - ${k.kodu} ${k.adi || k.koduAdi}`).join('\n') : '', + Array.isArray(d?.ihaleOzellikList) && d.ihaleOzellikList.length + ? 'Özellikler:\n' + d.ihaleOzellikList.map((o) => ` - ${o.ihaleOzellik}`).join('\n') : '', + ].filter(Boolean); + + // İlan HTML'i varsa düz metne çevir (en zengin kaynak) + const ilanlar = d?.ilanList || []; + for (const il of ilanlar) { + if (il.veriHtml) { + parcalar.push(`\n--- ${il.ilanTipAciklama || 'İLAN'} (${il.ilanTarihi || ''}) ---`); + parcalar.push(htmlToText(il.veriHtml)); + } + } + + return { + ikn: t.ikn, + ihaleAdi: d?.ihaleAdi || t.ihaleAdi, + idareAdi: d?.idareAdi || t.idareAdi, + ilanSayisi: ilanlar.length, + okasSayisi: okas.length, + metin: parcalar.join('\n'), + }; +} + +const ENT = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }; +function htmlToText(html) { + return String(html || '') + .replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ') + .replace(/<\s*\/\s*(tr|p|div|h\d)\s*>/gi, '\n') + .replace(/<\s*\/\s*t[dh]\s*>/gi, ' | ') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/&[a-z#0-9]+;/gi, (m) => ENT[m.toLowerCase()] ?? ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Chromium var mı? (DT worker ile aynı bağımlılık) */ +export async function browserAvailable() { + try { + await import('playwright'); + return true; + } catch { + return false; + } +} + +/** + * Doküman sayfasını gerçek tarayıcıyla açıp metnini çıkarır. + * Chromium yoksa anlaşılır hata verir. + */ +export async function documentText(ikn, { timeout = 60000 } = {}) { + let chromium; + try { + ({ chromium } = await import('playwright')); + } catch { + throw new Error('Sunucuda tarayıcı bileşeni yok — doküman metni çıkarılamıyor. Bağlantıyı kendi tarayıcınızda açabilirsiniz.'); + } + const { url, ihaleAdi } = await documentUrl(ikn); + const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] }); + try { + const ctx = await browser.newContext({ userAgent: UA, locale: 'tr-TR' }); + const page = await ctx.newPage(); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout }); + await page.waitForTimeout(1500); + const metin = await page.evaluate(() => document.body?.innerText || ''); + return { ikn, ihaleAdi, url, metin: String(metin).replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() }; + } finally { + await browser.close().catch(() => {}); + } +} diff --git a/server/ekap-scan.mjs b/server/ekap-scan.mjs index 9031907..e5c4383 100644 --- a/server/ekap-scan.mjs +++ b/server/ekap-scan.mjs @@ -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); }); diff --git a/server/history-store.mjs b/server/history-store.mjs index 5559240..4a71e02 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -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, diff --git a/server/index.js b/server/index.js index b1daed5..3323ce3 100644 --- a/server/index.js +++ b/server/index.js @@ -24,6 +24,7 @@ import { searchTed } from './ted.mjs'; import { readHistory, historyStats } from './history-store.mjs'; import { startScheduler } from './scheduler.mjs'; import { searchKik, kararMetni } from './kik.mjs'; +import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs'; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. @@ -343,6 +344,45 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { success: true, user }); } + // --- İhale dokümanları --- + if (url.pathname === '/api/documents/url') { + try { + const ikn = url.searchParams.get('ikn'); + if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); + const out = await documentUrl(ikn); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + // İlan metni + kalemler — analiz için (tarayıcı gerektirmez) + if (url.pathname === '/api/documents/ilan') { + try { + const ikn = url.searchParams.get('ikn'); + if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); + const out = await ilanMetni(ikn); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + // Doküman metni — sunucu tarafı tarayıcı gerektirir + if (url.pathname === '/api/documents/text') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + try { + const ikn = url.searchParams.get('ikn'); + if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' }); + const out = await documentText(ikn); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + if (url.pathname === '/api/documents/config') { + return json(res, 200, { tarayiciVar: await browserAvailable() }); + } + // --- KİK kurul kararları --- if (url.pathname === '/api/kik/search') { try { diff --git a/server/scheduler.mjs b/server/scheduler.mjs index e7c6613..519f608 100644 --- a/server/scheduler.mjs +++ b/server/scheduler.mjs @@ -10,6 +10,7 @@ */ import { spawn } from 'node:child_process'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { historyStats } from './history-store.mjs'; @@ -54,6 +55,8 @@ function tara() { p.on('close', (code) => { calisiyor = false; console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`); + // Taramanın ardından eksik OKAS kalemlerini doldur (fiyat analizi için) + if (code === 0 && process.env.KALEM_DOLDUR !== '0') kalemDoldur(); }); p.on('error', (e) => { calisiyor = false; @@ -61,6 +64,58 @@ function tara() { }); } +// Eksik OKAS kalemlerini arka planda doldurur (kategori bazlı fiyat analizi) +function kalemDoldur() { + const n = process.env.KALEM_DOLDUR_LIMIT || '150'; + const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--kalem-doldur', n], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + p.stdout.on('data', (d) => process.stdout.write(String(d))); + p.on('error', (e) => console.warn('[scheduler] kalem doldurma başlatılamadı:', e.message)); +} + +// --- Doğrudan temin (Playwright/Chromium gerektirir) --- +let dtCalisiyor = false; + +// Chromium ~350MB RSS kullanabilir; sunucu darsa DT'yi atla ve uyar. +function bellekYeterliMi() { + const bosMB = Math.round(os.freemem() / 1024 / 1024); + const esik = Number(process.env.DT_MIN_BOS_MB || 600); + if (bosMB < esik) { + console.warn(`[scheduler] ⚠ Sunucu belleği dar (${bosMB}MB boş < ${esik}MB) — doğrudan temin taraması bu tur atlandı. Kalıcı sorun olursa DT_ENABLED=0 ile kapatın.`); + return false; + } + return true; +} + +function dtTara() { + if (process.env.DT_ENABLED === '0') return; + if (dtCalisiyor) { + console.log('[scheduler] önceki DT taraması sürüyor, bu tur atlandı'); + return; + } + if (!bellekYeterliMi()) return; + dtCalisiyor = true; + const pages = process.env.DT_PAGES || '5'; + const details = process.env.DT_DETAILS || '30'; + console.log(`[scheduler] doğrudan temin taraması başlıyor (${pages} sayfa, ${details} detay)`); + + const p = spawn(process.execPath, [path.join(__dirname, 'dt-worker.mjs'), '--pages', pages, '--details', details], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + p.stdout.on('data', (d) => process.stdout.write(String(d))); + p.stderr.on('data', (d) => process.stderr.write(String(d))); + p.on('close', (code) => { + dtCalisiyor = false; + if (code !== 0) console.warn(`[scheduler] ⚠ DT taraması ${code} koduyla bitti — Chromium kurulu mu / bellek yeterli mi kontrol edin.`); + else console.log('[scheduler] DT taraması bitti'); + }); + p.on('error', (e) => { + dtCalisiyor = false; + console.warn('[scheduler] ⚠ DT worker başlatılamadı:', e.message, '— Chromium yoksa DT_ENABLED=0 yapın.'); + }); +} + export function startScheduler() { if (process.env.SCAN_ENABLED === '0') { console.log('[scheduler] devre dışı (SCAN_ENABLED=0)'); @@ -72,4 +127,14 @@ export function startScheduler() { // Açılıştan 90 sn sonra ilk tur (sunucu ısınsın), sonra periyodik setTimeout(tara, 90 * 1000).unref?.(); setInterval(tara, ms).unref?.(); + + // Doğrudan temin: sonuç taramasıyla çakışmasın diye kaydırmalı + if (process.env.DT_ENABLED === '0') { + console.log('[scheduler] doğrudan temin devre dışı (DT_ENABLED=0)'); + } else { + const dtSaat = Number(process.env.DT_INTERVAL_SAAT || 4); + console.log(`[scheduler] doğrudan temin taraması her ${dtSaat} saatte bir çalışacak`); + setTimeout(dtTara, 4 * 60 * 1000).unref?.(); + setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.(); + } } diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 29de7ae..55e547f 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -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`;
- Tahmini Rekabet - 3 - 5 Firma + + {tender.gecerliTeklifSayisi != null || tender.teklifSayisi != null ? 'Katılan Firma' : 'Rekabet Verisi'} + + + {tender.gecerliTeklifSayisi != null + ? `${tender.gecerliTeklifSayisi} geçerli teklif` + : tender.teklifSayisi != null + ? `${tender.teklifSayisi} teklif` + : 'Sonuçlanınca gelir'} +
@@ -476,6 +485,8 @@ END:VCALENDAR`; + +

Kritik Yeterlik Şartları

    diff --git a/src/components/TenderDocuments.jsx b/src/components/TenderDocuments.jsx new file mode 100644 index 0000000..5cf2238 --- /dev/null +++ b/src/components/TenderDocuments.jsx @@ -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 ( +
    +

    + İhale Dokümanları +

    + + {!ekapIhalesi ? ( +

    + 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ı{' '} + + buradan açabilirsiniz + . + + )} +

    + ) : ( + <> +

    + İ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. +

    + +
    + + + +
    + + {bilgi && ( +

    + {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.} +

    + )} + + {error && ( +
    + +

    {error}

    +
    + )} + +

    + + 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. +

    + + )} +
    + ); +}; diff --git a/src/components/tools/PriceAnalysis.jsx b/src/components/tools/PriceAnalysis.jsx index 1cc319a..f87012c 100644 --- a/src/components/tools/PriceAnalysis.jsx +++ b/src/components/tools/PriceAnalysis.jsx @@ -52,7 +52,7 @@ export const PriceAnalysis = () => {

    Kalem Bazlı Fiyat Analizi

    - Toplanan ihalelerdeki gerçek birim fiyatlardan hesaplanır · {uniqueItems} farklı kalem, {itemCount} fiyat kaydı. + EKAP sonuç ilanlarındaki OKAS ihtiyaç kalemleri ve sözleşme bedellerinden hesaplanır · {uniqueItems} farklı kalem kategorisi, {itemCount} kayıt.

@@ -64,11 +64,24 @@ export const PriceAnalysis = () => { CSV İndir + {/* Yöntem şeffaflığı */} + {items.length > 0 && ( +
+ ℹ +

+ Yöntem: Türkiye'de ihale teklif birim fiyatları kamuya açık değildir. + Buradaki tutarlar, EKAP sonuç ilanındaki sözleşme bedelinin ihalenin OKAS + ihtiyaç kalemlerine bölünmesiyle elde edilen kategori bazlı yaklaşık değerlerdir — + kesin birim fiyat değildir. Aynı OKAS kategorisindeki ihaleler arasında karşılaştırma için kullanın. +

+
+ )} + {items.length === 0 ? (
- 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.
) : (