fix: preserve bid drafts and align tender source data

This commit is contained in:
2026-09-06 08:40:10 +03:00
parent c91631d99f
commit 456728b4bd
26 changed files with 510 additions and 81 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@@ -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
+16 -3
View File
@@ -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. - 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. - 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. - İ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. - 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. - 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. - Ş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 ## 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. `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. `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. - 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. - 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.
+8 -6
View File
@@ -1,3 +1,4 @@
import { documentContent } from './document-content.mjs';
// Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut // Teklif asistanı — İhaleAB'den taşınan üç AI özelliği, ihaleist'in mevcut
// analyzeSpec zinciri (yedekli sağlayıcılar) üzerinde: // analyzeSpec zinciri (yedekli sağlayıcılar) üzerinde:
// 1) belgeyeSor: ihale dokümanı/ilan metninde soru → atıflı yanıt // 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. */ /** İ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 (verilen && String(verilen).trim().length > 200) return { text: String(verilen), kaynak: 'yüklenen' };
if (ikn && (await browserAvailable().catch(() => false))) { 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) { 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 }; return { text: '', kaynak: null };
} }
export async function belgeyeSor({ ikn, soru, text, tender, chain }) { export async function belgeyeSor({ ikn, soru, text, tender, chain }) {
const q = String(soru ?? '').trim().slice(0, 500); const q = String(soru ?? '').trim().slice(0, 500);
if (q.length < 3) throw new Error('Soru en az 3 karakter olmalı.'); 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.'); 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 parcalar = parcala(metin);
const kaynaklar = getir(parcalar, q, 8); 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 }) { export async function teklifDenetle({ ikn, taslak, gereklilikler, tender, chain, text }) {
const t = String(taslak ?? '').trim().slice(0, 60000); const t = String(taslak ?? '').trim().slice(0, 60000);
if (t.length < 200) throw new Error('Taslak en az 200 karakter olmalı.'); 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); 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.'); 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.`; 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 }) { export async function teklifTaslak({ ikn, tur, tender, firma, belgeler, fiyat, gereklilikler, chain, text }) {
const istenen = BOLUMLER[tur] || BOLUMLER.tam; 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 liste = gereklilikMetni(tender, gereklilikler, metin);
const baglam = [ 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 : ''}`, `İ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 : ''}`,
+3
View File
@@ -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. 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. 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ı. İ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: Ö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 - Teklif zarfında eksikse doğrudan değerlendirme dışı bırakılma sebepleri
+5
View File
@@ -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();
}
+2 -2
View File
@@ -95,8 +95,8 @@ function mapDtRow(row) {
kurum, kurum,
sehir: row.ili && !row.ili.startsWith('Plaka') ? titleCaseTr(row.ili) : '', sehir: row.ili && !row.ili.startsWith('Plaka') ? titleCaseTr(row.ili) : '',
bolge: '', bolge: '',
tarih: row.dt_tarihi || '', tarih: (row.dt_tarihi || '').split(' ')[0],
saat: (det.tarihSaat || '').split(' ')[1] || '', saat: (det.tarihSaat || row.dt_tarihi || '').match(/\d{2}:\d{2}/)?.[0] || '',
alimTuru: 'Doğrudan Temin', alimTuru: 'Doğrudan Temin',
usul: det.yasaMaddesi usul: det.yasaMaddesi
? `Doğrudan Temin (4734 ${det.yasaMaddesi})` ? `Doğrudan Temin (4734 ${det.yasaMaddesi})`
+27
View File
@@ -0,0 +1,27 @@
export const RATE_SOURCE = 'https://www.tcmb.gov.tr/kurlar/today.xml';
export function parseRates(xml) {
const bulletinDate = /<Tarih_Date\b[^>]*\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(`<Currency\\b[^>]*CurrencyCode="${code}"[^>]*>([\\s\\S]*?)</Currency>`).exec(xml)?.[1];
const unit=Number(/<Unit>([^<]+)<\/Unit>/.exec(block || '')?.[1]);
const selling=Number(/<ForexSelling>([^<]+)<\/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.');
}
}
+1
View File
@@ -185,6 +185,7 @@ function rowToTender(r) {
id: `hist-${r.ikn}-${r.kisim}`, id: `hist-${r.ikn}-${r.kisim}`,
kaynak: 'EKAP Sonuç İlanı', kaynak: 'EKAP Sonuç İlanı',
ikn: r.ikn, ikn: r.ikn,
kisim: r.kisim,
baslik: r.ihale_adi || 'İhale', baslik: r.ihale_adi || 'İhale',
kurum: r.idare_adi || '—', kurum: r.idare_adi || '—',
sehir: titleCaseTr(r.il), sehir: titleCaseTr(r.il),
+13 -4
View File
@@ -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 * 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); if (cache.detail.has(ilanId)) return cache.detail.get(ilanId);
const raw = await ilanDetail(ilanId); const raw = await ilanDetail(ilanId);
const text = htmlToText(raw.content || ''); 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); cache.detail.set(ilanId, detail);
if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value); if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value);
return detail; return detail;
@@ -661,22 +663,28 @@ async function tendersPayload(force = false) {
console.warn('[kalem] özet uygulanamadı:', e.message); console.warn('[kalem] özet uygulanamadı:', e.message);
} }
const combined = uniqueTenders([...dt.tenders, ...c.tenders, ...hist.tenders]);
return { return {
success: true, success: true,
source: sources.join(' + '), source: sources.join(' + '),
fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'), fetchedAt: new Date(c.fetchedAt).toLocaleString('tr-TR'),
totalCount: c.totalCount + dt.totalCount + hist.totalCount, totalCount: c.totalCount + dt.totalCount + hist.totalCount,
ilanCount: c.totalCount, ilanCount: c.totalCount,
todayCount: c.todayCount, todayCount: bugunEklenenSayisi(combined),
loadedUniqueCount: combined.length,
dtCount: dt.totalCount, dtCount: dt.totalCount,
historyCount: hist.totalCount, historyCount: hist.totalCount,
tenders: [...dt.tenders, ...c.tenders, ...hist.tenders], tenders: combined,
}; };
} }
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`); const url = new URL(req.url, `http://localhost:${PORT}`);
try { 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ş --- // --- Google ile Giriş ---
if (url.pathname === '/api/auth/google/config') { if (url.pathname === '/api/auth/google/config') {
return json(res, 200, { clientId: process.env.GOOGLE_CLIENT_ID || null }); 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. // Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt.
arkaPlandaIsit(); arkaPlandaIsit();
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`); console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
+37
View File
@@ -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;
}
+2 -2
View File
@@ -308,7 +308,7 @@ export const AiSpecAnalyzerModal = () => {
</h4> </h4>
<ul className="space-y-2 text-xs text-slate-700"> <ul className="space-y-2 text-xs text-slate-700">
{(results.qualificationCriteria || []).length === 0 && ( {(results.qualificationCriteria || []).length === 0 && (
<li className="text-slate-400">Belgede yeterlik kriteri tespit edilemedi.</li> <li className="text-slate-400">Bu bölüm için yapılandırılmış yeterlik listesi çıkarılamadı. Derin analizdeki belge ve şartları kontrol edin.</li>
)} )}
{(results.qualificationCriteria || []).map((item, idx) => ( {(results.qualificationCriteria || []).map((item, idx) => (
<li key={idx} className="flex items-start gap-2 bg-slate-50 p-2.5 rounded-lg border border-slate-200 font-medium"> <li key={idx} className="flex items-start gap-2 bg-slate-50 p-2.5 rounded-lg border border-slate-200 font-medium">
@@ -338,7 +338,7 @@ export const AiSpecAnalyzerModal = () => {
</h4> </h4>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
{(results.risksAndPenalties || []).length === 0 && ( {(results.risksAndPenalties || []).length === 0 && (
<p className="text-slate-400">Belgede cezai hüküm/risk tespit edilemedi.</p> <p className="text-slate-400">Bu bölümde yapılandırılmış ceza/risk listesi yok. Bu, belgede risk bulunmadığı anlamına gelmez.</p>
)} )}
{(results.risksAndPenalties || []).map((risk, idx) => ( {(results.risksAndPenalties || []).map((risk, idx) => (
<div key={idx} className="p-3 bg-slate-50 rounded-lg border border-slate-200 space-y-1"> <div key={idx} className="p-3 bg-slate-50 rounded-lg border border-slate-200 space-y-1">
+26 -21
View File
@@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { bidRowTotal, validateBidRows, roundMoney } from '../services/bidValidation';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { import {
FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks, 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, ''])); const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
export const BidPackageBuilder = ({ results, tender }) => { 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 [acik, setAcik] = useState(true);
const [hata, setHata] = useState(null); const [hata, setHata] = useState(null);
const [firma, setFirma] = useState(() => { const [firma, setFirma] = useState(() => {
let kayitli = {}; 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 { return {
...bosFirma(), ...bosFirma(),
firmaUnvan: userProfile?.company || '', firmaUnvan: userProfile?.company || '',
...kayitli, ...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 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 [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik?.[1] || '');
const [kisim, setKisim] = useState(''); const [kisim, setKisim] = useState(storedDraft.kisim || '');
const [goturuBedel, setGoturuBedel] = useState(''); const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || '');
const kalemler = tender?.items?.length const kalemler = tender?.items?.length
? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]})) ? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]}))
: (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []); : (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []);
const [fiyatlar, setFiyatlar] = useState({}); const [fiyatlar, setFiyatlar] = useState(storedDraft.fiyatlar || {});
const alanYaz = (k, v) => { const alanYaz = (k, v) => {
const yeni = { ...firma, [k]: v }; const yeni = { ...firma, [k]: v };
setFirma(yeni); 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( const satirlar = useMemo(
@@ -70,21 +74,17 @@ export const BidPackageBuilder = ({ results, tender }) => {
[kalemler, fiyatlar], [kalemler, fiyatlar],
); );
const kalemToplami = useMemo( const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]);
() => satirlar.reduce((t, r) => { const saveDraft = async () => {
const { miktar } = miktarAyristir(r.quantity); const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar}});
return t + (miktar ?? 0) * (Number(r.birimFiyat) || 0); if (saved) { setDirty(false); setHata(null); }
}, 0), else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
[satirlar], };
);
const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami; const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami;
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim()); const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
const kalemEksik = teklifTuru === 'birim' && (!satirlar.length || satirlar.some(r => { const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
const {miktar,birim} = miktarAyristir(r.quantity);
return !r.name || !(miktar > 0) || !birim || !(Number(r.birimFiyat) > 0);
}));
const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || ''); const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || '');
const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1])); const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1]));
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik; const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik;
@@ -135,7 +135,12 @@ export const BidPackageBuilder = ({ results, tender }) => {
</button> </button>
{acik && ( {acik && (
<div className="border-t border-emerald-200 p-5 space-y-5"> <div className="border-t border-emerald-200 p-5 space-y-5" onChange={() => setDirty(true)}>
<div className="flex items-center gap-3">
<button type="button" onClick={saveDraft} disabled={savingCount > 0}
className="px-4 py-2 rounded-lg bg-blue-700 text-white text-xs disabled:opacity-50">Hazırlık taslağını kaydet</button>
<span role="status" className="text-xs">{dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'}</span>
</div>
{/* Sizden beklenen bilgiler */} {/* Sizden beklenen bilgiler */}
{eksikler.length > 0 && ( {eksikler.length > 0 && (
@@ -238,7 +243,7 @@ export const BidPackageBuilder = ({ results, tender }) => {
<tbody> <tbody>
{satirlar.map((r, i) => { {satirlar.map((r, i) => {
const { miktar, birim } = miktarAyristir(r.quantity); const { miktar, birim } = miktarAyristir(r.quantity);
const tutar = (miktar ?? 0) * (Number(r.birimFiyat) || 0); const tutar = bidRowTotal(r);
return ( return (
<tr key={i} className="border-b border-slate-100"> <tr key={i} className="border-b border-slate-100">
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td> <td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td>
+2 -2
View File
@@ -74,7 +74,7 @@ export const CompanyScannerModal = () => {
<h2 className="font-extrabold text-base flex items-center gap-2"> <h2 className="font-extrabold text-base flex items-center gap-2">
Firma Web Sitesi Tarama & Detaylı Ürün Analiz Motoru Firma Web Sitesi Tarama & Detaylı Ürün Analiz Motoru
</h2> </h2>
<p className="text-xs text-blue-200">Sayfalarınızdaki gerçek ürün ve faaliyet kollarını tarar, alakasız harici sektörleri kesinlikle engeller.</p> <p className="text-xs text-blue-200">Ürün ve faaliyet alanı önerilerini sitenizden çıkarır; profilinize eklemeden önce doğrulayın.</p>
</div> </div>
</div> </div>
<button <button
@@ -95,7 +95,7 @@ export const CompanyScannerModal = () => {
<span>Sektör Filtresi Aktif: Sadece web sitenizdeki kesinleşmiş faaliyet alanları ve detaylı ürün kataloğu işlenir.</span> <span>Sektör Filtresi Aktif: Sadece web sitenizdeki kesinleşmiş faaliyet alanları ve detaylı ürün kataloğu işlenir.</span>
</div> </div>
<span className="bg-blue-600 text-white text-[10px] font-extrabold px-2 py-0.5 rounded-full"> <span className="bg-blue-600 text-white text-[10px] font-extrabold px-2 py-0.5 rounded-full">
Sıfır Alakasız Sektör Garantisi Açıklanabilir ürün ve sektör eşleşmesi
</span> </span>
</div> </div>
+22 -8
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { BidPackageBuilder } from './BidPackageBuilder'; import { BidPackageBuilder } from './BidPackageBuilder';
import { TenderDocuments } from './TenderDocuments'; import { TenderDocuments } from './TenderDocuments';
@@ -18,8 +18,20 @@ export const TenderDetailModal = () => {
// Kur Farkı Calculator State // Kur Farkı Calculator State
const [bidAmount, setBidAmount] = useState(15000); const [bidAmount, setBidAmount] = useState(15000);
const usdRate = 36.45; const [fx, setFx] = useState(null);
const eurRate = 39.80; 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 // Stamp Drag Position State
const [stampPos, setStampPos] = useState({ x: 220, y: 180 }); const [stampPos, setStampPos] = useState({ x: 220, y: 180 });
@@ -320,17 +332,19 @@ export const TenderDetailModal = () => {
<span className="font-bold text-xs uppercase tracking-wider flex items-center gap-1.5"> <span className="font-bold text-xs uppercase tracking-wider flex items-center gap-1.5">
<Calculator className="w-4 h-4 text-blue-400" /> Kur Farkı Hesaplayıcı <Calculator className="w-4 h-4 text-blue-400" /> Kur Farkı Hesaplayıcı
</span> </span>
<span className="text-[10px] bg-blue-500/30 text-blue-300 px-2 py-0.5 rounded font-bold">TCMB Canlı</span> <span className="text-[10px] bg-blue-500/30 text-blue-300 px-2 py-0.5 rounded font-bold">TCMB döviz satış</span>
</div> </div>
{fx && <p className="text-[11px]"><a href={fx.source} target="_blank" rel="noreferrer" className="underline">TCMB bülteni: {fx.bulletinDate}</a> · Alındı: {new Date(fx.fetchedAt).toLocaleString('tr-TR')}</p>}
{fxError && <p role="alert" className="text-xs text-amber-200">{fxError}</p>}
<div className="grid grid-cols-2 gap-2 text-xs font-semibold"> <div className="grid grid-cols-2 gap-2 text-xs font-semibold">
<div className="bg-white/10 p-2.5 rounded-lg border border-white/10"> <div className="bg-white/10 p-2.5 rounded-lg border border-white/10">
<span className="text-slate-400 block text-[10px]">USD / TRY</span> <span className="text-slate-400 block text-[10px]">USD / TRY</span>
<span className="text-base font-black text-emerald-400">₺{usdRate}</span> <span className="text-base font-black text-emerald-400">{usdRate ? `₺${usdRate}` : '—'}</span>
</div> </div>
<div className="bg-white/10 p-2.5 rounded-lg border border-white/10"> <div className="bg-white/10 p-2.5 rounded-lg border border-white/10">
<span className="text-slate-400 block text-[10px]">EUR / TRY</span> <span className="text-slate-400 block text-[10px]">EUR / TRY</span>
<span className="text-base font-black text-blue-400">₺{eurRate}</span> <span className="text-base font-black text-blue-400">{eurRate ? `₺${eurRate}` : '—'}</span>
</div> </div>
</div> </div>
@@ -347,11 +361,11 @@ export const TenderDetailModal = () => {
<div className="bg-blue-950/80 p-3 rounded-lg border border-blue-800 text-xs space-y-1"> <div className="bg-blue-950/80 p-3 rounded-lg border border-blue-800 text-xs space-y-1">
<div className="flex justify-between text-slate-300"> <div className="flex justify-between text-slate-300">
<span>Dolar Karşılığı:</span> <span>Dolar Karşılığı:</span>
<span className="font-bold text-white">${(bidAmount / usdRate).toFixed(2)}</span> <span className="font-bold text-white">{usdRate ? '$' + (bidAmount / usdRate).toFixed(2) : 'Kur bekleniyor'}</span>
</div> </div>
<div className="flex justify-between text-slate-300"> <div className="flex justify-between text-slate-300">
<span>Euro Karşılığı:</span> <span>Euro Karşılığı:</span>
<span className="font-bold text-white">€{(bidAmount / eurRate).toFixed(2)}</span> <span className="font-bold text-white">{eurRate ? '€' + (bidAmount / eurRate).toFixed(2) : 'Kur bekleniyor'}</span>
</div> </div>
</div> </div>
</div> </div>
+41 -19
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
import { import {
X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info, X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info,
@@ -28,6 +28,14 @@ export const UrunAramaModal = () => {
isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders, isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders,
urunAramaBaslangic, setUrunAramaBaslangic, urunAramaBaslangic, setUrunAramaBaslangic,
} = useApp(); } = 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 [q, setQ] = useState('');
const [teknik, setTeknik] = useState(false); const [teknik, setTeknik] = useState(false);
const [sadeceAcik, setSadeceAcik] = useState(true); const [sadeceAcik, setSadeceAcik] = useState(true);
@@ -60,30 +68,40 @@ export const UrunAramaModal = () => {
if (!isUrunAramaOpen) return null; if (!isUrunAramaOpen) return null;
const araOkas = async (kodlar, sayfa = 0) => { const sorgula = async (query, sayfa = 0) => {
if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; } const request = ++requestVersion.current;
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa }); setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa });
try { 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 }); setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa });
} catch (e) { } catch (e) {
if (request !== requestVersion.current) return;
setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa }); setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa });
} }
}; };
const ara = async (sayfa = 0) => { const araOkas = (kodlar, sayfa = 0) => {
if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod), sayfa); 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(); const terim = q.trim();
if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; } if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; }
setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa }); return sorgula({ mod: 'metin', label: terim, teknik });
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 });
}
}; };
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) => { const okasEkle = (k) => {
if (!okasSecili.some((x) => x.kod === k.kod)) setOkasSecili((o) => [...o, { kod: k.kod, ad: k.ad }]); if (!okasSecili.some((x) => x.kod === k.kod)) setOkasSecili((o) => [...o, { kod: k.kod, ad: k.ad }]);
setOkasQ(''); setOkasAdaylar([]); setOkasQ(''); setOkasAdaylar([]);
@@ -112,7 +130,7 @@ export const UrunAramaModal = () => {
</p> </p>
</div> </div>
</div> </div>
<button onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg"> <button aria-label="Ürün aramasını kapat" onClick={() => setIsUrunAramaOpen(false)} className="text-slate-400 hover:text-white p-1 rounded-lg">
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
@@ -220,13 +238,17 @@ export const UrunAramaModal = () => {
</div> </div>
)} )}
{sonuc && sorguDegisti && (
<p role="status" className="text-xs text-amber-800">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.</p>
)}
{sonuc && ( {sonuc && (
<> <>
<div className="flex items-baseline justify-between gap-2 flex-wrap"> <div className="flex items-baseline justify-between gap-2 flex-wrap">
<p className="text-xs font-bold text-slate-900"> <p className="text-xs font-bold text-slate-900">
{mod === 'okas' {submitted?.mod === 'okas'
? <>OKAS <span className="text-indigo-700">{okasSecili.map((k) => k.kod).join(', ')}</span> kodlu{' '}</> ? <>OKAS <span className="text-indigo-700">{submitted?.label}</span> kodlu{' '}</>
: <>Teklif cetvelinde <span className="text-indigo-700">"{q.trim()}"</span> geçen{' '}</>} : <>Teklif cetvelinde <span className="text-indigo-700">"{submitted?.label}"</span> geçen{' '}</>}
<span className="text-indigo-700">{sonuc.totalCount.toLocaleString('tr-TR')}</span> ihale bulundu <span className="text-indigo-700">{sonuc.totalCount.toLocaleString('tr-TR')}</span> ihale bulundu
</p> </p>
{gizlenen > 0 && ( {gizlenen > 0 && (
@@ -281,7 +303,7 @@ export const UrunAramaModal = () => {
<div className="flex items-center justify-center gap-3 pt-2"> <div className="flex items-center justify-center gap-3 pt-2">
<button <button
disabled={durum.sayfa === 0 || durum.yukleniyor} disabled={durum.sayfa === 0 || durum.yukleniyor}
onClick={() => ara(durum.sayfa - 1)} onClick={() => sorgula(submitted, durum.sayfa - 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40" className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
> >
← Önceki ← Önceki
@@ -291,7 +313,7 @@ export const UrunAramaModal = () => {
</span> </span>
<button <button
disabled={(durum.sayfa + 1) * SAYFA >= sonuc.totalCount || durum.yukleniyor} disabled={(durum.sayfa + 1) * SAYFA >= sonuc.totalCount || durum.yukleniyor}
onClick={() => ara(durum.sayfa + 1)} onClick={() => sorgula(submitted, durum.sayfa + 1)}
className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40" className="text-xs font-bold px-3 py-1.5 rounded-lg border border-slate-300 disabled:opacity-40"
> >
Sonraki → Sonraki →
+5 -5
View File
@@ -106,7 +106,7 @@ export const PriceAnalysis = () => {
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-4"> <div className="bg-white rounded-xl border border-slate-200 p-5 shadow-xs space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap"> <div className="flex items-center justify-between gap-2 flex-wrap">
<h3 className="font-bold text-sm text-slate-900 truncate">{selected?.ad} — Birim Fiyat Dağılımı (₺)</h3> <h3 className="font-bold text-sm text-slate-900 truncate">{selected?.ad} — Kategoriye Dağıtılmış Sözleşme Tutarı (₺)</h3>
<span className="text-[10px] bg-emerald-100 text-emerald-800 font-extrabold px-2 py-0.5 rounded-full">{selected?.count} gerçek kayıt</span> <span className="text-[10px] bg-emerald-100 text-emerald-800 font-extrabold px-2 py-0.5 rounded-full">{selected?.count} gerçek kayıt</span>
</div> </div>
<div className="h-64 w-full"> <div className="h-64 w-full">
@@ -134,12 +134,12 @@ export const PriceAnalysis = () => {
<div className="bg-linear-to-r from-slate-900 to-emerald-950 text-white p-5 rounded-xl space-y-2 shadow-md border border-emerald-900/40"> <div className="bg-linear-to-r from-slate-900 to-emerald-950 text-white p-5 rounded-xl space-y-2 shadow-md border border-emerald-900/40">
<div className="flex items-center gap-2 font-bold text-sm text-emerald-400"> <div className="flex items-center gap-2 font-bold text-sm text-emerald-400">
<Sparkles className="w-5 h-5 text-amber-400" /> Teklif Önerisi (gerçek veriye dayalı) <Sparkles className="w-5 h-5 text-amber-400" /> Karşılaştırmanın kapsamı
</div> </div>
<p className="text-xs text-slate-300 leading-relaxed"> <p className="text-xs text-slate-300 leading-relaxed">
<strong>{selected?.ad}</strong> için toplanan {selected?.count} gerçek kayda göre rekabetçi birim fiyat aralığınız{' '} <strong>{selected?.ad}</strong> grubunda {selected?.count} kaydın medyan kategori tutarı {formatTL(selected?.median)}.
<strong className="text-amber-300">{formatTL(selected?.min)} – {formatTL(selected?.median)}</strong> arasıdır. Bunlar ürün birim fiyatı değildir. Test adedi, ambalaj birimi, cihaz ve hizmet kapsamı doğrulanmadan
Medyanın altında teklif vermek kazanma olasılığını artırır; ortalamanın ({formatTL(selected?.avg)}) üstü riskli olabilir. bu tutarlardan teklif fiyatı veya kazanma olasılığı çıkarılamaz.
</p> </p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -279,10 +279,10 @@ export const AppProvider = ({ children }) => {
.then(detail => { .then(detail => {
if (cancelled || !detail.aciklama) return; if (cancelled || !detail.aciklama) return;
setActiveTenderDetail(prev => setActiveTenderDetail(prev =>
prev && prev.id === t.id ? { ...prev, aciklama: detail.aciklama } : prev prev && prev.id === t.id ? { ...prev, ...detail } : prev
); );
setTenders(prev => setTenders(prev =>
prev.map(x => (x.id === t.id ? { ...x, aciklama: detail.aciklama } : x)) prev.map(x => (x.id === t.id ? { ...x, ...detail } : x))
); );
}) })
.catch(() => {}); .catch(() => {});
+7 -7
View File
@@ -1,3 +1,4 @@
import { quantityParts, bidRowTotal, validateBidRows } from './bidValidation.js';
/** /**
* İhale dosyası belgeleri — teklif mektubu, birim fiyat teklif cetveli ve * İhale dosyası belgeleri — teklif mektubu, birim fiyat teklif cetveli ve
* evrak kontrol listesi. * evrak kontrol listesi.
@@ -53,12 +54,7 @@ export const paraTR = (n) =>
(Number(n) || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); (Number(n) || 0).toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
/** "3 adet" → { miktar: 3, birim: "adet" } ; okunamazsa miktar null */ /** "3 adet" → { miktar: 3, birim: "adet" } ; okunamazsa miktar null */
export function miktarAyristir(q) { export const miktarAyristir = quantityParts;
const m = /(-?[\d.,]+)\s*(.*)$/.exec(String(q || '').trim());
if (!m) return { miktar: null, birim: String(q || '').trim() };
const sayi = parseFloat(m[1].replace(/\./g, '').replace(',', '.'));
return { miktar: isFinite(sayi) ? sayi : null, birim: (m[2] || '').trim() };
}
const kacir = (s) => const kacir = (s) =>
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
@@ -73,6 +69,8 @@ const STIL = `
.ust div { margin: .6mm 0; } .ust div { margin: .6mm 0; }
table { width: 100%; border-collapse: collapse; margin: 3mm 0; font-size: 9.5pt; } table { width: 100%; border-collapse: collapse; margin: 3mm 0; font-size: 9.5pt; }
th, td { border: 1px solid #444; padding: 1.6mm 2mm; text-align: left; vertical-align: top; } th, td { border: 1px solid #444; padding: 1.6mm 2mm; text-align: left; vertical-align: top; }
thead { display: table-header-group; }
tr { break-inside: avoid; }
th { background: #eee; font-weight: bold; } th { background: #eee; font-weight: bold; }
td.s, th.s { text-align: right; white-space: nowrap; } td.s, th.s { text-align: right; white-space: nowrap; }
.toplam td { font-weight: bold; background: #f4f4f4; } .toplam td { font-weight: bold; background: #f4f4f4; }
@@ -160,10 +158,12 @@ ihale dokümanındaki standart form metniyle karşılaştırınız.</p>`;
/** Birim fiyat teklif cetveli. */ /** Birim fiyat teklif cetveli. */
export function teklifCetveliHtml(f, satirlar, toplam) { export function teklifCetveliHtml(f, satirlar, toplam) {
const errors = validateBidRows(satirlar);
if (errors.length) throw new Error(errors.join(" "));
const govde = satirlar.map((r, i) => { const govde = satirlar.map((r, i) => {
const { miktar, birim } = miktarAyristir(r.quantity); const { miktar, birim } = miktarAyristir(r.quantity);
const bf = Number(r.birimFiyat) || 0; const bf = Number(r.birimFiyat) || 0;
const tutar = (miktar ?? 0) * bf; const tutar = bidRowTotal(r);
return `<tr> return `<tr>
<td class="s">${i + 1}</td> <td class="s">${i + 1}</td>
<td>${kacir(r.name)}</td> <td>${kacir(r.name)}</td>
+24
View File
@@ -0,0 +1,24 @@
export function quantityParts(value) {
if (typeof value === 'number') return {miktar:Number.isFinite(value) ? value : null,birim:''};
const match = /^\s*([+-]?[\d]+(?:[.,]\d+)*)\s+(.+?)\s*$/.exec(String(value || ''));
if (!match) return {miktar:null,birim:''};
const raw = match[1];
const normalized = raw.includes(',') ? raw.replace(/\./g,'').replace(',','.')
: /^\d{1,3}(?:\.\d{3})+$/.test(raw) ? raw.replace(/\./g,'') : raw;
const number = Number(normalized);
return {miktar:Number.isFinite(number) ? number : null,birim:match[2]};
}
export const roundMoney = n => Math.round((n + Number.EPSILON) * 100) / 100;
export function bidRowTotal(row) {
const {miktar} = quantityParts(row.quantity);
const price = Number(row.birimFiyat);
return miktar > 0 && Number.isFinite(price) && price > 0 ? roundMoney(miktar * price) : null;
}
export function validateBidRows(rows) {
if (!rows.length) return ['Resmi teklif cetvelindeki kalemler eksik.'];
return rows.flatMap((row,i) => {
const {miktar,birim} = quantityParts(row.quantity);
return (!row.name?.trim() || !(miktar > 0) || !birim || bidRowTotal(row) === null)
? [`${i+1}. kalemin adı, miktarı, birimi veya fiyatı eksik/geçersiz.`] : [];
});
}
+24
View File
@@ -0,0 +1,24 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {quantityParts,validateBidRows,bidRowTotal} from '../src/services/bidValidation.js';
import {yaziylaTL,teklifCetveliHtml} from '../src/services/bidDocs.js';
test('Turkish quantities, decimal quantities and units are preserved',()=>{
assert.deepEqual(quantityParts('2.138.330 puan'),{miktar:2138330,birim:'puan'});
assert.deepEqual(quantityParts('1.234,50 litre'),{miktar:1234.5,birim:'litre'});
assert.deepEqual(quantityParts('1.5 litre'),{miktar:1.5,birim:'litre'});
assert.equal(quantityParts('kalem 15').miktar,null);
assert.equal(validateBidRows([{name:'Kit',quantity:'20',birimFiyat:10}]).length,1);
});
test('empty and partially priced bids cannot generate a valid sheet',()=>{
assert.ok(validateBidRows([]).length);
assert.throws(()=>teklifCetveliHtml({},[],0));
assert.ok(validateBidRows([{name:'Kit',quantity:'20 adet',birimFiyat:''}]).length);
assert.equal(bidRowTotal({quantity:'3 adet',birimFiyat:'0.335'}),1.01);
assert.equal(yaziylaTL(1.999),'iki Türk Lirası');
});
test('100 rows retain Turkish names and escaped content in printable sheet',()=>{
const rows=Array.from({length:100},(_,i)=>({name:`Ölçüm <kit> ${i}`,quantity:'2 adet',birimFiyat:10}));
const out=teklifCetveliHtml({firmaUnvan:'İğdır',ikn:'2026/1'},rows,2000);
assert.equal((out.match(/Ölçüm &lt;kit&gt;/g)||[]).length,100);
assert.ok(out.includes('İğdır'));assert.ok(out.includes('2.000,00'));
});
+25
View File
@@ -0,0 +1,25 @@
// Isolated, localhost-only UI fixture. It is never imported by production.
import {createServer} from 'vite';
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 as netServer} from 'node:net';
const dir=mkdtempSync(join(tmpdir(),'ihaleist-browser-'));
process.env.AUTH_SECRET=randomBytes(32).toString('hex');
const {signToken}=await import('../server/auth.mjs');
const user={email:'browser-qa@example.test',name:'Laboratuvar QA',isAdmin:false};
const token=signToken(user);
const probe=netServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r));
const api=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{PATH:process.env.PATH,NODE_ENV:'test',AUTH_SECRET:process.env.AUTH_SECRET,IHALEIST_USER_DATA_DIR:dir,PORT:String(port),UYUM_GOMME:'0'},stdio:'ignore'});
const entries=[['1582991','Giresun','10.09.2026','10:00'],['1534665','Kayseri','22.09.2026','11:00'],['1499061','Akdeniz','22.09.2026','10:30'],['1573235','Aksaray','09.09.2026','10:00'],['1523851','Tepecik','11.09.2026','10:00']];
const tenders=entries.map(([id,city,date,time])=>({id:'qa-'+id,ikn:'2026/'+id,baslik:'QA — '+city+' laboratuvar',kurum:city+' TEST KURUMU',sehir:city,tarih:date,saat:time,alimTuru:'Mal Alımı',sektor:'Laboratuvar & Ölçüm',sektorler:['Laboratuvar & Ölçüm'],durum:'Açık',takipte:false,kanbanStatus:'incelenecek',items:[{id:'1',ad:'Sentetik test kalemi',miktar:2,birim:'adet'}],aciklama:'Bu yalnızca yerel arayüz test verisidir. Teklif geçerlilik süresi 120 takvim günü. Birim fiyat teklif. '.repeat(3)}));
const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{target:'http://127.0.0.1:'+port}}},plugins:[{name:'qa-fixtures',configureServer(server){server.middlewares.use((req,res,next)=>{
if(req.url==='/qa-signin') {res.setHeader('Content-Type','text/html; charset=utf-8');res.end(`<html lang="tr"><h1>Yerel laboratuvar testi</h1><p>Sentetik ihale verileri ve geçici kullanıcı deposu kullanılır.</p><button id="login">Test hesabıyla gir</button><script type="module">import {setSession} from '/src/services/auth.js';document.querySelector('#login').onclick=()=>{setSession(${JSON.stringify(token)},${JSON.stringify(user)});location.href='/';};</script></html>`);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);
+7
View File
@@ -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');
});
+9
View File
@@ -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='<Tarih_Date Tarih="04.09.2026"><Currency CurrencyCode="USD"><Unit>1</Unit><ForexSelling>48.3195</ForexSelling></Currency><Currency CurrencyCode="EUR"><Unit>100</Unit><ForexSelling>5500</ForexSelling></Currency></Tarih_Date>';
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('<html>error</html>'));
assert.throws(()=>parseRates(xml.replace('<Unit>100','<Unit>0')));
});
+40
View File
@@ -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,{});
});
+18
View File
@@ -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'});
});