Expose partial item search failures and allow query-scoped retries

This commit is contained in:
2026-09-06 10:31:58 +03:00
parent 0c16bbd96f
commit 21e80d0dd6
5 changed files with 53 additions and 12 deletions
+8
View File
@@ -587,3 +587,11 @@ SQLite desteği olmayan ortamda ortak kalem deposu artık boş liste gibi görü
`IHALEIST_SHARED_DATA_DIR` ile ortak kalem deposu dizini yapılandırılabilir. Tarayıcı QA ve HTTP test sunucuları ortak kalem verisini kendi geçici dizinlerinde tutacak şekilde güncellendi. Önceden başlatılmış test süreçleri yeniden başlatılmadı; mevcut veri dosyaları silinmedi. Diğer ortak veri depolarının tamamı bu değişiklikle izole edilmiş sayılmaz.
Test: bozuk gerçek SQLite dosyasıyla açılış başarısızlığı, dosyanın onarılmasından sonra aynı süreçte boş liste ve yeni kalem yazma/okuma. Tüm 128 test ve üretim derlemesi geçti. HTTP testinin yeni izole depo ayarı sonrasında dört HTTP kabul testi ayrıca geçti. Canlı sistemde yapay arıza oluşturulmadı. Büyük paket uyarıları ve planın kalan kapsamı açık; canlı yayın yapılmadı.
### D05 — Ana aramada kalem kaynağı durumu
Ortak kalem araması başarısız olduğunda istemci boş dizi, AppContext ise sessiz boş durum üretiyordu. Artık servis HTTP/geçersiz yanıt hatalarını ayrı döndürür; istek iptal edilebilir ve otuz saniyede zaman aşımına uğrar. Ana aramanın kalem sonuçları sorguya bağlı yükleniyor/tamamlandı/hata durumları taşır. Sorgu değişikliği önceki isteği iptal eder; geç yanıt/hata yeni sorguya atanmaz.
Liste kalem araması sürerken sonuçların genişleyebileceğini, hata olduğunda yalnız başlık/kurum/yüklü kart aramasıyla devam edildiğini ve sonuçların eksik olabileceğini açıklar. Kalem aramasını yeniden deneme düğmesi eklendi. Gerçek boş kalem sonucu hata sayılmaz.
Doğrulama: boş başarılı sonuç, depo 503 hatası ve iptal edilmiş arama için servis testi. Mevcut sorgu eşleşmesi testleriyle birlikte tüm 129 test ve üretim derlemesi geçti. Yeni hata durumunun tarayıcı kabulü açık. Büyük paket uyarıları sürüyor; canlı yayın yapılmadı.
+6 -1
View File
@@ -16,7 +16,7 @@ export const TenderList = () => {
const {
tenders,
searchQuery,
kalemEslesen,
kalemEslesen, itemSearchState, retryItemSearch,
uyumEsigi,
selectedCity,
selectedAlimTuru,
@@ -174,6 +174,11 @@ export const TenderList = () => {
</div>
{sortBy === 'newest' && <p className="text-xs text-slate-500">Yayın tarihi bilinmeyen kayıtlar sonda gösterilir. Aynı gün içinde saati bilinen kayıtlar önce gelir.</p>}
{itemSearchState?.status === 'loading' && <p role="status" className="text-xs text-slate-600">Doküman kalemlerinde arama sürüyor; listedeki sonuçlar henüz genişleyebilir.</p>}
{itemSearchState?.status === 'error' && <div role="alert" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<p>{itemSearchState.error} Başlık, kurum ve yüklü kart bilgileriyle arama sürüyor; sonuçlar eksik olabilir.</p>
<button type="button" onClick={retryItemSearch} className="mt-1 underline font-semibold">Kalem aramasını yeniden dene</button>
</div>}
{preparedExport && preparedExport.session === getToken() && <div role="status" className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs space-y-1">
<p>{preparedExport.createdAt} itibarıyla {preparedExport.count} ihale için Excel hazır{preparedExport.query ? ` — arama: ${preparedExport.query}` : ''}.</p>
<a href={preparedExport.url} download={preparedExport.name} className="font-bold text-emerald-800 underline">Excel dosyasını indir — {preparedExport.name}</a>
+11 -6
View File
@@ -58,21 +58,26 @@ export const AppProvider = ({ children }) => {
// ilk birkaç kalem taşınıyor, tam arama sunucuda yapılıyor.
const [kalemSonucu, setKalemSonucu] = useState(null);
const kalemEslesen = matchingItemResults(kalemSonucu, searchQuery);
const [kalemRetry,setKalemRetry] = useState(0);
const retryItemSearch = () => setKalemRetry(n=>n+1);
const itemSearchState = kalemSonucu?.query === normalizeTenderSearch(searchQuery) ? kalemSonucu : null;
useEffect(() => {
const q = searchQuery.trim();
if (q.length < 2) { setKalemSonucu(null); return; }
let iptal = false;
const controller=new AbortController();
setKalemSonucu({query:normalizeTenderSearch(q),status:'loading',matches:null});
const zaman = setTimeout(() => {
import('../services/kalem')
.then(({ kalemAra }) => kalemAra(q))
.then(({ kalemAra }) => kalemAra(q,{signal:controller.signal}))
.then((sonuclar) => {
if (iptal) return;
setKalemSonucu({query:normalizeTenderSearch(q),matches:sonuclar.length ? new Map(sonuclar.map((s) => [s.ikn, s.eslesen])) : null});
setKalemSonucu({query:normalizeTenderSearch(q),status:'loaded',matches:sonuclar.length ? new Map(sonuclar.map((s) => [s.ikn, s.eslesen])) : null});
})
.catch(() => { if (!iptal) setKalemSonucu(null); });
.catch(error => { if (!iptal) setKalemSonucu({query:normalizeTenderSearch(q),status:'error',error:error.message,matches:null}); });
}, 350);
return () => { iptal = true; clearTimeout(zaman); };
}, [searchQuery]);
return () => { iptal = true; controller.abort(); clearTimeout(zaman); };
}, [searchQuery,kalemRetry]);
const [selectedCity, setSelectedCity] = useState('');
const [selectedAlimTuru, setSelectedAlimTuru] = useState('');
const [selectedKurumTipi, setSelectedKurumTipi] = useState('');
@@ -725,7 +730,7 @@ export const AppProvider = ({ children }) => {
handleLogout,
liveStats,
searchQuery,
kalemEslesen,
kalemEslesen, itemSearchState, retryItemSearch,
setSearchQuery,
selectedCity,
setSelectedCity,
+15 -5
View File
@@ -26,11 +26,21 @@ export async function kalemleriGetir(ikn, {signal, timeoutMs = 30000} = {}) {
}
/** Kalem metninde arama — eşleşen İKN'ler. */
export async function kalemAra(q) {
const r = await fetch(`/api/kalem/ara?q=${encodeURIComponent(q)}`);
const d = await r.json().catch(() => ({}));
if (!d.success) return [];
return d.sonuclar || [];
export async function kalemAra(q,{signal,timeoutMs=30000}={}) {
const controller=new AbortController();
const cancel=()=>controller.abort();
if(signal?.aborted)cancel();else signal?.addEventListener('abort',cancel,{once:true});
let timedOut=false;
const timer=setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs);
try {
const r=await fetch(`/api/kalem/ara?q=${encodeURIComponent(q)}`,{signal:controller.signal});
const d=await r.json().catch(()=>null);
if(!r.ok || d?.success!==true || !Array.isArray(d.sonuclar))throw new Error('Doküman kalemlerinde arama tamamlanamadı.');
return d.sonuclar;
} catch(error) {
if(timedOut)throw new Error('Doküman kalemlerinde arama zaman aşımına uğradı.');
throw error;
} finally {clearTimeout(timer);signal?.removeEventListener('abort',cancel);}
}
/**
+13
View File
@@ -23,3 +23,16 @@ test('shared inventory waits are bounded and can be cancelled on tender change',
await assert.rejects(pending,{name:'AbortError'});
} finally {globalThis.fetch=original;}
});
test('item search does not convert unavailable inventory into zero matches',async()=>{
const {kalemAra}=await import('../src/services/kalem.js');
const original=globalThis.fetch;
try {
globalThis.fetch=async()=>Response.json({success:true,sonuclar:[]});
assert.deepEqual(await kalemAra('tüp'),[]);
globalThis.fetch=async()=>Response.json({success:false,error:'storage offline'},{status:503});
await assert.rejects(kalemAra('tüp'),/arama tamamlanamadı/);
const c=new AbortController();c.abort();
globalThis.fetch=async(url,{signal})=>{signal.throwIfAborted();};
await assert.rejects(kalemAra('tüp',{signal:c.signal}),{name:'AbortError'});
} finally {globalThis.fetch=original;}
});