From ff797f5a91d95a6af99e662ed1f31fc64ec0b3f9 Mon Sep 17 00:00:00 2001 From: ht Date: Thu, 3 Sep 2026 12:55:36 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20urun=20arama=20=E2=80=94=20urun=20adini?= =?UTF-8?q?=20ihale=20DOKUMANLARININ=20icinde=20aratir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saidsurucu/ihale-mcp deposu incelenirken EKAP arama API'sinin doküman ici arama parametreleri oldugu goruldu: teklifCetvelindeAra, teknikSartnamedeAra, idariSartnamedeAra, sozlesmeTasarisindaAra. EKAP dokumani kendi tarafinda indeksliyor — yani teklif cetvelinde arama icin dokumani indirmeye (ve CAPTCHA'ya) gerek yok. Kullanicinin sikayetini olcen kanit: ELDIVEN basliкta 1.135 → teklif cetvelinde 29.057 ORTOPEDIK VIDA baslikta 0 → teklif cetvelinde 3 HEMOGRAM baslikta 283 → teklif cetvelinde 2.529 "ORTOPEDIK VIDA" hicbir ihale BASLIGINDA gecmiyor; gectigi ihalelerin adi "VETERINER ILAC ... SARF MALZEME". Basliga bakan firma bunlari bulamiyordu. - ekap-v2.mjs: searchBody artik opts.kapsam ile daraltilabiliyor; searchInDocuments() yalnizca teklif cetvelinde (istege bagli teknik sartnamede) arar ve sonucu normalize eder - GET /api/urun-ara?q=&skip=&take=&teknik= — 5 dk bellek onbellegi ile (EKAP'a uzak cagri, ayni sorgu kaynagi yormasin) - Header'da "URUN ARA" dugmesi + UrunAramaModal: sayfalama, "sadece teklif verilebilir" suzgeci, havuzda acik olan ihalede detayi acma kisayolu SINIR: EKAP hangi kalemin eslestigini dondurmuyor, yalnizca eslesen ihaleleri. Kalem dokumu icin dokuman yuklemeye dayali ortak havuz yolu gecerli kaliyor; arayuzde bu acikca yaziliyor. Co-Authored-By: Claude Opus 5 --- server/ekap-v2.mjs | 40 ++++++ server/index.js | 29 +++++ src/App.jsx | 2 + src/components/Header.jsx | 12 +- src/components/UrunAramaModal.jsx | 207 ++++++++++++++++++++++++++++++ src/context/AppContext.jsx | 4 + src/services/urunArama.js | 16 +++ 7 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 src/components/UrunAramaModal.jsx create mode 100644 src/services/urunArama.js diff --git a/server/ekap-v2.mjs b/server/ekap-v2.mjs index 6e9573e..8523b9c 100644 --- a/server/ekap-v2.mjs +++ b/server/ekap-v2.mjs @@ -89,10 +89,14 @@ function searchBody(opts = {}) { return { searchText: opts.searchText || '', filterType: null, + // Arama kapsamı — EKAP metni ihale dokümanının İÇİNDE de arayabiliyor + // (teknik/idari şartname, sözleşme tasarısı, teklif cetveli). Varsayılan + // hepsi açık; opts.kapsam ile daraltılır (ör. yalnızca teklif cetveli). ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true, teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true, isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true, ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true, + ...(opts.kapsam || {}), searchType: 'GirdigimGibi', iknYili: opts.iknYili ?? null, iknSayi: opts.iknSayi ?? null, @@ -133,6 +137,42 @@ export async function searchTenders(opts = {}) { return { list: data.list || [], totalCount: data.totalCount ?? 0 }; } +/** + * Ürün/kalem araması — metni YALNIZCA ihale dokümanının içinde arar. + * + * Firmalar başlığa bakarak kendi ürünlerini bulamıyor: "ORTOPEDİK VİDA" + * hiçbir ihale BAŞLIĞINDA geçmiyor, ama üç ihalenin TEKLİF CETVELİNDE var — + * ve o ihalelerin adı "VETERİNER İLAÇ ... SARF MALZEME". EKAP dokümanı + * kendi tarafında indekslediği için doküman indirmeye gerek yok. + */ +export async function searchInDocuments(metin, { teknikSartname = false, skip = 0, take = 20 } = {}) { + const kapsam = { + ikNdeAra: false, ihaleAdindaAra: false, ihaleIlanindaAra: false, + teknikSartnamedeAra: !!teknikSartname, idariSartnamedeAra: false, + benzerIsMaddesindeAra: false, isinYapilacagiYerMaddesindeAra: false, + nitelikTurMiktarMaddesindeAra: false, ihaleBilgilerindeAra: false, + sozlesmeTasarisindaAra: false, teklifCetvelindeAra: true, + }; + const data = await ekapPost( + '/b_ihalearama/api/Ihale/GetListByParameters', + searchBody({ searchText: metin, kapsam, skip, take }), + ); + return { + totalCount: data.totalCount ?? 0, + list: (data.list || []).map((t) => ({ + ikn: t.ikn, + baslik: t.ihaleAdi, + kurum: t.idareAdi, + sehir: t.ihaleIlAdi, + tur: t.ihaleTipAciklama, + usul: t.ihaleUsulAciklama, + durum: t.ihaleDurumAciklama, + ihaleTarihSaat: t.ihaleTarihSaat, + dokumanSayisi: t.dokumanSayisi ?? 0, + })), + }; +} + // NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan. export async function getIlanlar(ihaleId) { const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId }); diff --git a/server/index.js b/server/index.js index a0692b1..2559a94 100644 --- a/server/index.js +++ b/server/index.js @@ -23,6 +23,10 @@ import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs'; import { kalemleriCikar } from './kalem-cikar.mjs'; +import { searchInDocuments } from './ekap-v2.mjs'; + +// Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma. +const urunAramaCache = new Map(); import * as kalemStore from './kalem-store.mjs'; import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs'; import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.mjs'; @@ -751,6 +755,31 @@ const server = http.createServer(async (req, res) => { const sonuc = kalemStore.kalemAra(q); return json(res, 200, { success: true, adet: sonuc.length, sonuclar: sonuc }); } + // --- Ürün araması: metni ihale DOKÜMANININ içinde arar --- + // EKAP teklif cetvelini kendi tarafında indeksliyor; doküman indirmeye + // gerek yok. "ORTOPEDİK VİDA" hiçbir başlıkta geçmiyor ama teklif + // cetvellerinde geçiyor — firmanın kendi ürününü bulma yolu bu. + if (url.pathname === '/api/urun-ara') { + const q = (url.searchParams.get('q') || '').trim(); + if (q.length < 3) return json(res, 400, { success: false, error: 'en az 3 karakter' }); + const skip = Math.max(0, Number(url.searchParams.get('skip')) || 0); + const take = Math.min(50, Math.max(1, Number(url.searchParams.get('take')) || 20)); + const teknik = url.searchParams.get('teknik') === '1'; + const anahtar = `${q}|${skip}|${take}|${teknik}`; + const simdi = Date.now(); + const onbellek = urunAramaCache.get(anahtar); + if (onbellek && simdi - onbellek.zaman < 5 * 60 * 1000) { + return json(res, 200, { success: true, onbellekten: true, ...onbellek.veri }); + } + try { + const out = await searchInDocuments(q, { teknikSartname: teknik, skip, take }); + urunAramaCache.set(anahtar, { zaman: simdi, veri: out }); + if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } if (url.pathname === '/api/health/kalem') { return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() }); } diff --git a/src/App.jsx b/src/App.jsx index 424f529..01875de 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import { DetailedSearchModal } from './components/DetailedSearchModal'; import { TenderDetailModal } from './components/TenderDetailModal'; import { CompanyScannerModal } from './components/CompanyScannerModal'; import { AiSpecAnalyzerModal } from './components/AiSpecAnalyzerModal'; +import { UrunAramaModal } from './components/UrunAramaModal'; import { KanbanBoard } from './components/KanbanBoard'; import { DailyBulletin } from './components/tools/DailyBulletin'; @@ -78,6 +79,7 @@ const MainContent = () => { + ); }; diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 1811bff..be13e5f 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,12 +1,13 @@ import React, { useState } from 'react'; import { useApp } from '../context/AppContext'; -import { Bot, Search, SlidersHorizontal, Gift, Bell, User, ChevronDown, CheckCircle, LogOut, Globe, Sparkles, RefreshCw } from 'lucide-react'; +import { Package, Bot, Search, SlidersHorizontal, Gift, Bell, User, ChevronDown, CheckCircle, LogOut, Globe, Sparkles, RefreshCw } from 'lucide-react'; export const Header = () => { const { searchQuery, setSearchQuery, setIsDetailedSearchOpen, + setIsUrunAramaOpen, setIsCompanyScannerOpen, setIsAiSpecAnalyzerOpen, userProfile, @@ -79,6 +80,15 @@ export const Header = () => { ARA + + + +
+ +
{ e.preventDefault(); ara(0); }} className="space-y-3"> +
+
+ + setQ(e.target.value)} + placeholder="Örn. ortopedik vida, hemogram kiti, kurşun önlük…" + className="w-full text-sm pl-9 pr-3 py-2.5 rounded-xl border border-slate-300 focus:ring-2 focus:ring-indigo-500 outline-none" + /> +
+ +
+ +
+ + +
+
+ +
+ +

+ Arama EKAP'ın doküman dizininde yapılır; hangi kalemin eşleştiğini EKAP döndürmüyor, + yalnızca eşleşen ihaleleri veriyor. Kalem dökümünü görmek için ihalenin dokümanını + yükleyin — kalemler çıkarılıp herkese açılır. +

+
+ + {durum.hata && ( +
+ +

{durum.hata}

+
+ )} + + {sonuc && ( + <> +
+

+ Teklif cetvelinde "{q.trim()}" geçen{' '} + {sonuc.totalCount.toLocaleString('tr-TR')} ihale bulundu +

+ {gizlenen > 0 && ( +

{gizlenen} tanesi tarihi geçtiği için gizlendi

+ )} +
+ +
+ {liste.map((t) => { + const acik = havuzda(t.ikn); + return ( +
+
+

{t.baslik}

+
+ {t.tur} + {t.ikn} +
+
+
+ + + {t.kurum} + + {t.sehir && {t.sehir}} + {t.ihaleTarihSaat && ( + {t.ihaleTarihSaat} + )} + {t.durum} + {acik && ( + + )} +
+
+ ); + })} + {liste.length === 0 && ( +

+ {sonuc.totalCount > 0 + ? 'Bu sayfadaki sonuçların tarihi geçmiş. "Sadece teklif verilebilir" kutusunu kapatarak görebilirsiniz.' + : 'Bu ürün adı hiçbir teklif cetvelinde bulunamadı. Daha kısa/genel bir terim deneyin.'} +

+ )} +
+ + {sonuc.totalCount > SAYFA && ( +
+ + + {durum.sayfa + 1} / {Math.ceil(sonuc.totalCount / SAYFA).toLocaleString('tr-TR')} + + +
+ )} + + )} +
+ + + ); +}; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index d67311f..b4e6c63 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -234,6 +234,8 @@ export const AppProvider = ({ children }) => { const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false); const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false); const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false); + // Ürün araması: metni ihale dokümanlarının içinde arar (EKAP indeksi) + const [isUrunAramaOpen, setIsUrunAramaOpen] = useState(false); const [aiAnalysisResults, setAiAnalysisResults] = useState(null); // Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da @@ -587,6 +589,8 @@ export const AppProvider = ({ children }) => { scanCompanyWebsite, updateCompanyCapabilities, isAiSpecAnalyzerOpen, + isUrunAramaOpen, + setIsUrunAramaOpen, setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, aiAnalysisResults, diff --git a/src/services/urunArama.js b/src/services/urunArama.js new file mode 100644 index 0000000..d9f5e99 --- /dev/null +++ b/src/services/urunArama.js @@ -0,0 +1,16 @@ +/** + * Ürün araması — metni ihale DOKÜMANININ içinde arar. + * + * EKAP teklif cetvelini kendi tarafında indeksliyor, yani doküman indirmeye + * (ve CAPTCHA'ya) gerek yok. Firmanın kendi ürününü bulma yolu bu: + * "ORTOPEDİK VİDA" hiçbir ihale başlığında geçmiyor ama teklif cetvellerinde + * geçiyor — o ihalelerin adı "VETERİNER İLAÇ ... SARF MALZEME". + */ +export async function urunAra(q, { skip = 0, take = 20, teknik = false } = {}) { + const p = new URLSearchParams({ q, skip: String(skip), take: String(take) }); + if (teknik) p.set('teknik', '1'); + const r = await fetch(`/api/urun-ara?${p}`); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'arama başarısız'); + return d; +}