diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..896fda6 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "ihaleist-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.gitignore b/.gitignore index 7ac8ea1..db2421b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ node_modules dist .DS_Store *.log + +# Canlı veri worker çıktısı +server/data/ +*.sqlite + +# Kişisel memanto hafızası (uygulama dışı) +MEMORY.md diff --git a/package-lock.json b/package-lock.json index 41f20f4..9b303e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "teminmatik-app", + "name": "ihaleist-app", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "teminmatik-app", + "name": "ihaleist-app", "version": "1.0.0", "dependencies": { "canvas-confetti": "^1.9.4", @@ -19,6 +19,7 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "playwright": "^1.62.1", "tailwindcss": "^4.0.0", "vite": "^6.0.7" } @@ -2443,6 +2444,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/package.json b/package.json index efc9a6c..b825f25 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "server": "node server/index.js" }, "dependencies": { "canvas-confetti": "^1.9.4", @@ -20,6 +21,7 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "playwright": "^1.62.1", "tailwindcss": "^4.0.0", "vite": "^6.0.7" } diff --git a/server/dt-store.mjs b/server/dt-store.mjs new file mode 100644 index 0000000..5c3105e --- /dev/null +++ b/server/dt-store.mjs @@ -0,0 +1,109 @@ +/** + * DT worker'ının doldurduğu SQLite'ı okuyup ihaleist tender modeline çevirir. + * Veritabanı yoksa (worker hiç koşmadıysa) sessizce boş liste döner. + */ + +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = path.join(__dirname, 'data', 'dt.sqlite'); + +function parseDetail(detailJson) { + if (!detailJson) return {}; + try { + const d = JSON.parse(detailJson).dogrudanTeminDetayResult || {}; + const dt = d.DogrudanTeminBilgileri || {}; + const idare = d.IdareBilgileri || {}; + const soz = d.SozlesmeBilgileri?.SozlesmeBilgisiList?.[0] || null; + return { + yasaMaddesi: dt.YasaKapsamiTeminMaddesi || null, + durum: dt.DtDurumu || null, + bransKodlari: dt.BransKodList || [], + kismiTeklif: dt.KismiTeklif || null, + idareTam: idare.Idare || null, + enUstIdare: idare.EnUstIdare || null, + kazananFirma: soz?.IstekliAdi || null, + sozlesmeBedeli: soz?.SozlesmeBedeli || null, + sozlesmeTarihi: soz?.SozlesmeTarihi || null, + }; + } catch { + return {}; + } +} + +const KURUM_RULES = [ + [/hastane|sağlık|başhekim|tıp merkezi|adsm|ağız ve diş/i, 'Sağlık Kurumları'], + [/ceza infaz|cezaevi|tutukevi|işyurt/i, 'Ceza İnfaz Kurumları'], + [/üniversite|rektörlü/i, 'Üniversiteler'], + [/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'], + [/belediye/i, 'Belediyeler'], + [/bakanlığı|valiliği|müdürlüğü|komutanlığı/i, 'Bakanlık / Kamu Kurumları'], +]; + +function classifyKurum(text) { + for (const [re, label] of KURUM_RULES) if (re.test(text || '')) return label; + return 'Diğer Kamu Kurumları'; +} + +function mapDtRow(row) { + const det = parseDetail(row.detail_json); + const kurum = det.idareTam || row.idare_adi || ''; + return { + id: `dt-${row.dogrudan_temin_id}`, + kaynak: 'EKAP · Doğrudan Temin', + kaynakUrl: 'https://ekap.kik.gov.tr/EKAP/YeniIhaleArama.aspx', + ikn: row.dtn || '', + baslik: row.isin_adi || '', + kurum, + sehir: row.ili && !row.ili.startsWith('Plaka') ? row.ili : '', + bolge: '', + tarih: row.dt_tarihi || '', + saat: '', + alimTuru: 'Doğrudan Temin', + usul: det.yasaMaddesi ? `Doğrudan Temin (4734 ${det.yasaMaddesi})` : 'Doğrudan Temin (4734 22/d)', + sektor: row.turu || 'Genel', + brans: (det.bransKodlari || []).join(', ') || row.turu || 'Genel', + kurumTipi: classifyKurum(kurum), + eklenmeZamani: row.yayim_tarihi ? `Yayım: ${row.yayim_tarihi}` : '', + isYeni: false, + isLiveFeed: true, + aciliyetBadge: null, + items: [], + aciklama: null, + yaklasikMaliyet: null, + teslimatSuresi: null, + teslimYeri: null, + yeterlikKriterleri: [], + aiMatchScore: null, + aiRiskScore: null, + aiBidRange: null, + kanbanStatus: 'incelenecek', + atananPersonel: null, + durum: det.durum || 'Aktif', + incelendi: false, + takipte: false, + // Sonuçlanmış DT'lerde kazanan/fiyat bilgisi + sozlesmeBedeli: det.sozlesmeBedeli || null, + kazananFirma: det.kazananFirma || null, + }; +} + +export function readDtTenders(limit = 300) { + if (!fs.existsSync(DB_PATH)) return { tenders: [], totalCount: 0, available: false }; + const db = new DatabaseSync(DB_PATH, { readOnly: true }); + try { + const total = db.prepare('SELECT COUNT(*) c FROM dt_tenders').get().c; + const rows = db + .prepare( + `SELECT dogrudan_temin_id, dtn, isin_adi, idare_adi, turu, ili, dt_tarihi, yayim_tarihi, detail_json + FROM dt_tenders ORDER BY rowid DESC LIMIT ?`, + ) + .all(limit); + return { tenders: rows.map(mapDtRow), totalCount: total, available: true }; + } finally { + db.close(); + } +} diff --git a/server/dt-worker.mjs b/server/dt-worker.mjs new file mode 100644 index 0000000..9a09648 --- /dev/null +++ b/server/dt-worker.mjs @@ -0,0 +1,298 @@ +/** + * ihaleist Doğrudan Temin (22/d) Worker + * + * EKAP'ın doğrudan temin verisini toplar. İmzalı v2 API'ye ihtiyaç duymaz: + * Playwright ile YeniIhaleArama.aspx açılarak oturum (ASP.NET_SessionId + + * BIG-IP TS çerezi) kurulur, ashx çağrıları sayfanın İÇİNDEN fetch ile yapılır — + * böylece TLS parmak izi ve çerezler gerçek tarayıcıya aittir. + * (Yöntem referansı: alizorlu/ekap-dt-service) + * + * Kullanım: + * node server/dt-worker.mjs # liste + eksik detayları çek + * node server/dt-worker.mjs --pages 5 # ilk 5 liste sayfası + * node server/dt-worker.mjs --details 20 # bu turda en fazla 20 detay + * + * Veri: server/data/dt.sqlite (node:sqlite, ek bağımlılık yok) + * Bağımlılık: playwright (npm i -D playwright && npx playwright install chromium) + */ + +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'dt.sqlite'); + +const BASE_URL = 'https://ekap.kik.gov.tr'; +const SEARCH_PAGE = '/EKAP/YeniIhaleArama.aspx'; +const DATA_HANDLER = '/EKAP/Ortak/YeniIhaleAramaData.ashx'; + +const args = process.argv.slice(2); +const argVal = (name, def) => { + const i = args.indexOf(name); + return i >= 0 && args[i + 1] ? Number(args[i + 1]) : def; +}; +const MAX_PAGES = argVal('--pages', 10); +const MAX_DETAILS = argVal('--details', 40); +const DELAY_BASE_MS = 2000; +const DELAY_JITTER_MS = 1500; + +// --------------------------------------------------------------------------- +// SQLite +// --------------------------------------------------------------------------- +fs.mkdirSync(DATA_DIR, { recursive: true }); +const db = new DatabaseSync(DB_PATH); +db.exec(` + CREATE TABLE IF NOT EXISTS dt_tenders ( + dogrudan_temin_id TEXT PRIMARY KEY, + idare_id TEXT, + dtn TEXT, + isin_adi TEXT, + idare_adi TEXT, + turu TEXT, + durumu TEXT, + ili TEXT, + dt_tarihi TEXT, + yayim_tarihi TEXT, + list_json TEXT, + detail_json TEXT, + detail_fetched_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dt_tarihi ON dt_tenders (dt_tarihi); +`); + +const upsertList = db.prepare(` + INSERT INTO dt_tenders (dogrudan_temin_id, idare_id, dtn, isin_adi, idare_adi, turu, durumu, ili, dt_tarihi, yayim_tarihi, list_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(dogrudan_temin_id) DO UPDATE SET + dtn = excluded.dtn, isin_adi = excluded.isin_adi, idare_adi = excluded.idare_adi, + turu = excluded.turu, durumu = excluded.durumu, ili = excluded.ili, + dt_tarihi = excluded.dt_tarihi, yayim_tarihi = excluded.yayim_tarihi, + list_json = excluded.list_json, updated_at = datetime('now') +`); + +const setDetail = db.prepare(` + UPDATE dt_tenders SET detail_json = ?, detail_fetched_at = datetime('now'), updated_at = datetime('now') + WHERE dogrudan_temin_id = ? +`); + +// --------------------------------------------------------------------------- +// EKAP oturumu (Playwright) +// --------------------------------------------------------------------------- +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const humanDelay = () => sleep(DELAY_BASE_MS + Math.floor(Math.random() * DELAY_JITTER_MS)); + +async function openSession() { + const browser = await chromium.launch({ + headless: true, + args: [ + '--disable-blink-features=AutomationControlled', + '--no-first-run', + '--no-default-browser-check', + '--disable-dev-shm-usage', + ], + }); + const context = await browser.newContext({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + locale: 'tr-TR', + timezoneId: 'Europe/Istanbul', + viewport: { width: 1920, height: 1080 }, + extraHTTPHeaders: { 'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7' }, + }); + await context.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + window.chrome = window.chrome || { runtime: {} }; + Object.defineProperty(navigator, 'languages', { get: () => ['tr-TR', 'tr', 'en-US', 'en'] }); + Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); + }); + const page = await context.newPage(); + console.log(`[dt-worker] EKAP oturumu açılıyor: ${BASE_URL}${SEARCH_PAGE}`); + const resp = await page.goto(BASE_URL + SEARCH_PAGE, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + if (!resp || !resp.ok()) throw new Error(`EKAP arama sayfası açılamadı: HTTP ${resp?.status()}`); + await sleep(2500 + Math.floor(Math.random() * 1500)); // BIG-IP TS çerezinin oturması için + console.log('[dt-worker] oturum hazır'); + return { browser, page }; +} + +const searchPage = (sess, i) => + fetchAshx(sess, `?ES=&ihaleidListesi=&dtBilgiSecim=1&metot=dtAra&orderBy=10&pageIndex=${i}`); + +const fetchDetail = (sess, dtId, idareId) => + fetchAshx( + sess, + `?dogrudanTeminId=${encodeURIComponent(dtId)}&idareId=${encodeURIComponent(idareId)}&metot=dtDetayGetir`, + ); + +// ashx çağrısını sayfanın içinden yapar (çerezler otomatik gider). +// Ara sıra BIG-IP challenge HTML'i döner; sayfayı tazeleyip 3 kez dener. +async function fetchAshx(sess, query) { + for (let attempt = 1; attempt <= 3; attempt++) { + let status = 0; + let text = ''; + try { + const raw = await sess.page.evaluate(async (url) => { + const res = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json, text/plain, */*', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + }, + }); + return JSON.stringify({ status: res.status, text: await res.text() }); + }, DATA_HANDLER + query); + ({ status, text } = JSON.parse(raw)); + } catch (err) { + if (attempt === 3) throw err; + } + + if (status === 200 && text && !text.trimStart().startsWith('<')) { + return JSON.parse(text); + } + + if (attempt < 3) { + console.warn(`[dt-worker] beklenmeyen yanıt (HTTP ${status}, ${text.length} kr), oturum tazeleniyor (deneme ${attempt})`); + await refreshSession(sess); + await sleep(3000 * attempt); + } else { + throw new Error(`EKAP ashx beklenmeyen yanıt (HTTP ${status}, ${text.length} kr), denemeler tükendi`); + } + } +} + +// Aynı context içinde sayfayı yeniden yükleyerek oturumu tazeler. +async function refreshSession(sess) { + try { + await sess.page.goto(BASE_URL + SEARCH_PAGE, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await sleep(2500 + Math.floor(Math.random() * 1500)); + } catch (err) { + console.warn(`[dt-worker] oturum tazeleme hatası: ${err.message}`); + } +} + +// dtAra listesi alanları obfuscate edilmiş (E1..E14). Çözülmüş konum eşlemesi: +// E1=DTN E2=işin adı E3=idare adı E4=tür kodu E7=DT tarihi E8=yayım tarihi +// E10=dogrudanTeminId E11=idareId E12=il plaka kodu E13=ilan var E14=e-ihale +const TUR_KODU = { 1: 'Mal Alımı', 2: 'Hizmet Alımı', 3: 'Konaklama/Hizmet', 4: 'Danışmanlık' }; +const IL_PLAKA = { + 1: 'Adana', 6: 'Ankara', 7: 'Antalya', 16: 'Bursa', 21: 'Diyarbakır', 25: 'Erzurum', + 26: 'Eskişehir', 27: 'Gaziantep', 34: 'İstanbul', 35: 'İzmir', 38: 'Kayseri', 41: 'Kocaeli', + 42: 'Konya', 44: 'Malatya', 55: 'Samsun', 61: 'Trabzon', 65: 'Van', 63: 'Şanlıurfa', +}; + +function mapListRow(r) { + const plaka = Number(r.E12); + return { + dogrudanTeminId: String(r.E10 ?? ''), + idareId: String(r.E11 ?? ''), + dtn: String(r.E1 ?? ''), + isinAdi: r.E2 ?? null, + idareAdi: r.E3 ?? null, + turu: TUR_KODU[Number(r.E4)] || (r.E4 ? `Tür ${r.E4}` : null), + durumu: null, // liste durum vermiyor; detayda geliyor + ili: IL_PLAKA[plaka] || (plaka ? `Plaka ${plaka}` : null), + dtTarihi: r.E7 ?? null, + yayimTarihi: r.E8 ?? null, + }; +} + +function extractRows(listResponse) { + if (Array.isArray(listResponse)) return listResponse; + // dtAra yanıtı: { sp, yeniDogrudanTeminAramaResultList: [...] } + if (Array.isArray(listResponse?.yeniDogrudanTeminAramaResultList)) { + return listResponse.yeniDogrudanTeminAramaResultList; + } + for (const v of Object.values(listResponse || {})) { + if (Array.isArray(v) && v.length && typeof v[0] === 'object') return v; + } + return []; +} + +// --------------------------------------------------------------------------- +// Ana akış +// --------------------------------------------------------------------------- +async function run() { + const { browser, page } = await openSession(); + const sess = { browser, page }; + try { + // 1) Liste taraması + let newCount = 0; + for (let i = 0; i < MAX_PAGES; i++) { + const data = await searchPage(sess, i); + const rows = extractRows(data); + if (i === 0 && rows[0]) { + const s = mapListRow(rows[0]); + console.log(`[dt-worker] ilk sayfa: ${rows.length} kayıt; örnek: ${s.dtn} / ${(s.isinAdi || '').slice(0, 40)} / ${s.ili}`); + } + if (rows.length === 0) { + console.log(`[dt-worker] sayfa ${i}: boş, tarama bitti`); + break; + } + let pageNew = 0; + for (const r of rows) { + const m = mapListRow(r); + if (!m.dogrudanTeminId) continue; + const existed = db + .prepare('SELECT 1 FROM dt_tenders WHERE dogrudan_temin_id = ?') + .get(m.dogrudanTeminId); + upsertList.run( + m.dogrudanTeminId, m.idareId, m.dtn, m.isinAdi, m.idareAdi, + m.turu, m.durumu, m.ili, m.dtTarihi, m.yayimTarihi, JSON.stringify(r), + ); + if (!existed) { + pageNew++; + newCount++; + } + } + console.log(`[dt-worker] sayfa ${i}: ${rows.length} kayıt, ${pageNew} yeni`); + if (pageNew === 0 && i > 0) { + console.log('[dt-worker] sayfadaki tüm kayıtlar zaten mevcut, tarama durdu'); + break; + } + await humanDelay(); + } + + // 2) Eksik detaylar + const pending = db + .prepare( + `SELECT dogrudan_temin_id, idare_id FROM dt_tenders + WHERE detail_json IS NULL AND idare_id != '' LIMIT ?`, + ) + .all(MAX_DETAILS); + console.log(`[dt-worker] ${pending.length} detay çekilecek`); + 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}`); + } + await humanDelay(); + } + + const total = db.prepare('SELECT COUNT(*) AS c FROM dt_tenders').get().c; + console.log( + `[dt-worker] bitti: ${newCount} yeni kayıt, ${ok} detay; toplam DB: ${total} doğrudan temin`, + ); + } finally { + await browser.close(); + db.close(); + } +} + +run().catch((err) => { + console.error('[dt-worker] ölümcül hata:', err.message); + process.exit(1); +}); diff --git a/server/geotrust-tls-rsa-ca-g1.pem b/server/geotrust-tls-rsa-ca-g1.pem new file mode 100644 index 0000000..9642eed --- /dev/null +++ b/server/geotrust-tls-rsa-ca-g1.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEjTCCA3WgAwIBAgIQDQd4KhM/xvmlcpbhMf/ReTANBgkqhkiG9w0BAQsFADBh +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH +MjAeFw0xNzExMDIxMjIzMzdaFw0yNzExMDIxMjIzMzdaMGAxCzAJBgNVBAYTAlVT +MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j +b20xHzAdBgNVBAMTFkdlb1RydXN0IFRMUyBSU0EgQ0EgRzEwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC+F+jsvikKy/65LWEx/TMkCDIuWegh1Ngwvm4Q +yISgP7oU5d79eoySG3vOhC3w/3jEMuipoH1fBtp7m0tTpsYbAhch4XA7rfuD6whU +gajeErLVxoiWMPkC/DnUvbgi74BJmdBiuGHQSd7LwsuXpTEGG9fYXcbTVN5SATYq +DfbexbYxTMwVJWoVb6lrBEgM3gBBqiiAiy800xu1Nq07JdCIQkBsNpFtZbIZhsDS +fzlGWP4wEmBQ3O67c+ZXkFr2DcrXBEtHam80Gp2SNhou2U5U7UesDL/xgLK6/0d7 +6TnEVMSUVJkZ8VeZr+IUIlvoLrtjLbqugb0T3OYXW+CQU0kBAgMBAAGjggFAMIIB +PDAdBgNVHQ4EFgQUlE/UXYvkpOKmgP792PkA76O+AlcwHwYDVR0jBBgwFoAUTiJU +IBiV5uNu5g/6+rkS7QYXjzkwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsG +AQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMDQGCCsGAQUFBwEB +BCgwJjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEIGA1Ud +HwQ7MDkwN6A1oDOGMWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEds +b2JhbFJvb3RHMi5jcmwwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEW +HGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwDQYJKoZIhvcNAQELBQADggEB +AIIcBDqC6cWpyGUSXAjjAcYwsK4iiGF7KweG97i1RJz1kwZhRoo6orU1JtBYnjzB +c4+/sXmnHJk3mlPyL1xuIAt9sMeC7+vreRIF5wFBC0MCN5sbHwhNN1JzKbifNeP5 +ozpZdQFmkCo+neBiKR6HqIA+LMTMCMMuv2khGGuPHmtDze4GmEGZtYLyF8EQpa5Y +jPuV6k2Cr/N3XxFpT3hRpt/3usU/Zb9wfKPtWpoznZ4/44c1p9rzFcZYrWkj3A+7 +TNBJE0GmP2fhXhP1D/XVfIW/h0yCJGEiV9Glm/uGOa3DXHlmbAcxSyCRraG+ZBkA +7h4SeM6Y8l/7MBRpPCz6l8Y= +-----END CERTIFICATE----- diff --git a/server/il-plaka.mjs b/server/il-plaka.mjs new file mode 100644 index 0000000..6118699 --- /dev/null +++ b/server/il-plaka.mjs @@ -0,0 +1,17 @@ +// Türkiye il plaka kodu → il adı (81 il) +export const IL_PLAKA = { + 1: 'Adana', 2: 'Adıyaman', 3: 'Afyonkarahisar', 4: 'Ağrı', 5: 'Amasya', 6: 'Ankara', + 7: 'Antalya', 8: 'Artvin', 9: 'Aydın', 10: 'Balıkesir', 11: 'Bilecik', 12: 'Bingöl', + 13: 'Bitlis', 14: 'Bolu', 15: 'Burdur', 16: 'Bursa', 17: 'Çanakkale', 18: 'Çankırı', + 19: 'Çorum', 20: 'Denizli', 21: 'Diyarbakır', 22: 'Edirne', 23: 'Elazığ', 24: 'Erzincan', + 25: 'Erzurum', 26: 'Eskişehir', 27: 'Gaziantep', 28: 'Giresun', 29: 'Gümüşhane', + 30: 'Hakkari', 31: 'Hatay', 32: 'Isparta', 33: 'Mersin', 34: 'İstanbul', 35: 'İzmir', + 36: 'Kars', 37: 'Kastamonu', 38: 'Kayseri', 39: 'Kırklareli', 40: 'Kırşehir', 41: 'Kocaeli', + 42: 'Konya', 43: 'Kütahya', 44: 'Malatya', 45: 'Manisa', 46: 'Kahramanmaraş', 47: 'Mardin', + 48: 'Muğla', 49: 'Muş', 50: 'Nevşehir', 51: 'Niğde', 52: 'Ordu', 53: 'Rize', 54: 'Sakarya', + 55: 'Samsun', 56: 'Siirt', 57: 'Sinop', 58: 'Sivas', 59: 'Tekirdağ', 60: 'Tokat', + 61: 'Trabzon', 62: 'Tunceli', 63: 'Şanlıurfa', 64: 'Uşak', 65: 'Van', 66: 'Yozgat', + 67: 'Zonguldak', 68: 'Aksaray', 69: 'Bayburt', 70: 'Karaman', 71: 'Kırıkkale', 72: 'Batman', + 73: 'Şırnak', 74: 'Bartın', 75: 'Ardahan', 76: 'Iğdır', 77: 'Yalova', 78: 'Karabük', + 79: 'Kilis', 80: 'Osmaniye', 81: 'Düzce', +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..671def1 --- /dev/null +++ b/server/index.js @@ -0,0 +1,369 @@ +/** + * ihaleist API Proxy — ilan.gov.tr canlı ihale verisi + * + * Kaynak: Basın İlan Kurumu resmi ilan portalı (www.ilan.gov.tr) açık JSON API'si. + * Kapsam: 4734 açık ihaleler + 2886 kira/satış ihaleleri. (Doğrudan temin 22/d bu + * kaynakta yayınlanmaz — Faz 2'de EKAP worker'ı ile eklenecek.) + * + * Bağımlılık yok: Node 18+ built-in http + fetch. + * Çalıştır: node server/index.js (PORT env ile port değişir, varsayılan 3001) + */ + +import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readDtTenders } from './dt-store.mjs'; + +// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; +// zinciri tamamlamak için PEM'i yerelden ekliyoruz. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EXTRA_CA = fs.readFileSync(path.join(__dirname, 'geotrust-tls-rsa-ca-g1.pem'), 'utf8'); +const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA]; + +const PORT = process.env.PORT || 3001; +const ILAN_BASE = 'https://www.ilan.gov.tr'; +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 dk +const LIST_SIZE = 20; // ilan.gov.tr sayfa başına 20 kayıtla sınırlıyor +const MAX_PAGES = 8; // 8 sayfa × 20 = 160 ilan + +const ILAN_HEADERS = { + accept: 'application/json', + 'content-type': 'application/json', + origin: ILAN_BASE, + referer: `${ILAN_BASE}/ilan/tum-ilanlar`, + 'user-agent': + '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', +}; + +// --------------------------------------------------------------------------- +// İl → Bölge eşlemesi +// --------------------------------------------------------------------------- +const REGIONS = { + 'MARMARA BÖLGESİ': ['İstanbul', 'Bursa', 'Kocaeli', 'Balıkesir', 'Tekirdağ', 'Sakarya', 'Çanakkale', 'Edirne', 'Kırklareli', 'Yalova', 'Bilecik'], + 'İÇ ANADOLU BÖLGESİ': ['Ankara', 'Konya', 'Kayseri', 'Eskişehir', 'Sivas', 'Aksaray', 'Karaman', 'Kırıkkale', 'Kırşehir', 'Nevşehir', 'Niğde', 'Yozgat', 'Çankırı'], + 'EGE BÖLGESİ': ['İzmir', 'Manisa', 'Aydın', 'Denizli', 'Muğla', 'Afyonkarahisar', 'Kütahya', 'Uşak'], + 'AKDENİZ BÖLGESİ': ['Antalya', 'Adana', 'Mersin', 'Hatay', 'Kahramanmaraş', 'Osmaniye', 'Isparta', 'Burdur'], + 'KARADENİZ BÖLGESİ': ['Samsun', 'Trabzon', 'Ordu', 'Rize', 'Giresun', 'Zonguldak', 'Tokat', 'Çorum', 'Amasya', 'Sinop', 'Kastamonu', 'Bartın', 'Karabük', 'Düzce', 'Bolu', 'Artvin', 'Gümüşhane', 'Bayburt'], + 'DOĞU ANADOLU BÖLGESİ': ['Erzurum', 'Van', 'Malatya', 'Elazığ', 'Erzincan', 'Ağrı', 'Kars', 'Ardahan', 'Iğdır', 'Muş', 'Bitlis', 'Bingöl', 'Tunceli', 'Hakkari'], + 'GÜNEYDOĞU ANADOLU BÖLGESİ': ['Gaziantep', 'Şanlıurfa', 'Diyarbakır', 'Mardin', 'Batman', 'Siirt', 'Şırnak', 'Adıyaman', 'Kilis'], +}; +const CITY_TO_REGION = {}; +for (const [region, cities] of Object.entries(REGIONS)) { + for (const c of cities) CITY_TO_REGION[c.toLocaleUpperCase('tr-TR')] = region; +} + +// --------------------------------------------------------------------------- +// Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre) +// --------------------------------------------------------------------------- +const SECTOR_RULES = [ + [/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş/i, 'Sağlık / Tıbbi Malzeme'], + [/yazılım|bilişim|sunucu|donanım|kamera|network|bilgisayar/i, 'Bilişim & Yazılım'], + [/gıda|yemek|catering|ekmek|et |sebze|meyve|kumanya/i, 'Gıda & Catering'], + [/temizlik|hijyen|dezenfek|deterjan/i, 'Temizlik & Hijyen'], + [/inşaat|yapım|onarım|tadilat|bakım|asfalt|beton|sulama|yol /i, 'İnşaat & Bakım'], + [/kırtasiye|matbaa|baskı|kağıt|toner/i, 'Kırtasiye & Büro'], + [/giyim|tekstil|elbise|üniforma|kıyafet/i, 'Tekstil & Giyim'], + [/araç|otomotiv|lastik|akaryakıt|yakıt|kiralama/i, 'Otomotiv & Yedek Parça'], + [/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş/i, 'Elektrik & Enerji'], +]; + +const INSTITUTION_RULES = [ + [/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'], + [/belediye/i, 'Belediyeler'], + [/üniversite/i, 'Üniversiteler'], + [/hastane|sağlık müdürlüğü|başhekim/i, 'Eğitim ve Araştırma Hastaneleri'], + [/ağız ve diş/i, 'Ağız ve Diş Sağlığı Merkezleri'], + [/ceza infaz|cezaevi|tevkifevi/i, 'Ceza İnfaz Kurumları'], + [/bakanlığı|bakanlık|valiliği|müdürlüğü/i, 'Bakanlıklar'], + [/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'], +]; + +function classify(rules, text, fallback) { + for (const [re, label] of rules) if (re.test(text)) return label; + return fallback; +} + +function titleCaseTr(s) { + if (!s) return ''; + return s + .toLocaleLowerCase('tr-TR') + .split(/\s+/) + .map((w) => (w ? w[0].toLocaleUpperCase('tr-TR') + w.slice(1) : w)) + .join(' '); +} + +function getFilter(ad, key) { + const f = (ad.adTypeFilters || []).find((x) => x.key === key); + return f ? f.value : null; +} + +function relativeTimeTr(iso) { + if (!iso) return ''; + const diffMs = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diffMs / 60000); + if (mins < 60) return `${Math.max(mins, 1)} dk önce eklendi`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours} saat önce eklendi`; + return `${Math.floor(hours / 24)} gün önce eklendi`; +} + +function daysUntil(ddmmyyyy) { + if (!ddmmyyyy) return null; + const [d, m, y] = ddmmyyyy.split('.').map(Number); + if (!d || !m || !y) return null; + return Math.ceil((new Date(y, m - 1, d) - new Date()) / 86400000); +} + +// ilan.gov.tr kaydını ihaleist tender modeline çevir +function mapAd(ad) { + const cityUpper = (ad.addressCityName || '').toLocaleUpperCase('tr-TR'); + const sehir = titleCaseTr(ad.addressCityName || ''); + const tarih = getFilter(ad, 'İhale ve Teklif Açma Tarihi') || ''; + const ikn = getFilter(ad, 'İhale Kayıt No') || ad.adNo || ''; + const usul = getFilter(ad, 'İhale Usulü') || 'Belirtilmemiş'; + const ihaleTuru = getFilter(ad, 'İhale Türü') || ''; + const searchBase = `${ad.title || ''} ${ad.advertiserName || ''}`; + const kalanGun = daysUntil(tarih); + + let aciliyetBadge = null; + if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN'; + + const publishedToday = + ad.publishStartDate && new Date(ad.publishStartDate).toDateString() === new Date().toDateString(); + + return { + id: `ilan-${ad.id}`, + kaynak: 'ilan.gov.tr', + kaynakUrl: `${ILAN_BASE}${ad.urlStr || ''}`, + ikn, + baslik: ad.title || '', + kurum: ad.advertiserName || '', + sehir, + bolge: CITY_TO_REGION[cityUpper] || '', + tarih, + saat: '', + alimTuru: ihaleTuru || 'İhale', + usul, + sektor: classify(SECTOR_RULES, searchBase, 'Genel'), + brans: ihaleTuru || 'Genel', + kurumTipi: classify(INSTITUTION_RULES, ad.advertiserName || '', 'Diğer Kamu Kurumları'), + eklenmeZamani: relativeTimeTr(ad.publishStartDate), + isYeni: Boolean(publishedToday), + isLiveFeed: true, + aciliyetBadge, + items: [], + aciklama: null, // detay çağrısıyla lazy doldurulur + yaklasikMaliyet: null, + teslimatSuresi: null, + teslimYeri: null, + yeterlikKriterleri: [], + aiMatchScore: null, + aiRiskScore: null, + aiBidRange: null, + kanbanStatus: 'incelenecek', + atananPersonel: null, + durum: 'Aktif', + incelendi: false, + takipte: false, + sozlesmeBedeli: null, + kazananFirma: null, + }; +} + +// --------------------------------------------------------------------------- +// ilan.gov.tr istemcisi + cache +// --------------------------------------------------------------------------- +function httpsJson(urlStr, { method = 'GET', body = null } = {}) { + return new Promise((resolve, reject) => { + const u = new URL(urlStr); + const payload = body ? JSON.stringify(body) : null; + const req = https.request( + { + hostname: u.hostname, + path: u.pathname + u.search, + method, + ca: CA_BUNDLE, + headers: { + ...ILAN_HEADERS, + ...(payload ? { 'content-length': Buffer.byteLength(payload) } : {}), + }, + timeout: 30000, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if (res.statusCode < 200 || res.statusCode >= 300) { + return reject(new Error(`ilan.gov.tr HTTP ${res.statusCode}: ${text.slice(0, 200)}`)); + } + try { + resolve(JSON.parse(text)); + } catch { + reject(new Error('ilan.gov.tr geçersiz JSON döndürdü')); + } + }); + }, + ); + req.on('timeout', () => req.destroy(new Error('ilan.gov.tr zaman aşımı'))); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function ilanSearch(keys, skipCount = 0, maxResultCount = LIST_SIZE) { + const data = await httpsJson(`${ILAN_BASE}/api/api/services/app/Ad/AdsByFilter`, { + method: 'POST', + body: { keys, skipCount, maxResultCount }, + }); + return data.result || {}; +} + +async function ilanDetail(id) { + const data = await httpsJson( + `${ILAN_BASE}/api/api/services/app/AdDetail/GetAdDetail?id=${encodeURIComponent(id)}`, + ); + return data.result || {}; +} + +function htmlToText(htmlStr) { + if (!htmlStr) return ''; + return htmlStr + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<(?:br|\/tr|\/p|\/h\d|\/li)>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +const cache = { tenders: null, fetchedAt: 0, totalCount: 0, todayCount: 0, detail: new Map() }; + +async function getTenders(force = false) { + const fresh = cache.tenders && Date.now() - cache.fetchedAt < CACHE_TTL_MS; + if (fresh && !force) return cache; + + const allAds = []; + let numFound = 0; + for (let page = 0; page < MAX_PAGES; page++) { + const result = await ilanSearch({ ats: [3] }, page * LIST_SIZE, LIST_SIZE); + const ads = result.ads || []; + numFound = result.numFound || numFound; + if (ads.length === 0) break; + allAds.push(...ads); + if (allAds.length >= numFound) break; + } + const seen = new Set(); + cache.tenders = allAds + .filter((ad) => (seen.has(ad.id) ? false : seen.add(ad.id))) + .map(mapAd); + cache.totalCount = numFound || allAds.length; + cache.todayCount = cache.tenders.filter((t) => t.isYeni).length; + cache.fetchedAt = Date.now(); + console.log(`[ihaleist-api] ${cache.tenders.length} ihale yüklendi (toplam havuz: ${cache.totalCount})`); + return cache; +} + +async function getDetail(ilanId) { + if (cache.detail.has(ilanId)) return cache.detail.get(ilanId); + const raw = await ilanDetail(ilanId); + const text = htmlToText(raw.content || ''); + const detail = { aciklama: text.slice(0, 4000), adNo: raw.adNo || null }; + cache.detail.set(ilanId, detail); + if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value); + return detail; +} + +// --------------------------------------------------------------------------- +// HTTP sunucu +// --------------------------------------------------------------------------- +function json(res, code, body) { + res.writeHead(code, { + 'Content-Type': 'application/json; charset=utf-8', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }); + res.end(JSON.stringify(body)); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + try { + if (url.pathname === '/api/tenders') { + const force = url.searchParams.get('refresh') === '1'; + const c = await getTenders(force); + // Doğrudan temin (EKAP worker) verisini varsa birleştir + let dt = { tenders: [], totalCount: 0, available: false }; + try { + dt = readDtTenders(300); + } catch (e) { + console.warn('[ihaleist-api] DT okuma hatası:', e.message); + } + const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları']; + if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker'); + return json(res, 200, { + success: true, + source: sources.join(' + '), + fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'), + totalCount: c.totalCount + dt.totalCount, + todayCount: c.todayCount, + dtCount: dt.totalCount, + tenders: [...dt.tenders, ...c.tenders], + }); + } + + if (url.pathname === '/api/dt') { + let dt = { tenders: [], totalCount: 0, available: false }; + try { + dt = readDtTenders(Number(url.searchParams.get('limit')) || 300); + } catch (e) { + return json(res, 200, { success: true, available: false, error: e.message, tenders: [] }); + } + return json(res, 200, { + success: true, + available: dt.available, + source: 'EKAP Doğrudan Temin (22/d)', + totalCount: dt.totalCount, + tenders: dt.tenders, + }); + } + + const detailMatch = url.pathname.match(/^\/api\/tenders\/ilan-(\d+)\/detail$/); + if (detailMatch) { + const detail = await getDetail(detailMatch[1]); + return json(res, 200, { success: true, ...detail }); + } + + if (url.pathname === '/api/stats') { + const c = await getTenders(); + return json(res, 200, { + success: true, + toplamIlan: c.totalCount, + bugunEklenen: c.todayCount, + yuklenenIlan: c.tenders.length, + kaynak: 'ilan.gov.tr', + }); + } + + if (url.pathname === '/api/health') return json(res, 200, { ok: true }); + + return json(res, 404, { success: false, error: 'not found' }); + } catch (err) { + console.error('[ihaleist-api] hata:', err.message); + return json(res, 502, { success: false, error: err.message }); + } +}); + +server.listen(PORT, () => { + console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`); + getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message)); +}); diff --git a/src/components/TenderCard.jsx b/src/components/TenderCard.jsx index afe16ab..6b06844 100644 --- a/src/components/TenderCard.jsx +++ b/src/components/TenderCard.jsx @@ -91,10 +91,17 @@ export const TenderCard = ({ tender }) => { {tender.aciliyetBadge} )} - - - %{tender.aiMatchScore} Uyumlu - + {tender.aiMatchScore != null && ( + + + %{tender.aiMatchScore} Uyumlu + + )} + {tender.kaynak && ( + + 🟢 {tender.kaynak} + + )} {/* Material Items Preview Box */} diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index f602064..1b76e09 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -433,22 +433,26 @@ END:VCALENDAR`;

Teknik şartname ve geçmiş ihale sonuçları analiz edildi.

- - %{tender.aiMatchScore} Şirket Uyumu - + {tender.aiMatchScore != null && ( + + %{tender.aiMatchScore} Şirket Uyumu + + )}
Risk Skoru - {tender.aiRiskScore} Risk + {tender.aiRiskScore || 'Analiz Bekliyor'}{tender.aiRiskScore ? ' Risk' : ''}
Önerilen Teklif Aralığı - ₺{tender.aiBidRange?.recommended.toLocaleString('tr-TR')} + {tender.aiBidRange?.recommended != null + ? `₺${tender.aiBidRange.recommended.toLocaleString('tr-TR')}` + : '—'}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 0a02fa8..830de8d 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,6 +1,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { MOCK_TENDERS, MOCK_LIVE_STATS, MOCK_COMPANY_STATS } from '../data/mockTenders'; import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService'; +import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi'; const AppContext = createContext(); @@ -30,68 +31,56 @@ export const AppProvider = ({ children }) => { const [isLiveMode, setIsLiveMode] = useState(true); const [isRefreshingLive, setIsRefreshingLive] = useState(false); + const [liveDataError, setLiveDataError] = useState(null); + const [liveDataSource, setLiveDataSource] = useState(null); - const refreshLiveEKAPData = () => { + // Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur. + const refreshLiveEKAPData = async ({ refresh = true } = {}) => { setIsRefreshingLive(true); - setTimeout(() => { - const liveItems = [ - { - id: `live-ekap-${Date.now()}`, - ikn: `2026/${Math.floor(100000 + Math.random() * 900000)}`, - baslik: "T.C. SAĞLIK BAKANLIĞI ANKARA ŞEHİR HASTANESİ 18 KALEM TIBBİ SARF MALZEME ALIMI", - kurum: "T.C. SAĞLIK BAKANLIĞI ANKARA ŞEHİR HASTANESİ BAŞHEKİMLİĞİ", - sehir: "Ankara", - bolge: "İÇ ANADOLU BÖLGESİ", - tarih: "14.08.2026", - saat: "10:30", - alimTuru: "Açık İhale", - usul: "Açık İhale Usulü (4734 Sayılı Kanun Madde 19)", - sektor: "Tıbbi Cihaz ve Sarf Malzemeleri", - brans: "Ortopedi & Beyin Cerrahi Sarf Malzemeleri", - kurumTipi: "Eğitim ve Araştırma Hastaneleri", - eklenmeZamani: " Canlı Akış (Canlı Yayınlandı)", - isYeni: true, - isLiveFeed: true, - aciliyetBadge: "🔴 CANLI İHALE", - items: [ - { id: "li1", ad: "Titanyum Pedikül Vidası", miktar: "150", birim: "adet", birimFiyat: 1420 }, - { id: "li2", ad: "Cerrahi Rod (5.5mm)", miktar: "80", birim: "adet", birimFiyat: 680 }, - { id: "li3", ad: "Posterior Servikal Kafes", miktar: "40", birim: "adet", birimFiyat: 2450 } - ], - aciklama: "T.C. Sağlık Bakanlığı Ankara Şehir Hastanesi Beyin ve Sinir Cerrahisi Kliniği İhtiyacı İçin Tıbbi Sarf Malzeme Canlı Veri Akışıyla Alınacaktır.", - yaklasikMaliyet: 542000, - teslimatSuresi: "15 Takvim Günü", - teslimYeri: "Ankara Bilkent Şehir Hastanesi Medikal Depo", - yeterlikKriterleri: ["ÜTS Barkod Kaydı", "ISO 13485 Kalite Belgesi", "TSE HYB Satış Yetkisi"], - aiMatchScore: 98, - aiRiskScore: "Düşük", - aiBidRange: { min: 435000, max: 510000, recommended: 468000 }, - kanbanStatus: "incelenecek", - atananPersonel: "Hikmet Tanrıverdi", - durum: "Aktif", - incelendi: false, - takipte: true, - sozlesmeBedeli: null, - kazananFirma: null - } - ]; - - setTenders(prev => { - const filtered = prev.filter(t => !t.id.startsWith('live-ekap-')); - return [...liveItems, ...filtered]; - }); - + setLiveDataError(null); + try { + const data = await fetchLiveTenders({ refresh }); + setTenders(prev => mergeUserState(data.tenders, prev)); + setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt }); setLiveStats(prev => ({ ...prev, - todayAdded: prev.todayAdded + 1, - totalItems: prev.totalItems + 1 + bugunEklenen: data.todayCount ?? prev.bugunEklenen, + aktifTakip: data.totalCount ?? prev.aktifTakip })); - + } catch (err) { + setLiveDataError(err.message); + } finally { setIsRefreshingLive(false); - }, 1000); + } }; + // Açılışta canlı veriyi yükle (başarısız olursa mevcut/mock veri ekranda kalır) + useEffect(() => { + refreshLiveEKAPData({ refresh: false }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [activeTenderDetail, setActiveTenderDetail] = useState(null); + + // Canlı ihalede detay açıldığında ilan metnini API'den tamamla + useEffect(() => { + const t = activeTenderDetail; + if (!t || !t.id?.startsWith('ilan-') || t.aciklama) return; + let cancelled = false; + fetchTenderDetail(t.id) + .then(detail => { + if (cancelled || !detail.aciklama) return; + setActiveTenderDetail(prev => + prev && prev.id === t.id ? { ...prev, aciklama: detail.aciklama } : prev + ); + setTenders(prev => + prev.map(x => (x.id === t.id ? { ...x, aciklama: detail.aciklama } : x)) + ); + }) + .catch(() => {}); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTenderDetail?.id]); const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false); const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false); const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false); @@ -317,6 +306,8 @@ export const AppProvider = ({ children }) => { setIsLiveMode, isRefreshingLive, refreshLiveEKAPData, + liveDataError, + liveDataSource, liveStats, searchQuery, setSearchQuery, diff --git a/src/services/tenderApi.js b/src/services/tenderApi.js new file mode 100644 index 0000000..e1c7346 --- /dev/null +++ b/src/services/tenderApi.js @@ -0,0 +1,38 @@ +/** + * ihaleist canlı veri istemcisi. + * Backend proxy (server/index.js) üzerinden ilan.gov.tr resmi ihale ilanlarını çeker. + * Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir. + */ + +export async function fetchLiveTenders({ refresh = false } = {}) { + const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`); + if (!res.ok) throw new Error(`API hatası: HTTP ${res.status}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü'); + return data; +} + +export async function fetchTenderDetail(tenderId) { + const res = await fetch(`/api/tenders/${tenderId}/detail`); + if (!res.ok) throw new Error(`Detay API hatası: HTTP ${res.status}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Detay alınamadı'); + return data; +} + +// Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir. +const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel']; + +export function mergeUserState(liveTenders, previousTenders) { + const prevById = new Map((previousTenders || []).map((t) => [t.id, t])); + return liveTenders.map((tender) => { + const prev = prevById.get(tender.id); + if (!prev) return tender; + const merged = { ...tender }; + for (const f of USER_FIELDS) { + if (prev[f] !== undefined && prev[f] !== null && prev[f] !== false) merged[f] = prev[f]; + } + if (prev.aciklama && !merged.aciklama) merged.aciklama = prev.aciklama; + return merged; + }); +} diff --git a/vite.config.js b/vite.config.js index b62324f..f54ccd6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,12 @@ export default defineConfig({ plugins: [react(), tailwindcss()], server: { port: 3000, - host: true + host: true, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + } + } } })