From 40fea290e416c2031dae21010e7bff8c76b46a11 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 30 Aug 2026 14:07:29 +0300 Subject: [PATCH] perf: DT detaylari 10 paralel + adaptif fren (0.36 -> 3.2 kayit/sn) & feat: EKAP'tan inen dokumani ihaleye yukleme --- server/dt-worker.mjs | 52 ++++++++++--- server/index.js | 5 +- server/user-store.mjs | 28 +++++-- src/components/TenderDocuments.jsx | 119 +++++++++++++++++++++++++++-- src/services/account.js | 8 +- 5 files changed, 183 insertions(+), 29 deletions(-) diff --git a/server/dt-worker.mjs b/server/dt-worker.mjs index 41861a6..01c6432 100644 --- a/server/dt-worker.mjs +++ b/server/dt-worker.mjs @@ -36,7 +36,10 @@ const argVal = (name, def) => { return i >= 0 && args[i + 1] ? Number(args[i + 1]) : def; }; const MAX_PAGES = argVal('--pages', 10); -const MAX_DETAILS = argVal('--details', 250); +const MAX_DETAILS = argVal('--details', 1500); +// Detaylar PARALEL çekilir; asıl darboğaz istek başına bekleme idi. +const DETAY_PARALEL = Number(process.env.DT_PARALEL || argVal('--paralel', 10)); +const DETAY_GECIKME_MS = Number(process.env.DT_GECIKME_MS || 400); const DELAY_BASE_MS = 2000; const DELAY_JITTER_MS = 1500; @@ -262,7 +265,10 @@ async function run() { await humanDelay(); } - // 2) Eksik detaylar + // 2) Eksik detaylar — paralel + adaptif fren + // EKAP'ı zorlamamak için: her turda DETAY_PARALEL istek eş zamanlı, + // aralarda kısa duraklama. Sunucu direnç gösterirse (challenge/hata) + // gecikme kendiliğinden büyür, temiz turlarda tekrar daralır. const pending = db .prepare( // EN YENİ kayıtlar önce: arayüz en yeni 5000'i gösteriyor; varsayılan @@ -273,18 +279,42 @@ async function run() { ORDER BY rowid DESC LIMIT ?`, ) .all(MAX_DETAILS); - console.log(`[dt-worker] ${pending.length} detay çekilecek`); + console.log(`[dt-worker] ${pending.length} detay çekilecek (${DETAY_PARALEL} paralel)`); let ok = 0; - for (const row of pending) { - try { - const detail = await fetchDetail(sess, row.dogrudan_temin_id, row.idare_id); - setDetail.run(JSON.stringify(detail), row.dogrudan_temin_id); - ok++; - } catch (err) { - console.warn(`[dt-worker] detay hatası (${row.dogrudan_temin_id}): ${err.message}`); + let hata = 0; + let gecikme = DETAY_GECIKME_MS; + const basladi = Date.now(); + + for (let i = 0; i < pending.length; i += DETAY_PARALEL) { + const grup = pending.slice(i, i + DETAY_PARALEL); + const sonuc = await Promise.all( + grup.map(async (row) => { + try { + const detail = await fetchDetail(sess, row.dogrudan_temin_id, row.idare_id); + setDetail.run(JSON.stringify(detail), row.dogrudan_temin_id); + return true; + } catch (err) { + console.warn(`[dt-worker] detay hatası (${row.dogrudan_temin_id}): ${err.message}`); + return false; + } + }), + ); + const grupHata = sonuc.filter((x) => !x).length; + ok += sonuc.length - grupHata; + hata += grupHata; + + // Adaptif fren: hata varsa yavaşla, temizse hızlan (2.5 sn ile 250 ms arası) + gecikme = grupHata + ? Math.min(2500, Math.round(gecikme * 2)) + : Math.max(250, Math.round(gecikme * 0.9)); + + if (i && i % (DETAY_PARALEL * 20) === 0) { + const hiz = (ok / ((Date.now() - basladi) / 1000)).toFixed(1); + console.log(`[dt-worker] ${ok}/${pending.length} detay (${hiz} kayıt/sn, gecikme ${gecikme}ms, hata ${hata})`); } - await humanDelay(); + if (i + DETAY_PARALEL < pending.length) await sleep(gecikme + Math.floor(Math.random() * 200)); } + console.log(`[dt-worker] detay turu: ${ok} başarılı, ${hata} hata, ${Math.round((Date.now() - basladi) / 1000)} sn`); const total = db.prepare('SELECT COUNT(*) AS c FROM dt_tenders').get().c; console.log( diff --git a/server/index.js b/server/index.js index de4e1d5..5322ba0 100644 --- a/server/index.js +++ b/server/index.js @@ -709,6 +709,7 @@ const server = http.createServer(async (req, res) => { mime: body.mime || null, dosya: dosyaAdi, sonKullanma: body.sonKullanma || null, + ikn: body.ikn ? String(body.ikn).slice(0, 40) : null, // ihaleye bağlı belge }); return json(res, 200, { success: true, id, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) }); } catch (e) { @@ -719,9 +720,11 @@ const server = http.createServer(async (req, res) => { const ok = store.deleteDocument(user.email, url.searchParams.get('id')); return json(res, ok ? 200 : 404, { success: ok, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) }); } + // ?ikn=... verilirse yalnızca o ihaleye yüklenen belgeler döner + const iknFiltre = url.searchParams.get('ikn') || undefined; return json(res, 200, { success: true, - belgeler: store.listDocuments(user.email), + belgeler: store.listDocuments(user.email, { ikn: iknFiltre }), kullanim: store.documentUsage(user.email), haklar: { adet: haklar(user.email, opts).belgeAdet, bayt: haklar(user.email, opts).belgeBayt }, }); diff --git a/server/user-store.mjs b/server/user-store.mjs index 32f0976..5ddc4a5 100644 --- a/server/user-store.mjs +++ b/server/user-store.mjs @@ -69,9 +69,11 @@ CREATE TABLE IF NOT EXISTS belgeler ( mime TEXT, dosya TEXT NOT NULL, -- diskteki dosya adı son_kullanma TEXT, + ikn TEXT, -- belge bir ihaleye aitse İKN/DTN olusturma TEXT ); CREATE INDEX IF NOT EXISTS idx_belge_email ON belgeler(email); +CREATE INDEX IF NOT EXISTS idx_belge_ikn ON belgeler(email, ikn); `; export function available() { return Boolean(DatabaseSync); } @@ -82,6 +84,16 @@ export function openDb() { fs.mkdirSync(BELGE_DIR, { recursive: true }); const db = new DatabaseSync(DB_PATH); db.exec(SCHEMA); + // Geçiş: belgeler ihaleye bağlanabilsin diye ikn kolonu eklendi + try { + const k = db.prepare('PRAGMA table_info(belgeler)').all().map((c) => c.name); + if (!k.includes('ikn')) { + db.exec('ALTER TABLE belgeler ADD COLUMN ikn TEXT'); + console.log('[user-store] belgeler.ikn kolonu eklendi'); + } + } catch (e) { + console.warn('[user-store] belge şema geçişi atlandı:', e.message); + } return db; } @@ -212,18 +224,20 @@ export function verifyApiKey(ham) { // --- Belgeler (gerçek dosya, gerçek boyut) --------------------------------- -export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullanma }) { +export function addDocument(email, { ad, kategori, boyut, mime, dosya, sonKullanma, ikn }) { const id = crypto.randomUUID(); withDb((db) => db.prepare( - 'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,olusturma) VALUES (?,?,?,?,?,?,?,?,?)' - ).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, now())); + 'INSERT INTO belgeler (id,email,ad,kategori,boyut,mime,dosya,son_kullanma,ikn,olusturma) VALUES (?,?,?,?,?,?,?,?,?,?)' + ).run(id, email, ad, kategori || null, boyut, mime || null, dosya, sonKullanma || null, ikn || null, now())); return id; } -export function listDocuments(email) { - return withDb((db) => db.prepare( - 'SELECT id, ad, kategori, boyut, mime, son_kullanma, olusturma FROM belgeler WHERE email=? ORDER BY olusturma DESC' - ).all(email), []); +export function listDocuments(email, { ikn } = {}) { + const alanlar = 'id, ad, kategori, boyut, mime, son_kullanma, ikn, olusturma'; + return withDb((db) => (ikn + ? db.prepare(`SELECT ${alanlar} FROM belgeler WHERE email=? AND ikn=? ORDER BY olusturma DESC`).all(email, ikn) + : db.prepare(`SELECT ${alanlar} FROM belgeler WHERE email=? ORDER BY olusturma DESC`).all(email) + ), []); } export function getDocument(email, id) { diff --git a/src/components/TenderDocuments.jsx b/src/components/TenderDocuments.jsx index 5cf2238..5a7ca9a 100644 --- a/src/components/TenderDocuments.jsx +++ b/src/components/TenderDocuments.jsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useApp } from '../context/AppContext'; import { getToken } from '../services/auth'; -import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download } from 'lucide-react'; +import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download, Upload, Trash2, Paperclip } from 'lucide-react'; +import { uploadDocument, listTenderDocuments, deleteDocument } from '../services/account'; +import { extractText } from '../services/pdfText'; /** * İhale dokümanları paneli. @@ -15,6 +17,53 @@ export const TenderDocuments = ({ tender }) => { const [bilgi, setBilgi] = useState(null); const ikn = tender?.ikn; + const [belgeler, setBelgeler] = useState([]); + const [yukleniyor, setYukleniyor] = useState(false); + const dosyaRef = useRef(null); + + const belgeleriYukle = useCallback(() => { + if (!ikn) return; + listTenderDocuments(ikn).then(setBelgeler).catch(() => {}); + }, [ikn]); + useEffect(() => { belgeleriYukle(); }, [belgeleriYukle]); + + // EKAP dokümanı kullanıcının tarayıcısına iniyor; indirdiği dosyayı buraya + // yükleyince ihaleye bağlanır ve metni AI analizine verilebilir. + const dosyaSec = async (e) => { + const f = e.target.files?.[0]; + e.target.value = ''; + if (!f) return; + setYukleniyor(true); setError(null); + try { + await uploadDocument(f, 'İhale Dokümanı', ikn); + belgeleriYukle(); + setBilgi({ yuklendi: f.name }); + } catch (err) { + setError(err.kod === 'KOTA_ASILDI' ? err.message : `Yüklenemedi: ${err.message}`); + } finally { setYukleniyor(false); } + }; + + const belgeSil = async (id) => { + try { await deleteDocument(id); belgeleriYukle(); } catch (err) { setError(err.message); } + }; + + // Yüklenen PDF'i AI'ya ver + const belgeyiAnalizEt = async (b) => { + setBusy('belge'); setError(null); + try { + const r = await fetch(`/api/me/documents/download?id=${encodeURIComponent(b.id)}`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!r.ok) throw new Error('Belge indirilemedi'); + const blob = await r.blob(); + const metin = await extractText(new File([blob], b.ad, { type: b.mime || 'application/pdf' })); + if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).'); + setIsAiSpecAnalyzerOpen(true); + await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, tender }); + } catch (e) { + setError(e.message); + } finally { setBusy(null); } + }; const ekapIhalesi = /^\d{4}\/\d+$/.test(String(ikn || '')); const dokumaniAc = async () => { @@ -94,7 +143,8 @@ export const TenderDocuments = ({ tender }) => { {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.} + {bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi. } + {bilgi.yuklendi && <>{bilgi.yuklendi} yüklendi ve bu ihaleye bağlandı.}

)} @@ -105,13 +155,66 @@ export const TenderDocuments = ({ tender }) => { )} -

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

)} + + {/* EKAP dokümanı kullanıcının tarayıcısına iniyor; sunucu tarafından + alınamıyor. İndirilen dosya buraya yüklenip ihaleye bağlanır. */} +
+

+ + + EKAP dokümanı kendi oturumunuz üzerinden bilgisayarınıza iner — biz sunucudan + alamıyoruz. İnen dosyayı buraya yükleyin; bu ihaleye bağlanır ve AI ile analiz edebilirsiniz. + +

+
+ + + PDF, Word, ZIP · en fazla 10 MB +
+
+ + {belgeler.length > 0 && ( +
+

+ Bu ihaleye yüklenen belgeler ({belgeler.length}) +

+ {belgeler.map((b) => ( +
+ + {b.ad} + + {(b.boyut / 1024).toLocaleString('tr-TR', { maximumFractionDigits: 0 })} KB + + + +
+ ))} +
+ )} + ); }; diff --git a/src/services/account.js b/src/services/account.js index d653b4d..77789c5 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -20,6 +20,10 @@ async function iste(url, opts = {}) { // --- Plan & kota --- export const getPlan = () => iste('/api/me/plan'); +/** Belirli bir ihaleye yüklenmiş belgeler */ +export const listTenderDocuments = (ikn) => + iste(`/api/me/documents?ikn=${encodeURIComponent(ikn)}`).then((d) => d.belgeler || []); + // --- Kullanıcı verisi --- export const getAllUserData = () => iste('/api/me/data').then((d) => d.veri || {}); export const setUserData = (anahtar, deger) => @@ -34,7 +38,7 @@ export const revokeApiKey = (id) => iste(`/api/me/apikeys?id=${encodeURIComponen export const listDocuments = () => iste('/api/me/documents'); export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); -export async function uploadDocument(file, kategori) { +export async function uploadDocument(file, kategori, ikn) { const icerikBase64 = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result).split(',')[1]); @@ -43,7 +47,7 @@ export async function uploadDocument(file, kategori) { }); return iste('/api/me/documents', { method: 'POST', - body: JSON.stringify({ ad: file.name, mime: file.type, kategori, icerikBase64 }), + body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64 }), }); }