diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index e7c3f89..bfd9fcd 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -407,3 +407,9 @@ Gerçek 390 px tarayıcı testinde dialog 337/337 px genişlikle taşmadı, ilk Ürün arama native dialog, başlık bağlantısı, açılış odağı, açık Tab sınırları ve Escape/kapat sonrası odak dönüşü kullanıyor. Mobil iç boşluklar azaltıldı; 390 px dialog içerik genişliği taşmadı. Metin/OKAS seçimleri aria-pressed taşıyor. Tarayıcı testinde ilk uygulamada Shift+Tab BODY'ye çıktı; açık odak döngüsü eklendi. AutoFocus nedeniyle kapanış kontrolünün yanlışlıkla opener kaydedilmesi de düzeltildi; gerekli durumda header ürün arama düğmesi hedefleniyor. Son gerçek tarayıcı sonucu: ters Tab aktif INPUT ile dialog içinde; Escape sonrası 0 açık dialog ve data-product-search-trigger=true olan BUTTON odakta. Kanıt evidence/qa-product-dialog-keyboard.json. 80 test ve son üretim derlemesi geçti. Gerçek arama sonucu/ihale detay geçişi, OKAS çekmecesinden açılış ve diğer modallerin erişilebilirliği halen açık. + +### Ana havuz dışı ürün arama sonucunun detayı + +Ürün arama sonuçlarında geçerli EKAP İKN varsa artık ana havuzda bulunmasa da detay açılır. Sonuç kararlı ekap-İKN kimliğiyle mevcut listeye eklenir; varsa sunucudan yüklenmiş kullanıcı durumu uygulanır. Böylece mevcut takip/hazırlık işlemleri aynı kayıt modeliyle çalışabilir. Detay metni İKN üzerinden ilan API'sinden alınır; yanıt başka İKN taşıyorsa reddedilir. İlan metni bulunmayıp yalnız temel bilgi dönmüşse belirgin kaynak uyarısı gösterilir. Hizmet/mal türü kaynaktan korunur; kalemler ve bilinmeyen tarih/saat uydurulmaz. + +82 test ve üretim derlemesi geçti. Ek testler arama kaydının kararlı kimlik ve kaynak türüyle dönüşmesini, bilinmeyen alanları boş tutmasını, İKN endpoint seçimini, eksik resmi ilan uyarısını ve farklı İKN yanıtının reddini doğruluyor. Gerçek ürün arama yanıtından detay açma/takip kaydetme tarayıcı kabulü hâlâ açık. Ürün aramasının döndürdüğü her alanın resmi dokümanla teyidi ayrıca gereklidir. diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 1d938ed..0801a3c 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -105,6 +105,7 @@ export const TenderDetailModal = () => {
{(actionError || saveError) &&

{actionError || saveError}

} + {tender.sourceWarning &&

{tender.sourceWarning}

} {detailLoad?.id === tender.id && detailLoad.status === 'loading' &&

Kaynak ilan metni yükleniyor…

} {detailLoad?.id === tender.id && detailLoad.status === 'error' &&

{detailLoad.error}

diff --git a/src/components/UrunAramaModal.jsx b/src/components/UrunAramaModal.jsx index 0e7fc1b..3165f5c 100644 --- a/src/components/UrunAramaModal.jsx +++ b/src/components/UrunAramaModal.jsx @@ -24,7 +24,7 @@ const gecmisMi = tarihSaat => { export const UrunAramaModal = () => { const { - isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders, + isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, openSearchTender, tenders, urunAramaBaslangic, setUrunAramaBaslangic, } = useApp(); const modal = useRef(null); @@ -300,12 +300,12 @@ export const UrunAramaModal = () => { {t.ihaleTarihSaat} )} {t.durum} - {acik && ( + {/^\d{4}\/\d+$/.test(String(t.ikn || '')) && ( )}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 14622b9..ba3a2c9 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,3 +1,4 @@ +import { searchTender } from '../services/searchTender'; import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch'; import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react'; import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService'; @@ -281,6 +282,14 @@ export const AppProvider = ({ children }) => { }, []); const [activeTenderDetail, setActiveTenderDetail] = useState(null); + const openSearchTender = (result) => { + const existing=tenders.find(t=>t.ikn===result.ikn); + const candidate=existing || searchTender(result); + const merged=applyTenderStates([candidate],savedTenderStates.current).find(t=>t.ikn===candidate.ikn) || candidate; + if(!existing) setTenders(previous=>previous.some(t=>t.ikn===merged.ikn) ? previous : [...previous,merged]); + setActiveTenderDetail(merged); + }; + const [detailLoad, setDetailLoad] = useState(null); const [detailRetry, setDetailRetry] = useState(0); const retryTenderDetail = () => setDetailRetry(value => value + 1); @@ -288,7 +297,7 @@ export const AppProvider = ({ children }) => { // Canlı ihalede detay açıldığında ilan metnini API'den tamamla useEffect(() => { const t = activeTenderDetail; - if (!t || !t.id?.startsWith('ilan-') || t.aciklama) { setDetailLoad(null); return; } + if (!t || !(t.id?.startsWith('ilan-') || t.id?.startsWith('ekap-')) || t.aciklama) { setDetailLoad(null); return; } let cancelled = false; const controller = new AbortController(); setDetailLoad({id:t.id,status:'loading'}); @@ -744,6 +753,7 @@ export const AppProvider = ({ children }) => { addSavedFilter, deleteSavedFilter, activeTenderDetail, + openSearchTender, detailLoad, retryTenderDetail, setActiveTenderDetail, diff --git a/src/services/searchTender.js b/src/services/searchTender.js new file mode 100644 index 0000000..882b713 --- /dev/null +++ b/src/services/searchTender.js @@ -0,0 +1,10 @@ +/** Keep unknown fields unknown when promoting a document-search result to a detail. */ +export function searchTender(result) { + const ikn=String(result.ikn || '').trim(); + if(!/^\d{4}\/\d+$/.test(ikn)) throw new Error('Bu sonuç için geçerli EKAP İKN bilgisi yok.'); + const match=String(result.ihaleTarihSaat || '').trim().match(/^(\d{2}\.\d{2}\.\d{4})(?:\s+(\d{2}:\d{2}))?/); + return {id:`ekap-${ikn}`,ikn,baslik:result.baslik || '',kurum:result.kurum || '',sehir:result.sehir || '', + tarih:match?.[1] || '',saat:match?.[2] || '',alimTuru:result.tur || '',usul:result.usul || '', + durum:result.durum || '',items:[],aciklama:'',kaynak:'EKAP ürün araması', + sourceDate:result.ihaleTarihSaat || '',discoveredFromSearch:true}; +} diff --git a/src/services/tenderApi.js b/src/services/tenderApi.js index f1b1e91..35cd76e 100644 --- a/src/services/tenderApi.js +++ b/src/services/tenderApi.js @@ -20,10 +20,17 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} = let timedOut = false; const timer = setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs); try { - const res = await fetch(`/api/tenders/${encodeURIComponent(tenderId)}/detail`, {signal:controller.signal}); + const searchResult=String(tenderId).startsWith('ekap-'); + const url=searchResult ? `/api/documents/ilan?ikn=${encodeURIComponent(tenderId.slice(5))}` : `/api/tenders/${encodeURIComponent(tenderId)}/detail`; + const res = await fetch(url, {signal:controller.signal}); 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ı'); + if (searchResult) { + if (data.ikn !== tenderId.slice(5)) throw new Error('Kaynak yanıtındaki İKN aranan ihaleyle uyuşmuyor.'); + data.aciklama=data.metin; + data.sourceWarning=data.ilanSayisi ? null : 'EKAP ilan metni bulunamadı; yalnız temel ihale bilgileri alındı. Resmi dokümanı kontrol edin.'; + } if (typeof data.aciklama !== 'string' || !data.aciklama.trim()) throw new Error('Kaynak ilan metni alınamadı. Yeniden deneyin veya asıl ilanı kontrol edin.'); return data; diff --git a/tests/search-tender.test.mjs b/tests/search-tender.test.mjs new file mode 100644 index 0000000..4f80fd3 --- /dev/null +++ b/tests/search-tender.test.mjs @@ -0,0 +1,12 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {searchTender} from '../src/services/searchTender.js'; +test('search-only tender becomes a stable detail without fabricated bid or classification fields',()=>{ + const source={ikn:'2026/1538773',baslik:'Laboratuvar hizmeti',tur:'Hizmet Alımı',ihaleTarihSaat:'22.09.2026 11:30'}; + const t=searchTender(source); + assert.equal(t.id,'ekap-2026/1538773');assert.equal(t.alimTuru,'Hizmet Alımı'); + assert.equal(t.tarih,'22.09.2026');assert.equal(t.saat,'11:30');assert.deepEqual(t.items,[]); + const unknown=searchTender({...source,ihaleTarihSaat:'bilinmiyor'}); + assert.equal(unknown.tarih,'');assert.equal(unknown.saat,'');assert.equal(unknown.aciklama,''); + assert.throws(()=>searchTender({...source,ikn:'unknown'})); +}); diff --git a/tests/tender-detail-fetch.test.mjs b/tests/tender-detail-fetch.test.mjs index 74b40eb..fc552f4 100644 --- a/tests/tender-detail-fetch.test.mjs +++ b/tests/tender-detail-fetch.test.mjs @@ -20,3 +20,17 @@ test('detail loading rejects empty source, supports retry and stops on timeout/c await assert.rejects(()=>pending,{name:'AbortError'}); } finally { globalThis.fetch=original; } }); + +test('search-only detail uses IKN endpoint and warns when official announcement is absent',async()=>{ + const original=globalThis.fetch; + try { + globalThis.fetch=async url=>{ + assert.equal(url,'/api/documents/ilan?ikn=2026%2F123'); + return Response.json({success:true,ikn:'2026/123',metin:'Temel ihale bilgisi',ilanSayisi:0}); + }; + const detail=await fetchTenderDetail('ekap-2026/123'); + assert.equal(detail.aciklama,'Temel ihale bilgisi');assert.match(detail.sourceWarning,/yalnız temel/); + globalThis.fetch=async()=>Response.json({success:true,ikn:'2026/999',metin:'Farklı kaynak'}); + await assert.rejects(fetchTenderDetail('ekap-2026/123'),/İKN/); + } finally {globalThis.fetch=original;} +});