fix: preserve bid drafts and align tender source data
This commit is contained in:
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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 : ''}`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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})`
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 →
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
|||||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||||
|
|||||||
@@ -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.`] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 <kit>/g)||[]).length,100);
|
||||||
|
assert.ok(out.includes('İğdır'));assert.ok(out.includes('2.000,00'));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
@@ -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,{});
|
||||||
|
});
|
||||||
@@ -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'});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user