feat: urun arama — urun adini ihale DOKUMANLARININ icinde aratir

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:55:36 +03:00
co-authored by Claude Opus 5
parent 8826ff8e68
commit ff797f5a91
7 changed files with 309 additions and 1 deletions
+40
View File
@@ -89,10 +89,14 @@ function searchBody(opts = {}) {
return { return {
searchText: opts.searchText || '', searchText: opts.searchText || '',
filterType: null, 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, ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true,
teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true, teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true,
isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true, isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true,
ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true, ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true,
...(opts.kapsam || {}),
searchType: 'GirdigimGibi', searchType: 'GirdigimGibi',
iknYili: opts.iknYili ?? null, iknYili: opts.iknYili ?? null,
iknSayi: opts.iknSayi ?? null, iknSayi: opts.iknSayi ?? null,
@@ -133,6 +137,42 @@ export async function searchTenders(opts = {}) {
return { list: data.list || [], totalCount: data.totalCount ?? 0 }; 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. // NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan.
export async function getIlanlar(ihaleId) { export async function getIlanlar(ihaleId) {
const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId }); const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId });
+29
View File
@@ -23,6 +23,10 @@ import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email
import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs';
import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs'; import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs';
import { kalemleriCikar } from './kalem-cikar.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 * as kalemStore from './kalem-store.mjs';
import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs'; import { classifyTenderCpv, classifyCpvByKeyword } from './cpv-classify.mjs';
import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSession, verifyWebhook, planForPrice, cancelSubscription, IPTAL_NEDENLERI } from './billing.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); const sonuc = kalemStore.kalemAra(q);
return json(res, 200, { success: true, adet: sonuc.length, sonuclar: sonuc }); 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') { if (url.pathname === '/api/health/kalem') {
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() }); return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
} }
+2
View File
@@ -13,6 +13,7 @@ import { DetailedSearchModal } from './components/DetailedSearchModal';
import { TenderDetailModal } from './components/TenderDetailModal'; import { TenderDetailModal } from './components/TenderDetailModal';
import { CompanyScannerModal } from './components/CompanyScannerModal'; import { CompanyScannerModal } from './components/CompanyScannerModal';
import { AiSpecAnalyzerModal } from './components/AiSpecAnalyzerModal'; import { AiSpecAnalyzerModal } from './components/AiSpecAnalyzerModal';
import { UrunAramaModal } from './components/UrunAramaModal';
import { KanbanBoard } from './components/KanbanBoard'; import { KanbanBoard } from './components/KanbanBoard';
import { DailyBulletin } from './components/tools/DailyBulletin'; import { DailyBulletin } from './components/tools/DailyBulletin';
@@ -78,6 +79,7 @@ const MainContent = () => {
<TenderDetailModal /> <TenderDetailModal />
<CompanyScannerModal /> <CompanyScannerModal />
<AiSpecAnalyzerModal /> <AiSpecAnalyzerModal />
<UrunAramaModal />
</main> </main>
); );
}; };
+11 -1
View File
@@ -1,12 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useApp } from '../context/AppContext'; 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 = () => { export const Header = () => {
const { const {
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
setIsDetailedSearchOpen, setIsDetailedSearchOpen,
setIsUrunAramaOpen,
setIsCompanyScannerOpen, setIsCompanyScannerOpen,
setIsAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
userProfile, userProfile,
@@ -79,6 +80,15 @@ export const Header = () => {
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
<span className="font-semibold text-xs tracking-wider">ARA</span> <span className="font-semibold text-xs tracking-wider">ARA</span>
</button> </button>
<button
type="button"
onClick={() => setIsUrunAramaOpen(true)}
title="Ürün Arama — ürün adınızı ihale dokümanlarının içinde arar"
className="bg-amber-400 hover:bg-amber-300 text-slate-950 font-black px-3 py-2 rounded-lg text-xs flex items-center gap-1.5 transition-colors shrink-0"
>
<Package className="w-4 h-4" />
<span className="hidden sm:inline tracking-wide">ÜRÜN ARA</span>
</button>
<button <button
type="button" type="button"
onClick={() => setIsDetailedSearchOpen(true)} onClick={() => setIsDetailedSearchOpen(true)}
+207
View File
@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { useApp } from '../context/AppContext';
import {
X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info,
} from 'lucide-react';
import { urunAra } from '../services/urunArama';
/**
* Ürün Arama — ürün adını ihale dokümanlarının içinde aratır.
*
* İhalelerin sadece başlığına bakmak yetersiz: "ORTOPEDİK VİDA" hiçbir
* başlıkta geçmiyor ama üç ihalenin teklif cetvelinde var, ve o ihalelerin
* adı "VETERİNER İLAÇ ... SARF MALZEME". Bu ekran o boşluğu kapatıyor.
*/
const SAYFA = 20;
const gecmisMi = (tarihSaat) => {
const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(String(tarihSaat || ''));
if (!m) return false;
const [, g, a, y, ss = '23', dd = '59'] = m;
return new Date(+y, +a - 1, +g, +ss, +dd) < new Date();
};
export const UrunAramaModal = () => {
const { isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders } = useApp();
const [q, setQ] = useState('');
const [teknik, setTeknik] = useState(false);
const [sadeceAcik, setSadeceAcik] = useState(true);
const [durum, setDurum] = useState({ yukleniyor: false, sonuc: null, hata: null, sayfa: 0 });
if (!isUrunAramaOpen) return null;
const ara = async (sayfa = 0) => {
const terim = q.trim();
if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; }
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
try {
const d = await urunAra(terim, { skip: sayfa * SAYFA, take: SAYFA, teknik });
setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
} catch (e) {
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
}
};
const sonuc = durum.sonuc;
const liste = (sonuc?.list || []).filter((t) => !sadeceAcik || !gecmisMi(t.ihaleTarihSaat));
const gizlenen = (sonuc?.list?.length || 0) - liste.length;
// Havuzdaki açık ihaleyle eşleşirse kartı doğrudan açabilelim
const havuzda = (ikn) => tenders?.find((t) => t.ikn === ikn) || null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/75 backdrop-blur-xs animate-fade-in">
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[92vh]">
<div className="bg-linear-to-r from-indigo-900 via-blue-900 to-slate-900 text-white px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-400 text-slate-900 flex items-center justify-center shadow-md">
<Package className="w-6 h-6" />
</div>
<div>
<h2 className="font-extrabold text-base">Ürün Arama</h2>
<p className="text-xs text-blue-200">
Ürün adınızı ihale <strong>dokümanlarının içinde</strong> arar — başlıkta geçmese bile bulur.
</p>
</div>
</div>
<button onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6 space-y-4 overflow-y-auto">
<form onSubmit={(e) => { e.preventDefault(); ara(0); }} className="space-y-3">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={q}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={durum.yukleniyor}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white font-bold text-xs px-5 rounded-xl flex items-center gap-2"
>
{durum.yukleniyor ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Ara
</button>
</div>
<div className="flex items-center gap-4 flex-wrap text-xs">
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
<input type="checkbox" checked={teknik} onChange={(e) => setTeknik(e.target.checked)} className="accent-indigo-600" />
Teknik şartnamede de ara
</label>
<label className="flex items-center gap-1.5 font-semibold text-slate-700 cursor-pointer">
<input type="checkbox" checked={sadeceAcik} onChange={(e) => setSadeceAcik(e.target.checked)} className="accent-indigo-600" />
Sadece teklif verilebilir ihaleler
</label>
</div>
</form>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 flex gap-2.5">
<Info className="w-4 h-4 text-blue-700 shrink-0 mt-0.5" />
<p className="text-[11px] text-blue-900 leading-relaxed">
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.
</p>
</div>
{durum.hata && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3 flex gap-2">
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0 mt-0.5" />
<p className="text-xs text-red-800">{durum.hata}</p>
</div>
)}
{sonuc && (
<>
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<p className="text-xs font-bold text-slate-900">
Teklif cetvelinde <span className="text-indigo-700">"{q.trim()}"</span> geçen{' '}
<span className="text-indigo-700">{sonuc.totalCount.toLocaleString('tr-TR')}</span> ihale bulundu
</p>
{gizlenen > 0 && (
<p className="text-[11px] text-slate-500">{gizlenen} tanesi tarihi geçtiği için gizlendi</p>
)}
</div>
<div className="space-y-2">
{liste.map((t) => {
const acik = havuzda(t.ikn);
return (
<div key={t.ikn} className="border border-slate-200 rounded-xl p-3 hover:border-indigo-300 hover:bg-indigo-50/30 transition-colors">
<div className="flex items-start justify-between gap-3 flex-wrap">
<p className="font-bold text-xs text-slate-900 flex-1 min-w-0 leading-snug">{t.baslik}</p>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] font-bold bg-slate-100 text-slate-700 px-2 py-0.5 rounded">{t.tur}</span>
<span className="text-[10px] font-mono text-slate-500">{t.ikn}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-600 mt-1.5">
<span className="flex items-center gap-1 truncate max-w-sm">
<Building2 className="w-3 h-3 text-slate-400 shrink-0" />
<span className="truncate">{t.kurum}</span>
</span>
{t.sehir && <span className="flex items-center gap-1"><MapPin className="w-3 h-3 text-rose-500" />{t.sehir}</span>}
{t.ihaleTarihSaat && (
<span className="flex items-center gap-1"><CalendarDays className="w-3 h-3 text-blue-600" />{t.ihaleTarihSaat}</span>
)}
<span className="text-slate-400">{t.durum}</span>
{acik && (
<button
onClick={() => { setActiveTenderDetail(acik); setIsUrunAramaOpen(false); }}
className="font-bold text-indigo-700 hover:underline"
>
Havuzda açık — detayı aç
</button>
)}
</div>
</div>
);
})}
{liste.length === 0 && (
<p className="text-xs text-slate-500 text-center py-6">
{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.'}
</p>
)}
</div>
{sonuc.totalCount > SAYFA && (
<div className="flex items-center justify-center gap-3 pt-2">
<button
disabled={durum.sayfa === 0 || durum.yukleniyor}
onClick={() => ara(durum.sayfa - 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
← Önceki
</button>
<span className="text-xs text-slate-500">
{durum.sayfa + 1} / {Math.ceil(sonuc.totalCount / SAYFA).toLocaleString('tr-TR')}
</span>
<button
disabled={(durum.sayfa + 1) * SAYFA >= sonuc.totalCount || durum.yukleniyor}
onClick={() => ara(durum.sayfa + 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
>
Sonraki →
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};
+4
View File
@@ -234,6 +234,8 @@ export const AppProvider = ({ children }) => {
const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false); const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false);
const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false); const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false);
const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = 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); const [aiAnalysisResults, setAiAnalysisResults] = useState(null);
// Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da // Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da
@@ -587,6 +589,8 @@ export const AppProvider = ({ children }) => {
scanCompanyWebsite, scanCompanyWebsite,
updateCompanyCapabilities, updateCompanyCapabilities,
isAiSpecAnalyzerOpen, isAiSpecAnalyzerOpen,
isUrunAramaOpen,
setIsUrunAramaOpen,
setIsAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen,
analyzeSpecPdfWithGemini, analyzeSpecPdfWithGemini,
aiAnalysisResults, aiAnalysisResults,
+16
View File
@@ -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;
}