/** * ihaleist canlı veri istemcisi. * Backend proxy (server/index.js) üzerinden ilan.gov.tr resmi ihale ilanlarını çeker. * Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir. */ function sourceError(data, temporaryMessage) { if (data.code === 'HUMAN_VERIFICATION_REQUIRED' && data.manualAction) { return new Error(data.error || 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.'); } if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) return new Error(temporaryMessage); return null; } export async function fetchLiveTenders({ refresh = false } = {}) { const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`); const data = await res.json().catch(() => ({})); if (!res.ok) { const failure = sourceError(data, 'İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.'); if (failure) throw failure; throw new Error(`API hatası: HTTP ${res.status}`); } if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü'); return data; } export async function fetchTenderDetail(tenderId, {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 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}); const data = await res.json().catch(() => ({})); if (!res.ok) { const failure = sourceError(data, 'Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.'); if (failure) throw failure; throw new Error(`Detay API hatası: HTTP ${res.status}`); } 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; const warnings = [ !data.ilanSayisi && 'EKAP ilan metni bulunamadı; yalnız temel ihale bilgileri alındı. Resmi dokümanı kontrol edin.', data.sourceDateWarning, ].filter(Boolean); data.sourceWarning=warnings.length ? warnings.join(' ') : null; } 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; } catch (error) { if (timedOut) throw new Error('İlan metni yüklenirken zaman aşımı oluştu. Yeniden deneyin.'); throw error; } finally { clearTimeout(timer); signal?.removeEventListener('abort',cancel); } } // Live ilan.gov.tr rows can carry an older announcement date than EKAP's // authoritative tender detail. Fetch the official record when a user opens a // tender so the modal/card can reconcile the date without enriching the full // pool (which would be too expensive for thousands of rows). export async function fetchOfficialTenderDetail(ikn, {signal, timeoutMs = 30000} = {}) { const cleanIkn = String(ikn || '').replace(/[\s\u00a0]+/g, ''); if (!/^\d{4}\/\d+$/.test(cleanIkn)) throw new Error('Geçersiz İKN'); 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 res = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(cleanIkn)}`, { signal: controller.signal }); const data = await res.json().catch(() => ({})); if (!res.ok) { const failure = sourceError(data, 'Resmî ihale kaynağı geçici olarak erişilemiyor.'); if (failure) throw failure; throw new Error(`Resmî detay API hatası: HTTP ${res.status}`); } if (!data.success || data.ikn !== cleanIkn) throw new Error('Resmî detay yanıtı aranan İKN ile uyuşmuyor.'); if (!data.tarih || !data.saat) throw new Error('Resmî detayda ihale tarih ve saati bulunamadı.'); return data; } catch (error) { if (timedOut) throw new Error('Resmî ihale tarihi yüklenirken zaman aşımı oluştu.'); throw error; } finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); } } // Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir. const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel', 'gorev', 'notes', 'checklist', 'bidDraft']; export function mergeUserState(liveTenders, previousTenders) { const prevById = new Map((previousTenders || []).map((t) => [t.id, t])); return liveTenders.map((tender) => { const prev = prevById.get(tender.id); if (!prev) return tender; const merged = { ...tender }; for (const f of USER_FIELDS) { if (prev[f] !== undefined) merged[f] = prev[f]; } if (prev.aciklama && !merged.aciklama) merged.aciklama = prev.aciklama; return merged; }); }