Open and retain tender details discovered outside the main pool

This commit is contained in:
2026-09-06 10:01:34 +03:00
parent 819a10c006
commit a136a74a1b
8 changed files with 66 additions and 6 deletions
+6
View File
@@ -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.
+1
View File
@@ -105,6 +105,7 @@ export const TenderDetailModal = () => {
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby={dialogTitleId} tabIndex={-1} onKeyDown={handleDialogKey} className="bg-white w-full max-w-6xl rounded-2xl shadow-2xl border border-slate-200 overflow-hidden my-auto flex flex-col max-h-[92vh]">
{(actionError || saveError) && <p role="alert" className="p-3 bg-red-50 text-red-900">{actionError || saveError}</p>}
{tender.sourceWarning && <p role="alert" className="p-3 bg-amber-50 text-amber-900 text-sm">{tender.sourceWarning}</p>}
{detailLoad?.id === tender.id && detailLoad.status === 'loading' && <p role="status" className="p-3 bg-blue-50 text-blue-900">Kaynak ilan metni yükleniyor…</p>}
{detailLoad?.id === tender.id && detailLoad.status === 'error' && <div role="alert" className="p-3 bg-amber-50 text-amber-900 text-sm">
<p>{detailLoad.error}</p>
+4 -4
View File
@@ -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 = () => {
<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 && (
{/^\d{4}\/\d+$/.test(String(t.ikn || '')) && (
<button
onClick={() => { setActiveTenderDetail(acik); setIsUrunAramaOpen(false); }}
onClick={() => { openSearchTender(t); setIsUrunAramaOpen(false); }}
className="font-bold text-indigo-700 hover:underline"
>
Havuzda açık — detayı aç
{acik ? 'Listedeki detayı aç' : 'İhale detayını aç'}
</button>
)}
</div>
+11 -1
View File
@@ -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,
+10
View File
@@ -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};
}
+8 -1
View File
@@ -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;
+12
View File
@@ -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'}));
});
+14
View File
@@ -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;}
});