diff --git a/docs/quality/evidence/qa-kanban-after-reload.png b/docs/quality/evidence/qa-kanban-after-reload.png new file mode 100644 index 0000000..e45c29e Binary files /dev/null and b/docs/quality/evidence/qa-kanban-after-reload.png differ diff --git a/docs/quality/evidence/qa-kanban-after-reload.txt b/docs/quality/evidence/qa-kanban-after-reload.txt new file mode 100644 index 0000000..11408e0 --- /dev/null +++ b/docs/quality/evidence/qa-kanban-after-reload.txt @@ -0,0 +1,144 @@ +- banner: + - text: İhale + - generic: ist + - textbox "İhale, kurum veya İKN no ile arayın..." + - button "ARA": + - generic: ARA + - button "ÜRÜN ARA": + - generic: ÜRÜN ARA + - button "Detaylı İhale Arama": + - button "AI Şartname Analizi": + - generic: AI Şartname Analizi + - button "Web Siteni Tara (AI)": + - generic: Web Siteni Tara (AI) + - generic: 7 gün + - button: + - button "L Laboratuvar": + - generic: L + - generic: Laboratuvar +- generic: "5" +- generic: Canlı İhale Havuzu +- generic: "5" +- generic: Yüklü İhale +- generic: "0" +- generic: Bugün Eklenen +- generic: "5" +- generic: Kalem +- generic: "0" +- generic: Sonuç Arşivi +- generic: "5" +- generic: Takibimdeki +- button "Önceki sekmeler": +- button "Sonraki sekmeler": +- button "Ana İhale Listesi": + - generic: Ana İhale Listesi +- button "Günlük Bülten": + - generic: Günlük Bülten +- button "Sonuç Sorgula": + - generic: Sonuç Sorgula +- button "İhale Takvimi": + - generic: İhale Takvimi +- button "Sözleşme Sorgula": + - generic: Sözleşme Sorgula +- button "KDV Hesapla": + - generic: KDV Hesapla +- button "Sınır Değer": + - generic: Sınır Değer +- button "Fiyat Analizi": + - generic: Fiyat Analizi +- button "Kanban Pano Sürükle Takip" [active]: + - generic: Kanban Pano + - generic: Sürükle Takip +- button "Rakip Radar Canlı Uyar": + - generic: Rakip Radar + - generic: Canlı Uyar +- button "Doküman Deposu": + - generic: Doküman Deposu +- button "Analitik Dashboard": + - generic: Analitik Dashboard +- button "Kırmızı Bayrak Şeffaflık": + - generic: Kırmızı Bayrak + - generic: Şeffaflık +- button "KİK Kurul Kararları": + - generic: KİK Kurul Kararları +- button "AB İhaleleri (TED)": + - generic: AB İhaleleri (TED) +- button "Planlar": + - generic: Planlar +- button "API & Webhooks": + - generic: API & Webhooks +- button "Mobil PWA": + - generic: Mobil PWA +- main: + - heading "Ekip Kanban Takip Panosu" [level=1] + - paragraph: Kartları kolonlar arasında sürükleyerek ihale süreçlerinizi yönetin. + - combobox: + - option "Tüm Personeller" [selected] + - button "💬 Slack/Teams'e Gönder": + - generic: 💬 Slack/Teams'e Gönder + - generic: İncelenecek + - generic: "0" + - paragraph: İhale yok + - generic: İnceleniyor + - generic: "0" + - paragraph: İhale yok + - generic: Teklif Hazırlanıyor + - generic: "5" + - generic: "#2026/1582991" + - 'generic "Teslim: 10.09.2026 10:00"': 5 gün + - button "Detayı Aç": + - heading "QA — Giresun laboratuvar" [level=4] + - text: Giresun + - text: 10.09.2026 + - generic: "Atanan:" + - combobox: + - option "Atama Yap" [selected] + - generic: "#2026/1534665" + - 'generic "Teslim: 22.09.2026 11:00"': 17 gün + - button "Detayı Aç": + - heading "QA — Kayseri laboratuvar" [level=4] + - text: Kayseri + - text: 22.09.2026 + - generic: "Atanan:" + - combobox: + - option "Atama Yap" [selected] + - generic: "#2026/1499061" + - 'generic "Teslim: 22.09.2026 10:30"': 17 gün + - button "Detayı Aç": + - heading "QA — Akdeniz laboratuvar" [level=4] + - text: Akdeniz + - text: 22.09.2026 + - generic: "Atanan:" + - combobox: + - option "Atama Yap" [selected] + - generic: "#2026/1573235" + - 'generic "Teslim: 09.09.2026 10:00"': 4 gün + - button "Detayı Aç": + - heading "QA — Aksaray laboratuvar" [level=4] + - text: Aksaray + - text: 09.09.2026 + - generic: "Atanan:" + - combobox: + - option "Atama Yap" [selected] + - generic: "#2026/1523851" + - 'generic "Teslim: 11.09.2026 10:00"': 6 gün + - button "Detayı Aç": + - heading "QA — Tepecik laboratuvar" [level=4] + - text: Tepecik + - text: 11.09.2026 + - generic: "Atanan:" + - combobox: + - option "Atama Yap" [selected] + - generic: Teklif Verildi + - generic: "0" + - paragraph: İhale yok + - generic: Kazanıldı + - generic: "0" + - paragraph: İhale yok + - generic: Kaybedildi + - generic: "0" + - paragraph: İhale yok +- button +- button "Kullanım kılavuzu (? tuşu)": +- region "Notifications (F8)": + - list \ No newline at end of file diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index e50b035..24322d5 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -9,14 +9,27 @@ Planın tamamı henüz bitmedi. Canlı yayın yapılmadı. - Eski belgeler tablosunda ikn indeksi kolon migration'ından önce oluşuyordu. Transaction içinde kolonlar önce ekleniyor; eski belgeyi koruyan, tekrarlanabilir migration testi geçti. - Veri deposu hataları artık boş hesap veya başarılı yazma gibi yutulmuyor. - İhale başına kullanıcıya ait hazırlık durumu, snapshot ve sürüm saklanıyor. Yeni API sürüm çakışmasında 409 döndürüyor. İki kullanıcı ve beş kayıt testi geçti. -- Arayüz görev/takip/atama/not işlemlerini sunucu onayıyla güncelliyor. Tarayıcıyla uçtan uca doğrulama bekliyor. +- Arayüz görev/takip/atama/not işlemlerini sunucu onayıyla güncelliyor. İzole tarayıcı testinde beş sentetik ihale görevi yenilemeden sonra Kanban’da korundu; atama/not ve yeniden giriş kabulü ayrıca bekliyor. - ICS sabit tarih kullanmıyor. Beş tarih, yıl geçişi, artık gün, geçersiz tarih ve UTF-8 katlama testleri geçti. - Uydurma 150 TL fiyat/sürüklenen kaşe simülatörü gerçek hazırlık formuyla değiştirildi. Boş kalem, fiyat, süre ve firma kontrolleri eklendi. Bu alanlarda görsel/işlevsel test bekliyor. - Şehir filtresi mevcut 81 il tablosundan besleniyor. +## İkinci değişiklik paketi + +- Tam ilan metnindeki 4.000 karakter sınırı kaldırıldı; saat/teslimat gibi alanlar kaynak metinden çıkarılıyor. Sentetik uzun metin testi geçti; beş resmi metinle kabul bekliyor. +- DT kayıtları kurum ve kimlik ile tekilleştiriliyor, kaynak kimlikleri korunuyor. Farklı sözleşme kısımları birleştirilmiyor. Mevcut kullanıcı referanslarının toplu dönüşümü hâlâ açık. +- Cetvel miktar/birim ayrıştırması ve para yuvarlaması ortak yardımcıya taşındı. Eksik fiyat engeli, Türkçe sayı ve 100 satırlık yazdırılabilir HTML testi geçti. Gerçek PDF sayfalama testi yerine geçmez. +- Firma, fiyatlar, süre ve teklif türü hazırlık taslağı olarak sunucuya kaydedilebiliyor. HTTP testinde kimlik doğrulama, hesap ayrımı, kalıcılık ve eski sürüme 409 yanıtı doğrulandı. Formun tarayıcıda yeniden yüklenmesi ayrıca test edilmeli. +- Sabit kurlar kaldırıldı; TCMB döviz satış kuru bülten tarihi ve kaynakla gösteriliyor. Kaynak hatasında uydurma değer üretilmiyor. Kategori tutarından birim fiyat önerisi ve kesin eşleşme garantisi kaldırıldı. +- Belge okuyucularının `metin`/`text` uyumsuzluğu giderildi. Bu, AI kaynak çelişkilerinin tamamının çözüldüğü anlamına gelmiyor; sürümlü ortak model açık. +- Ürün arama sonuçları son çalıştırılan sorguya bağlı. Sonraki/önceki sayfa aynı sorguyu kullanıyor; düzenlenmiş alanlar için önceki sonuç uyarısı gösteriliyor. Kapanan aramanın geç yanıtı yok sayılıyor ve yüklenme durumu sıfırlanıyor. Etkileşimli regresyon testi bekliyor. +- `tests/browser-qa.mjs` localhost üzerinde geçici hesap/veritabanı ve açıkça sentetik beş ilanla test ortamı sağlar. Üretim giriş mekanizmasına eklenmez. + +Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-reload.png), [ekran metni](evidence/qa-kanban-after-reload.txt). Bu kanıt gerçek beş ihale dokümanıyla başvuru kabulü değildir. + ## Doğrulama -`node --test tests/*.test.mjs`: 9/9 geçti. +`npm test`: 19/19 geçti (son yeniden çalıştırma: 6 Eylül 2026). `npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor. `node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti. @@ -34,4 +47,4 @@ Planın tamamı henüz bitmedi. Canlı yayın yapılmadı. - D10: beş resmi doküman ve uygun test firma verisiyle gerçek hazırlık kabulü; tarihleri geçmişse güncel beş adayla ayrıca kabul. - D11: küçük sürümlerle yayın, yedek/geri dönüş ve canlı ölçümler. -Kod testleri bütün planın tamamlandığını kanıtlamaz. Uçtan uca tarayıcı ve canlı kabul henüz yoktur. +Kod testleri bütün planın tamamlandığını kanıtlamaz. Tarayıcı kanıtı şu an yalnız sentetik görev kalıcılığı senaryosunu kapsar; kapsamlı uçtan uca ve canlı kabul henüz yoktur. diff --git a/server/ai-teklif.mjs b/server/ai-teklif.mjs index 291e68e..a690acb 100644 --- a/server/ai-teklif.mjs +++ b/server/ai-teklif.mjs @@ -1,3 +1,4 @@ +import { documentContent } from './document-content.mjs'; // Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut // analyzeSpec zinciri (yedekli sağlayıcılar) üzerinde: // 1) belgeyeSor: ihale dokümanı/ilan metninde soru → atıflı yanıt @@ -47,21 +48,22 @@ export function getir(parcalar, soru, k = 8) { } /** İhalenin metni: verilen metin > EKAP dokümanı (tarayıcı varsa) > ilan metni. */ -export async function ihaleMetni(ikn, verilen) { +export async function ihaleMetni(ikn, verilen, ilan) { if (verilen && String(verilen).trim().length > 200) return { text: String(verilen), kaynak: 'yüklenen' }; if (ikn && (await browserAvailable().catch(() => false))) { - try { const r = await documentText(ikn); if (r?.text && r.text.length > 200) return { text: r.text, kaynak: 'EKAP dokümanı' }; } catch { /* ilan metnine düş */ } + try { const text = documentContent(await documentText(ikn)); if (text.length > 200) return { text, kaynak: 'EKAP dokümanı' }; } catch { /* ilan metnine düş */ } } if (ikn) { - try { const r = await ilanMetni(ikn); const text = typeof r === 'string' ? r : r?.text; if (text && text.length > 100) return { text, kaynak: 'ilan metni' }; } catch { /* yok */ } + try { const text = documentContent(await ilanMetni(ikn)); if (text && text.length > 100) return { text, kaynak: 'ilan metni' }; } catch { /* yok */ } } + if (String(ilan || '').trim().length > 100) return {text:String(ilan),kaynak:'İhale ilanı (teknik şartname değil)'}; return { text: '', kaynak: null }; } export async function belgeyeSor({ ikn, soru, text, tender, chain }) { const q = String(soru ?? '').trim().slice(0, 500); if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.'); - const { text: metin, kaynak } = await ihaleMetni(ikn, text); + const { text: metin, kaynak } = await ihaleMetni(ikn, text, tender?.aciklama); if (!metin) throw new Error('Bu ihale için doküman metni bulunamadı — PDF\'i "Yapay Zeka Asistanı"ndan yükleyip tekrar sorun.'); const parcalar = parcala(metin); const kaynaklar = getir(parcalar, q, 8); @@ -96,7 +98,7 @@ function gereklilikMetni(tender, gereklilikler, metin) { export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) { const t = String(taslak ?? '').trim().slice(0, 60000); if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.'); - const { text: metin } = await ihaleMetni(ikn, text).catch(() => ({ text: '' })); + const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama).catch(() => ({ text: '' })); const liste = gereklilikMetni(tender, gereklilikler, metin); if (!liste.length) throw new Error('Gereklilik listesi yok — önce derin analiz çalıştırın veya dokümanı yükleyin.'); const sistem = `Sen kamu ihalelerinde teklif dosyası denetçisisin. Gereklilik listesi ve teklif TASLAĞI verilecek. Her gereklilik için taslağı kontrol et. "karsilaniyor" yalnızca taslakta açık karşılığı varsa (alıntıyı 'kanit'e aynen kopyala); değinilmiyorsa "eksik"; kısmen/belirsizse "belirsiz". Sayı üretme. Yalnızca JSON: {"ozet":"2-3 cümle","maddeler":[{"gereklilik":"","durum":"karsilaniyor"|"eksik"|"belirsiz","kanit":"veya null","oneri":"veya null"}]} — maddeleri verilen sırada döndür.`; @@ -121,7 +123,7 @@ const BOLUMLER = { export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) { const istenen = BOLUMLER[tur] || BOLUMLER.tam; - const { text: metin } = await ihaleMetni(ikn, text).catch(() => ({ text: '' })); + const { text: metin } = await ihaleMetni(ikn, text, tender?.aciklama).catch(() => ({ text: '' })); const liste = gereklilikMetni(tender, gereklilikler, metin); const baglam = [ `İHALE: ${tender?.baslik ?? '?'} · İdare: ${tender?.kurum ?? '?'} · İl: ${tender?.sehir ?? '?'} · Tarih: ${tender?.tarih ?? '?'} · Usul: ${tender?.usul ?? tender?.alimTuru ?? '?'}${tender?.yaklasikMaliyet ? ' · Yaklaşık maliyet: ' + tender.yaklasikMaliyet : ''}${ikn ? ' · İKN ' + ikn : ''}`, diff --git a/server/ai.mjs b/server/ai.mjs index f706d1f..a8096b6 100644 --- a/server/ai.mjs +++ b/server/ai.mjs @@ -109,6 +109,9 @@ Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`; export const DERIN_SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734 sayılı Kamu İhale Kanunu, 4735 sayılı Kamu İhale Sözleşmeleri Kanunu ve ikincil mevzuat) konusunda çok deneyimli bir ihale danışmanısın. Sana verilen teknik şartname / idari şartname / sözleşme tasarısı / ihale ilanı metnini madde madde incele. İki şey üret: (1) derinlemesine analiz, (2) teklif anına kadar izlenecek somut yol haritası. +Kaynak metinde bulunmayan hukuki sonuç çıkartma. Konsorsiyum ile iş ortaklığı farklı kavramlardır; birinin yasağını diğerine genişletme. +Bir katsayıyı yaklaşık maliyet yüzdesi olarak yorumlama; formül metinde yoksa hesaplama yapma. +Her kritik koşulun madde numarasını ve metindeki dayanağını belirt. Bölümler arasında bulundu/bulunamadı çelişkisi oluşturma. Özellikle şunları ara — ihaleyi kaybettiren ama gözden kaçan detaylar bunlardır: - Teklif zarfında eksikse doğrudan değerlendirme dışı bırakılma sebepleri diff --git a/server/document-content.mjs b/server/document-content.mjs new file mode 100644 index 0000000..dac69f3 --- /dev/null +++ b/server/document-content.mjs @@ -0,0 +1,5 @@ +/** Normalize document providers without losing their source label. */ +export function documentContent(result) { + if (typeof result === 'string') return result.trim(); + return String(result?.metin || result?.text || '').trim(); +} diff --git a/server/dt-store.mjs b/server/dt-store.mjs index 64b73cc..73abb87 100644 --- a/server/dt-store.mjs +++ b/server/dt-store.mjs @@ -95,8 +95,8 @@ function mapDtRow(row) { kurum, sehir: row.ili && !row.ili.startsWith('Plaka') ? titleCaseTr(row.ili) : '', bolge: '', - tarih: row.dt_tarihi || '', - saat: (det.tarihSaat || '').split(' ')[1] || '', + tarih: (row.dt_tarihi || '').split(' ')[0], + saat: (det.tarihSaat || row.dt_tarihi || '').match(/\d{2}:\d{2}/)?.[0] || '', alimTuru: 'Doğrudan Temin', usul: det.yasaMaddesi ? `Doğrudan Temin (4734 ${det.yasaMaddesi})` diff --git a/server/exchange-rates.mjs b/server/exchange-rates.mjs new file mode 100644 index 0000000..c9e7368 --- /dev/null +++ b/server/exchange-rates.mjs @@ -0,0 +1,27 @@ +export const RATE_SOURCE = 'https://www.tcmb.gov.tr/kurlar/today.xml'; +export function parseRates(xml) { + const bulletinDate = /]*\bTarih="([^"]+)"/.exec(xml)?.[1]; + if (!bulletinDate) throw new Error('TCMB bülten tarihi okunamadı.'); + const rates={}; + for (const code of ['USD','EUR']) { + const block=new RegExp(`]*CurrencyCode="${code}"[^>]*>([\\s\\S]*?)`).exec(xml)?.[1]; + const unit=Number(/([^<]+)<\/Unit>/.exec(block || '')?.[1]); + const selling=Number(/([^<]+)<\/ForexSelling>/.exec(block || '')?.[1]); + if (!(unit>0) || !(selling>0)) throw new Error(`${code} döviz satış kuru okunamadı.`); + rates[code]=selling/unit; + } + return {bulletinDate,rates,type:'Döviz satış',source:RATE_SOURCE}; +} +let cache; +export async function exchangeRates() { + if (cache && Date.now()-cache.at<15*60*1000) return {...cache.value,cached:true}; + try { + const response=await fetch(RATE_SOURCE,{signal:AbortSignal.timeout(10000)}); + if (!response.ok) throw new Error('TCMB kaynağına ulaşılamadı.'); + const value={...parseRates(await response.text()),fetchedAt:new Date().toISOString()}; + cache={at:Date.now(),value};return {...value,cached:false}; + } catch(error) { + if(cache) return {...cache.value,cached:true,refreshError:'Güncelleme alınamadı; gösterilen bülten tarihini kontrol edin.'}; + throw new Error('Kur alınamadı. Daha sonra yeniden deneyin.'); + } +} diff --git a/server/history-store.mjs b/server/history-store.mjs index c156802..1dc8e66 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -185,6 +185,7 @@ function rowToTender(r) { id: `hist-${r.ikn}-${r.kisim}`, kaynak: 'EKAP Sonuç İlanı', ikn: r.ikn, + kisim: r.kisim, baslik: r.ihale_adi || 'İhale', kurum: r.idare_adi || '—', sehir: titleCaseTr(r.il), diff --git a/server/index.js b/server/index.js index 56c5bc5..8993525 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,5 @@ +import { exchangeRates } from './exchange-rates.mjs'; +import { uniqueTenders, announcementFields } from './tender-quality.mjs'; /** * ihaleist API Proxy — ilan.gov.tr canlı ihale verisi * @@ -582,7 +584,7 @@ async function getDetail(ilanId) { if (cache.detail.has(ilanId)) return cache.detail.get(ilanId); const raw = await ilanDetail(ilanId); const text = htmlToText(raw.content || ''); - const detail = { aciklama: text.slice(0, 4000), adNo: raw.adNo || null }; + const detail = { ...announcementFields(text), adNo: raw.adNo || null }; cache.detail.set(ilanId, detail); if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value); return detail; @@ -661,22 +663,28 @@ async function tendersPayload(force = false) { console.warn('[kalem] özet uygulanamadı:', e.message); } + const combined = uniqueTenders([...dt.tenders, ...c.tenders, ...hist.tenders]); return { success: true, source: sources.join(' + '), fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'), totalCount: c.totalCount + dt.totalCount + hist.totalCount, ilanCount: c.totalCount, - todayCount: c.todayCount, + todayCount: bugunEklenenSayisi(combined), + loadedUniqueCount: combined.length, dtCount: dt.totalCount, historyCount: hist.totalCount, - tenders: [...dt.tenders, ...c.tenders, ...hist.tenders], + tenders: combined, }; } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); try { + if (url.pathname === '/api/exchange-rates') { + try { return json(res,200,{success:true,...await exchangeRates()}); } + catch(e) { return json(res,503,{success:false,error:e.message}); } + } // --- Google ile Giriş --- if (url.pathname === '/api/auth/google/config') { return json(res, 200, { clientId: process.env.GOOGLE_CLIENT_ID || null }); @@ -1507,7 +1515,8 @@ const server = http.createServer(async (req, res) => { } }); -server.listen(PORT, () => { +server.listen(PORT, process.env.NODE_ENV === 'test' ? '127.0.0.1' : undefined, () => { + if (process.env.NODE_ENV === 'test') return; // Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt. arkaPlandaIsit(); console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`); diff --git a/server/tender-quality.mjs b/server/tender-quality.mjs new file mode 100644 index 0000000..4826c05 --- /dev/null +++ b/server/tender-quality.mjs @@ -0,0 +1,37 @@ +const norm = v => String(v || '').trim().replace(/\s+/g,' ').toLocaleLowerCase('tr-TR'); +export function tenderIdentity(t) { + const ikn = String(t.ikn || '').replace(/\s/g,''); + // Awards from different lots must never be collapsed into one contract. + if (t.durum === 'Sonuçlandı') return `award:${ikn}:${t.kisim ?? t.id}:${norm(t.kazananFirma)}`; + if (/^\d{2}DT\d+$/i.test(ikn)) return `dt:${ikn.toUpperCase()}:${norm(t.kurum)}`; + // Corrections/cancellations remain separate events until their source linkage is verified. + if (/düzeltme|zeyilname|iptal/i.test(t.baslik || '')) return `event:${t.id}`; + return /^\d{4}\/\d+$/.test(ikn) ? `tender:${ikn}` : `source:${t.id}`; +} +export function uniqueTenders(records) { + const found = new Map(); + for (const t of records) { + const key = tenderIdentity(t), old = found.get(key); + if (!old) { found.set(key,{...t,sourceIds:[t.id]}); continue; } + const sourceIds = [...new Set([...old.sourceIds,t.id])]; + const merged = {...old}; + for (const [field,value] of Object.entries(t)) { + if (merged[field] == null || merged[field] === '' || (Array.isArray(merged[field]) && !merged[field].length)) merged[field] = value; + } + if ((t.aciklama || '').length > (old.aciklama || '').length) merged.aciklama = t.aciklama; + found.set(key,{...merged,id:old.id,sourceIds}); + } + return [...found.values()]; +} +export function announcementFields(text) { + const content = String(text || ''); + const result = {aciklama:content}; + const deadline = /Tarih\s*ve\s*Saati\s*:\s*(\d{2}\.\d{2}\.\d{4})\s*[-–]?\s*(\d{2}:\d{2})/i.exec(content); + if (deadline) { result.tarih = deadline[1]; result.saat = deadline[2]; } + const place = /3\.3\.?\s*[^:]{0,120}:\s*([\s\S]*?)(?=3\.4\.?)/i.exec(content); + const duration = /3\.4\.?\s*[^:]{0,100}:\s*([\s\S]*?)(?=3\.5\.?|4\s*[-–])/i.exec(content); + if (place) result.teslimYeri = place[1].trim(); + if (duration) result.teslimatSuresi = duration[1].trim(); + if (/hizmet alımı\s+4734/i.test(content)) result.alimTuru = 'Kiralama ve Hizmet Alımı'; + return result; +} diff --git a/src/components/AiSpecAnalyzerModal.jsx b/src/components/AiSpecAnalyzerModal.jsx index 17f33e8..e9796da 100644 --- a/src/components/AiSpecAnalyzerModal.jsx +++ b/src/components/AiSpecAnalyzerModal.jsx @@ -308,7 +308,7 @@ export const AiSpecAnalyzerModal = () => {
    {(results.qualificationCriteria || []).length === 0 && ( -
  • Belgede yeterlik kriteri tespit edilemedi.
  • +
  • Bu bölüm için yapılandırılmış yeterlik listesi çıkarılamadı. Derin analizdeki belge ve şartları kontrol edin.
  • )} {(results.qualificationCriteria || []).map((item, idx) => (
  • @@ -338,7 +338,7 @@ export const AiSpecAnalyzerModal = () => {
    {(results.risksAndPenalties || []).length === 0 && ( -

    Belgede cezai hüküm/risk tespit edilemedi.

    +

    Bu bölümde yapılandırılmış ceza/risk listesi yok. Bu, belgede risk bulunmadığı anlamına gelmez.

    )} {(results.risksAndPenalties || []).map((risk, idx) => (
    diff --git a/src/components/BidPackageBuilder.jsx b/src/components/BidPackageBuilder.jsx index 45a9652..f0f880d 100644 --- a/src/components/BidPackageBuilder.jsx +++ b/src/components/BidPackageBuilder.jsx @@ -1,4 +1,5 @@ import React, { useState, useMemo } from 'react'; +import { bidRowTotal, validateBidRows, roundMoney } from '../services/bidValidation'; import { useApp } from '../context/AppContext'; import { FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks, @@ -34,35 +35,38 @@ const FIRMA_ANAHTAR = 'ihaleist_firma_bilgi'; const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, ''])); export const BidPackageBuilder = ({ results, tender }) => { - const { userProfile } = useApp(); + const { userProfile, mutateTender, savingCount, authUser } = useApp(); + const storedDraft = tender?.bidDraft || {}; + const [dirty, setDirty] = useState(false); const [acik, setAcik] = useState(true); const [hata, setHata] = useState(null); const [firma, setFirma] = useState(() => { let kayitli = {}; - try { kayitli = JSON.parse(localStorage.getItem(FIRMA_ANAHTAR) || '{}'); } catch { /* yoksay */ } + try { kayitli = JSON.parse(localStorage.getItem(FIRMA_ANAHTAR + ':' + authUser?.email) || '{}'); } catch { /* yoksay */ } return { ...bosFirma(), firmaUnvan: userProfile?.company || '', ...kayitli, + ...(storedDraft.firma || {}), }; }); - const [teklifTuru, setTeklifTuru] = useState('birim'); + const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || 'birim'); const kaynakGecerlilik = /(?:geçerlilik[^.]{0,100}?|ihale tarihinden itibaren\s*)(\d+)\s*(?:takvim\s*)?gün/i.exec(tender?.aciklama || ''); - const [gecerlilikGun, setGecerlilikGun] = useState(kaynakGecerlilik?.[1] || ''); - const [kisim, setKisim] = useState(''); - const [goturuBedel, setGoturuBedel] = useState(''); + const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik?.[1] || ''); + const [kisim, setKisim] = useState(storedDraft.kisim || ''); + const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || ''); const kalemler = tender?.items?.length ? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]})) : (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []); - const [fiyatlar, setFiyatlar] = useState({}); + const [fiyatlar, setFiyatlar] = useState(storedDraft.fiyatlar || {}); const alanYaz = (k, v) => { const yeni = { ...firma, [k]: v }; setFirma(yeni); - try { localStorage.setItem(FIRMA_ANAHTAR, JSON.stringify(yeni)); } catch { /* kota */ } + try { localStorage.setItem(FIRMA_ANAHTAR + ':' + authUser?.email, JSON.stringify(yeni)); } catch { /* kota */ } }; const satirlar = useMemo( @@ -70,21 +74,17 @@ export const BidPackageBuilder = ({ results, tender }) => { [kalemler, fiyatlar], ); - const kalemToplami = useMemo( - () => satirlar.reduce((t, r) => { - const { miktar } = miktarAyristir(r.quantity); - return t + (miktar ?? 0) * (Number(r.birimFiyat) || 0); - }, 0), - [satirlar], - ); + const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]); + const saveDraft = async () => { + const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar}}); + if (saved) { setDirty(false); setHata(null); } + else setHata('Taslak kaydedilemedi. Tekrar deneyin.'); + }; const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami; const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim()); - const kalemEksik = teklifTuru === 'birim' && (!satirlar.length || satirlar.some(r => { - const {miktar,birim} = miktarAyristir(r.quantity); - return !r.name || !(miktar > 0) || !birim || !(Number(r.birimFiyat) > 0); - })); + const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0; const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || ''); const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1])); const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik; @@ -135,7 +135,12 @@ export const BidPackageBuilder = ({ results, tender }) => { {acik && ( -
    +
    setDirty(true)}> +
    + + {dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'} +
    {/* Sizden beklenen bilgiler */} {eksikler.length > 0 && ( @@ -238,7 +243,7 @@ export const BidPackageBuilder = ({ results, tender }) => { {satirlar.map((r, i) => { const { miktar, birim } = miktarAyristir(r.quantity); - const tutar = (miktar ?? 0) * (Number(r.birimFiyat) || 0); + const tutar = bidRowTotal(r); return ( {r.name || '—'} diff --git a/src/components/CompanyScannerModal.jsx b/src/components/CompanyScannerModal.jsx index 2ebfdc5..5dc5f9a 100644 --- a/src/components/CompanyScannerModal.jsx +++ b/src/components/CompanyScannerModal.jsx @@ -74,7 +74,7 @@ export const CompanyScannerModal = () => {

    Firma Web Sitesi Tarama & Detaylı Ürün Analiz Motoru

    -

    Sayfalarınızdaki gerçek ürün ve faaliyet kollarını tarar, alakasız harici sektörleri kesinlikle engeller.

    +

    Ürün ve faaliyet alanı önerilerini sitenizden çıkarır; profilinize eklemeden önce doğrulayın.

    - Sıfır Alakasız Sektör Garantisi + Açıklanabilir ürün ve sektör eşleşmesi
    diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 32c94a5..dc3b058 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useApp } from '../context/AppContext'; import { BidPackageBuilder } from './BidPackageBuilder'; import { TenderDocuments } from './TenderDocuments'; @@ -18,8 +18,20 @@ export const TenderDetailModal = () => { // Kur Farkı Calculator State const [bidAmount, setBidAmount] = useState(15000); - const usdRate = 36.45; - const eurRate = 39.80; + const [fx, setFx] = useState(null); + const [fxError, setFxError] = useState(null); + useEffect(() => { + if (!activeTenderDetail) return; + let cancelled = false; + fetch('/api/exchange-rates').then(r => r.json()).then(d => { + if (cancelled) return; + if (!d.success) throw new Error(d.error); + setFx(d); setFxError(d.refreshError || null); + }).catch(e => { if (!cancelled) setFxError(e.message); }); + return () => { cancelled = true; }; + }, [activeTenderDetail?.id]); + const usdRate = fx?.rates?.USD; + const eurRate = fx?.rates?.EUR; // Stamp Drag Position State const [stampPos, setStampPos] = useState({ x: 220, y: 180 }); @@ -320,17 +332,19 @@ export const TenderDetailModal = () => { Kur Farkı Hesaplayıcı - TCMB Canlı + TCMB döviz satış + {fx &&

    TCMB bülteni: {fx.bulletinDate} · Alındı: {new Date(fx.fetchedAt).toLocaleString('tr-TR')}

    } + {fxError &&

    {fxError}

    }
    USD / TRY - ₺{usdRate} + {usdRate ? `₺${usdRate}` : '—'}
    EUR / TRY - ₺{eurRate} + {eurRate ? `₺${eurRate}` : '—'}
    @@ -347,11 +361,11 @@ export const TenderDetailModal = () => {
    Dolar Karşılığı: - ${(bidAmount / usdRate).toFixed(2)} + {usdRate ? '$' + (bidAmount / usdRate).toFixed(2) : 'Kur bekleniyor'}
    Euro Karşılığı: - €{(bidAmount / eurRate).toFixed(2)} + {eurRate ? '€' + (bidAmount / eurRate).toFixed(2) : 'Kur bekleniyor'}
    diff --git a/src/components/UrunAramaModal.jsx b/src/components/UrunAramaModal.jsx index 73bab3e..d2d1669 100644 --- a/src/components/UrunAramaModal.jsx +++ b/src/components/UrunAramaModal.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useApp } from '../context/AppContext'; import { X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info, @@ -28,6 +28,14 @@ export const UrunAramaModal = () => { isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders, urunAramaBaslangic, setUrunAramaBaslangic, } = useApp(); + const requestVersion = useRef(0); + const [submitted, setSubmitted] = useState(null); + useEffect(() => { + if (!isUrunAramaOpen) { + requestVersion.current++; + setDurum((d) => ({ ...d, yukleniyor: false })); + } + }, [isUrunAramaOpen]); const [q, setQ] = useState(''); const [teknik, setTeknik] = useState(false); const [sadeceAcik, setSadeceAcik] = useState(true); @@ -60,30 +68,40 @@ export const UrunAramaModal = () => { if (!isUrunAramaOpen) return null; - const araOkas = async (kodlar, sayfa = 0) => { - if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; } + const sorgula = async (query, sayfa = 0) => { + const request = ++requestVersion.current; setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa }); try { - const d = await urunAra('', { skip: sayfa * SAYFA, take: SAYFA, okas: kodlar }); + const d = await urunAra(query.mod === 'metin' ? query.label : '', { + skip: sayfa * SAYFA, take: SAYFA, + teknik: query.teknik, okas: query.kodlar || [], + }); + if (request !== requestVersion.current) return; + setSubmitted(query); setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa }); } catch (e) { + if (request !== requestVersion.current) return; setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa }); } }; - const ara = async (sayfa = 0) => { - if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod), sayfa); + const araOkas = (kodlar, sayfa = 0) => { + if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; } + return sorgula({ mod: 'okas', label: kodlar.join(', '), kodlar: [...kodlar], teknik: false }, sayfa); + }; + + const ara = () => { + if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod)); 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 }); - } + return sorgula({ mod: 'metin', label: terim, teknik }); }; + const sorguDegisti = submitted && (submitted.mod !== mod || + (mod === 'metin' + ? submitted.label !== q.trim() || submitted.teknik !== teknik + : submitted.label !== okasSecili.map((k) => k.kod).join(', '))); + const okasEkle = (k) => { if (!okasSecili.some((x) => x.kod === k.kod)) setOkasSecili((o) => [...o, { kod: k.kod, ad: k.ad }]); setOkasQ(''); setOkasAdaylar([]); @@ -112,7 +130,7 @@ export const UrunAramaModal = () => {

    - @@ -220,13 +238,17 @@ export const UrunAramaModal = () => { )} + {sonuc && sorguDegisti && ( +

    Arama alanları değişti. Aşağıdaki sonuçlar ve sayfalar önceki aramaya aittir. Yeni seçimler için Ara düğmesine basın.

    + )} + {sonuc && ( <>

    - {mod === 'okas' - ? <>OKAS {okasSecili.map((k) => k.kod).join(', ')} kodlu{' '} - : <>Teklif cetvelinde "{q.trim()}" geçen{' '}} + {submitted?.mod === 'okas' + ? <>OKAS {submitted?.label} kodlu{' '} + : <>Teklif cetvelinde "{submitted?.label}" geçen{' '}} {sonuc.totalCount.toLocaleString('tr-TR')} ihale bulundu

    {gizlenen > 0 && ( @@ -281,7 +303,7 @@ export const UrunAramaModal = () => {
    `);return;} + if(req.url?.startsWith('/api/tenders')){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({success:true,tenders,totalCount:5,todayCount:0,historyCount:0,source:'Yerel sentetik QA',fetchedAt:new Date().toISOString()}));return;} + next(); +});}}]}); +await ui.listen();console.log('QA_URL='+ui.resolvedUrls.local[0]+'qa-signin'); +async function stop(){await ui.close();api.kill();rmSync(dir,{recursive:true,force:true});process.exit();} +process.on('SIGINT',stop);process.on('SIGTERM',stop); diff --git a/tests/document-content.test.mjs b/tests/document-content.test.mjs new file mode 100644 index 0000000..22297d8 --- /dev/null +++ b/tests/document-content.test.mjs @@ -0,0 +1,7 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {documentContent} from '../server/document-content.mjs'; +test('EKAP metin, upload text and plain string share the same reader',()=>{ + for(const input of [{metin:'Şartname'},{text:'Şartname'},'Şartname']) assert.equal(documentContent(input),'Şartname'); + assert.equal(documentContent(null),'');assert.equal(documentContent({metin:'',text:'İlan'}),'İlan'); +}); diff --git a/tests/exchange-rates.test.mjs b/tests/exchange-rates.test.mjs new file mode 100644 index 0000000..45376ac --- /dev/null +++ b/tests/exchange-rates.test.mjs @@ -0,0 +1,9 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {parseRates} from '../server/exchange-rates.mjs'; +test('rates carry bulletin date and use unit-adjusted selling price',()=>{ + const xml='148.31951005500'; + const d=parseRates(xml);assert.equal(d.rates.USD,48.3195);assert.equal(d.rates.EUR,55);assert.equal(d.bulletinDate,'04.09.2026'); + assert.throws(()=>parseRates('error')); + assert.throws(()=>parseRates(xml.replace('100','0'))); +}); diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs new file mode 100644 index 0000000..4f71ede --- /dev/null +++ b/tests/http-state.test.mjs @@ -0,0 +1,40 @@ +import {test,before,after} from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {randomBytes} from 'node:crypto'; +import {createServer} from 'node:net'; +const dir=mkdtempSync(join(tmpdir(),'ihaleist-http-')); +process.env.AUTH_SECRET=randomBytes(32).toString('hex'); +const {signToken}=await import('../server/auth.mjs'); +const token=signToken({email:'qa@example.test',name:'QA'}); +const other=signToken({email:'other@example.test',name:'Other'}); +let child,base; +const request=async(path,method='GET',body,auth=token)=>{ + const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json',...(auth?{Authorization:'Bearer '+auth}:{})},body:body?JSON.stringify(body):undefined}); + return {status:r.status,data:await r.json()}; +}; +before(async()=>{ + const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r)); + base='http://127.0.0.1:'+port; + child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,UYUM_GOMME:'0'},stdio:'ignore'}); + for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}} + throw new Error('QA API did not start'); +}); +after(async()=>{if(child){child.kill();await new Promise(r=>child.once('exit',r));}rmSync(dir,{recursive:true,force:true});}); +test('HTTP preparation requires auth, persists draft and rejects stale revisions',async()=>{ + assert.equal((await request('/api/me/tender-state','GET',null,null)).status,401); + const draft={firma:{firmaUnvan:'QA Laboratuvar'},fiyatlar:{0:'12.50'},gecerlilikGun:'120'}; + const data={key:'2026/1582991',patch:{takipte:true,gorev:{kontrolListesi:[]},bidDraft:draft},snapshot:{id:'ilan-qa',ikn:'2026/1582991'},version:0}; + assert.equal((await request('/api/me/tender-state','PUT',data)).status,200); + const read=await request('/api/me/tender-state');assert.deepEqual(read.data.records[data.key].data.bidDraft,draft); + assert.equal((await request('/api/me/tender-state','PUT',data)).status,409); + assert.deepEqual((await request('/api/me/tender-state','GET',null,other)).data.records,{}); +}); +test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{ + const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]); + const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]); + const data=await request('/api/me/data');assert.equal(data.status,200);assert.deepEqual(data.data.veri,{}); +}); diff --git a/tests/tender-quality.test.mjs b/tests/tender-quality.test.mjs new file mode 100644 index 0000000..882df7b --- /dev/null +++ b/tests/tender-quality.test.mjs @@ -0,0 +1,18 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {uniqueTenders,announcementFields} from '../server/tender-quality.mjs'; +test('duplicate DT records merge without merging another institution or losing source identities',()=>{ + const t={id:'dt-1',ikn:'26DT1693370',kurum:'Hastane'}; + const out=uniqueTenders([t,{...t,id:'dt-2',aciklama:'Tam metin'},{...t,id:'dt-3',kurum:'Başka hastane'}]); + assert.equal(out.length,2);assert.deepEqual(out[0].sourceIds,['dt-1','dt-2']);assert.equal(out[0].aciklama,'Tam metin'); +}); +test('distinct contract lots and corrections remain separate',()=>{ + const t={ikn:'2026/123',durum:'Sonuçlandı',kisim:'1',id:'a',kazananFirma:'Firma'}; + assert.equal(uniqueTenders([t,t,{...t,id:'b',kisim:'2'}]).length,2); + assert.equal(uniqueTenders([{ikn:'2026/123',id:'1'},{ikn:'2026/123',id:'2',baslik:'Düzeltme ilanı'}]).length,2); +}); +test('announcement preserves full text and extracts source-backed fields',()=>{ + const text='x'.repeat(4500)+' Tarih ve Saati : 10.09.2026 - 10:00 3.3. Teslim edileceği yer : Patoloji laboratuvarı 3.4. Süresi/teslim tarihi : 30 gün 3.5. İşe başlama tarihi : sözleşme'; + const d=announcementFields(text);assert.equal(d.aciklama,text);assert.equal(d.saat,'10:00');assert.equal(d.teslimYeri,'Patoloji laboratuvarı');assert.equal(d.teslimatSuresi,'30 gün'); + assert.deepEqual(announcementFields('Eksik ilan'),{aciklama:'Eksik ilan'}); +});