fix: persist tender preparation and correct calendar export
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# İhaleist düzeltme ve tamamlanma planı
|
||||
|
||||
6 Eylül 2026 · [Canlı test raporuna dayalı](ihaleist-test-raporu.html)
|
||||
|
||||
## Hedef ve tamamlanma tanımı
|
||||
|
||||
Bir laboratuvar sarfı, test kiti ve cihaz tedarikçisinin en az beş uygun ihale için **doküman, ürün uygunluğu, belge listesi, fiyat cetveli ve son kontrolleri tamamlayabilmesi**; çalışmasının yenileme ve yeniden giriş sonrasında korunması.
|
||||
|
||||
Ürün içindeki “Hazırlık tamamlandı” durumu, “EKAP’a teklif gönderildi” durumundan ayrı olacak. Resmi gönderim, ancak EKAP’tan doğrulanmış sonuç veya yetkili kullanıcının kaydettiği gönderim kanıtıyla işaretlenecek.
|
||||
|
||||
Plan 13 rapor bulgusunun tamamını ve ilk testte tamamlanamayan özellik kontrollerini kapsar. Bu belge uygulama planıdır; henüz kod veya canlı sistem değişikliği yapılmadı. Mevcut çalışma klasöründe rapor ve kanıtlar var; kaynak kod, dağıtım düzeni ve veritabanı yapısı doğrulanmadı. Aşağıdaki veri modeli bir öneridir; mevcut mimariye uyarlanacaktır.
|
||||
|
||||
## Teslimatlar ve sıra
|
||||
|
||||
| Aşama | İş paketi | Sorumluluk | Bağımlılık | Çıkış koşulu |
|
||||
|---|---|---|---|---|
|
||||
| 0 | Kod, çalışan sürüm ve hata teşhisi | Teknik sorumlu + backend | Yok | Tekrar üretilebilir test ortamı, kök neden ve etkilenen veri listesi |
|
||||
| 1 | Kalıcı kayıt, belge deposu, takvim ve eksik çıktı kontrolü | Backend + frontend + QA | 0 | Kayıtlar korunur; yanlış tarih ve yanıltıcı hazır çıktı üretilemez |
|
||||
| 2 | İhale verisi, tekilleştirme ve arama | Backend/veri + frontend | 0; kayıt dönüşümü için 1 | Tekil ve sürümlü kayıt, tam ilan, tutarlı filtre/sayaç |
|
||||
| 3 | Ortak belge/AI modeli ve teklif dosyası | Backend/AI + frontend + ihale uzmanı | 1 ve 2’deki veri sözleşmesi | Kaynak, süre, kalem, belge ve teklif türü bütün ekranlarda aynı |
|
||||
| 4 | Sağlık sektörüne uygun hazırlık akışı ve yardımcı modüller | Frontend/UX + backend | 2 ve 3 | Beş ihale için anlaşılır, eksikleri gösteren tek hazırlık akışı |
|
||||
| 5 | Tam kapsam kabul, kademeli yayın ve izleme | QA + teknik sorumlu + ürün sorumlusu | 1–4 | Beş dosya kabulü ve modül testleri geçer; geri dönüş planı doğrulanır |
|
||||
|
||||
**İlk teslimat:** Aşama 1 kritik düzeltmeleri. **İkinci teslimat:** Aşama 2–5 ile tüm hazırlık deneyimi. Bağımsız tarih/çıktı düzeltmeleri, büyük AI veya menü yenilemesini beklememeli. İş gücü ve takvim, Aşama 0’da kod ve entegrasyonlar görüldükten sonra kesinleştirilmeli; şu an kesin gün taahhüdü verilmemeli.
|
||||
|
||||
## Aşama 0 — Teşhisi ve çalışma ortamını sabitle
|
||||
|
||||
- Üretimdeki kod deposunu, commit/sürümü, API servislerini, veritabanını, migration geçmişini, dosya depolamayı ve veri toplama işlerini belirle.
|
||||
- `no such column: ikn` hatasında hangi sorgu/tablo/ortamın çalıştığını iz sürme kimliğiyle doğrula. Eksik migration, yanlış kolon adı veya yanlış veritabanına bağlanma seçeneklerini ayır; körlemesine kolon ekleme.
|
||||
- Takip, görev, belge ve firma kayıtlarının aynı altyapıyı kullanıp kullanmadığını belirle. API anahtarlarının yüklenme sorununun ayrı nedenini araştır.
|
||||
- Veri dönüşümünden önce yedek al ve test ortamında geri yüklemeyi doğrula. Üretim verisini doğrudan değiştirmeden dönüşüm önizlemesi oluştur.
|
||||
- ADMIN, normal firma yöneticisi ve ekip üyesi test hesapları oluştur; ayrı iki test firmasıyla veri ayrımını kontrol edebilecek ortam kur.
|
||||
- Rapordaki beş ihale ile Iğdır çelişki örneğini sürümlü test verisi olarak sakla. Test tarihi sabitlenerek tarih testlerinin gelecekte bozulması önlensin.
|
||||
|
||||
**Teslim:** Kök neden notu, mevcut veri modeli, test ortamı, iş bazında kapsam/tahmin ve yayın bağımlılıkları.
|
||||
|
||||
## Aşama 1 — Kritik işlevleri güvenilir yap
|
||||
|
||||
### D01 · Kalıcı kayıt ve belge deposu — H01, H06
|
||||
|
||||
1. Doğrulanan şema/sorgu uyumsuzluğunu sürümlü migration veya doğru sorgu ile düzelt; yeni kurulum ve mevcut kurulum yükseltmesini ayrı test et.
|
||||
2. Görev/takip işlemlerinde sunucu onayını esas al. Beklerken “Kaydediliyor”, hatada “Kaydedilemedi — yeniden dene” göster; başarısız işlemi kaydedilmiş gibi bırakma.
|
||||
3. Tekrarlanan isteğin ikinci görev oluşturmamasını sağla. Aynı kaydı iki sekmede güncellemede sessiz veri ezilmesi yerine sürüm kontrolü uygula.
|
||||
4. Belge için ihale bağlantısı, yükleyen firma, dosya sürümü, işleme durumu ve erişim yetkisini kalıcı tut. İhale dokümanı ile özel firma belgesini ayır; özel belgeler ortak havuza açılmamalı.
|
||||
5. Bekleme, boş liste, erişim reddi ve sunucu hatasını ayrı ekran durumlarına dönüştür. Sonsuz yüklenme yerine yeniden deneme sun.
|
||||
6. Başarısız eski işlemlerden kurtarılabilen kayıt olup olmadığını incele; kurtarma yoksa geçmiş çalışmayı yeniden oluşmuş gibi gösterme.
|
||||
|
||||
**Kabul:** Beş görev, not, checklist ve dosya; yenileme, yeni sekme, çıkış/giriş ve servis yeniden başlatma sonrasında aynı kalır. Ağ hatasında sahte başarı olmaz. Aynı isteğin tekrarı kopya üretmez. Diğer firma özel dosyayı göremez. Belge yükleme → analiz → indirme zinciri tamamlanır.
|
||||
|
||||
### D02 · Tarih ve saat için tek kaynak — H02, H09
|
||||
|
||||
- İhale tarih/saatini açık zaman dilimiyle sakla; ilan tarihi ve son teklif tarihini ayır. Liste, detay, geri sayım, takvim, bildirim ve ICS aynı alanı kullansın.
|
||||
- Belirsiz gün/ay ayrıştırmasını kaldır; saat bilinmiyorsa varsayılan saat uydurmak yerine eksikliği göster.
|
||||
- ICS’ye kararlı etkinlik kimliği ve sürüm/güncelleme bilgisi ekle; tarih değişikliğinde yeni kopya yerine mevcut etkinliği güncelleyebilecek çıktı üret.
|
||||
- Giresun test örneğinde 10.09.2026 10:00 Türkiye saati, UTC çıktıda `20260910T070000Z` olmalı.
|
||||
|
||||
**Kabul:** Beş ilanın kart, detay ve ICS anları aynı. Google/Outlook test takvimlerine aktarım doğru. Ay/yıl geçişi, artık gün, eksik saat ve zeyilname senaryoları geçer. Takvim görünümü 7 günle sınırlı kalmaz; tarih aralığı/ay görünümüne erişilir.
|
||||
|
||||
### D03 · Eksik teklifi hazır dosya olarak üretme — H03
|
||||
|
||||
- Kalem, miktar, birim, seçili kısım, fiyat, firma ve zorunlu belgeleri çıktı öncesi doğrula; boş fiyatı otomatik sıfır sayma.
|
||||
- Eksik çalışma çıktısı gerekiyorsa “EKSİK TASLAK — TEKLİF OLARAK KULLANILAMAZ” etiketi ve eksik listesiyle indirilsin.
|
||||
- Sahte/sabit “ONAYLI” kaşesini kaldır. Gerçek kullanıcı onayı, zamanı ve belge sürümü olmadan onay durumu gösterme; uygulama kaşesini e-imza yerine sunma.
|
||||
- PDF Türkçe karakterleri, tutar yuvarlaması, çok sayfalı cetvel, satır taşması ve alan adını düzelt. Excel ve PDF aynı hesaplama verisinden üretilsin.
|
||||
|
||||
**Kabul:** Giresun boş kalem örneği hazır teklif üretemez. Doğrulanmış çok kalemli örnekte ekrandaki ve çıktılardaki satır/toplamlar birebir aynı. Eksik alanlar kullanıcıyı ilgili forma götürür.
|
||||
|
||||
## Aşama 2 — İhale havuzu ve aramayı düzelt
|
||||
|
||||
### D04 · Tekil ihale ve kaynak sürümleri — H08
|
||||
|
||||
- Dahili ihale kimliği ile kaynak kayıt kimliklerini ayır. Aynı resmi İKN’nin farklı kaynaklardaki karşılıklarını birleştir; yalnız başlık benzerliğiyle kayıt birleştirme.
|
||||
- Doğrudan temin kimliklerinin benzersizlik kapsamını kaynak/kurum düzeyinde doğrula. Zeyilname, iptal ve sonuç bilgilerini ayrı ihale kopyası yerine ilişkili olay/sürüm olarak tut.
|
||||
- Mevcut tekrarları önizleme ve eşleme tablosuyla birleştir; görev, belge, takip ve not bağlantılarını koru. Şüpheli eşleşmeleri otomatik silme.
|
||||
- Liste, bülten, sonuç, sözleşme ve analitik sayaçlarına ortak filtre ve tekil kayıt tanımı uygula; “bugün” için aynı zaman dilimini kullan.
|
||||
|
||||
**Kabul:** 26DT1693370 ve bülten örnekleri tekil görünür. Aynı toplama işi tekrar çalışınca toplam büyümez. Gerçek zeyilname geçmişi ve kullanıcı kayıtları korunur. Sayaç ve listeler aynı filtrede uyuşur.
|
||||
|
||||
### D05 · Tam ilan, doğru alanlar ve sorgu durumu — H09, H11, H12
|
||||
|
||||
- Metin kesilmesinin veri kaynağı, API sınırı veya arayüz kaynaklı olduğunu ayır. Tam içerik ve kaynak bağlantısını sun; tam metin alınamıyorsa eksik olduğunu belirt.
|
||||
- Teslimat, saat, alım türü ve kısmi teklif gibi alanları kaynağa bağla. Iğdır personelli hizmeti yalnız cihaz/kit mal alımı olarak etiketleme.
|
||||
- 81 ili ortak il kodlarıyla sun; İ/ı ve diğer Türkçe karakterlerle aramayı doğrula.
|
||||
- Düzenlenen sorgu ile son çalıştırılan sorguyu ayır. Eski sorgu yanıtı geç gelirse yeni sonuçları ezmesin; filtre değişince sayfalama sıfırlansın.
|
||||
- Havuz dışındaki ürün sonuçlarından kaynak detayına/EKAP’a geçiş ver. Ürün araması, genel arama ve detaylı aramanın kapsamını açık anlat.
|
||||
|
||||
**Kabul:** Hemogram → OKAS geçişinde eski sonuç yeni sorguya ait gösterilmez. Iğdır/Bitlis seçilebilir. Beş ilanın tam metin ve temel alanları kontrol edilir. Boş sonuç, sonraki sayfa ve birleşik filtre testleri geçer.
|
||||
|
||||
## Aşama 3 — Belge, AI ve hazırlık formunu birleştir
|
||||
|
||||
### D06 · Ortak veri sözleşmesi — H04, H05, H07
|
||||
|
||||
Önerilen ortak bilgiler: ihale ve doküman sürümü; kaynak sayfa/madde; kalem numarası, miktar ve birim; teklif türü; geçerlilik süresi; gerekli belgeler; teslimat; analiz durumu; doğrulayan kullanıcı ve zaman. Her bilgi “doğrulandı / kontrol gerekli / bulunamadı / uygulanmıyor” durumlarından birini taşımalı; bu durumlar sıfır veya boş metne indirgenmemeli.
|
||||
|
||||
1. İlan, idari şartname, teknik şartname ve yüklenen firma belgesini türlerine göre ayır. PDF okuma ve gerektiğinde OCR sonucunda belge bütünlüğünü kontrol et.
|
||||
2. Belgeye Sor, ilan analizi, derin analiz, Taslak Üret ve Taslağı Denetle aynı sürümlü belge kümesini kullansın. Yalnız ilan varsa bunu açıkça belirt.
|
||||
3. AI’den yapılandırılmış çıktı al ve sunucuda doğrula. Ekran metninden tekrar alan çıkarmaya dayanma.
|
||||
4. 120 gün gibi kaynakta bulunan değerler forma aynen taşınsın. Kaynakta olmayan değere sessizce 60 gün varsayımı atama.
|
||||
5. Kalem çıkarılamaması teklif türünü götürü bedele çevirmesin; resmi cetvel yükleme veya kontrollü manuel tamamlama adımını açsın.
|
||||
6. Üstte bulunan yeterlik/riskin altta yok sayılmasını engelle. Konsorsiyum ve iş ortaklığını ayrı kavramlar olarak koru; çelişkiyi kaynak maddeleriyle kullanıcıya göster.
|
||||
7. Sayısal hesapları ve kapsamı tanımlı formülleri AI metin üretiminden ayır. Sınır değer formüllerini uygulanma koşulları ve güncel resmi kaynaklarıyla ihale uzmanına doğrulat; her alım türüne tek formül uygulama.
|
||||
8. Belge sürümü/zeyilname değişirse eski analiz ve hazırlık alanlarını “yeniden kontrol gerekli” yap. Kullanıcının düzeltmesini yeni AI çalışmasıyla sessizce ezme.
|
||||
9. Analiz durumlarını bekliyor/işleniyor/tamamlandı/hata olarak birleştir; tamamlanmayan teknik şartname analizini yapılmış gibi sunma.
|
||||
|
||||
**Kabul:** Iğdır örneğinde 120 gün bütün ilgili ekranlarda aynı; personel ve puan kalemleri görünür; birim fiyat koşulu korunur. Her kritik şart kaynağa gider. Belgeye Sor ile derin analiz aynı belge sürümünü gösterir. Çelişki veya eksik resmi kalem varken “Hazırlık tamamlandı” oluşmaz. Kaynak değişikliği önceki hazırlığı yeniden kontrol durumuna geçirir.
|
||||
|
||||
## Aşama 4 — Sağlık tedarikçisi için tek hazırlık akışı
|
||||
|
||||
### D07 · Başvuru hazırlık ekranı
|
||||
|
||||
**Firma ve ürünler → İhale seçimi → Resmi doküman → Kalem uygunluğu → Belgeler → Fiyatlandırma → Son kontrol → EKAP’a geçiş**
|
||||
|
||||
- Firma profilinde ürün grubu, marka/model, ÜTS durumu ve servis kapasitesini tanımla; web taramasını düzenlenebilir öneri olarak sun.
|
||||
- Kalem satırında miktar/birim, ürün eşleşmesi, teknik madde, numune/demonstrasyon, teslimat, sorumlu ve eksik belgeyi birlikte göster.
|
||||
- Sarf satışı ile cihaz kurulum/bakım ve personelli hizmeti ayır. Akdeniz’in 16 aylık cihaz yükümlülüğü veya Iğdır’ın personel yükümlülüğü sıradan kit eşleşmesiyle kaybolmasın.
|
||||
- Firma belgelerini uygun ihalelerde yeniden kullan; süre bitimi ve belge sürümünü takip et.
|
||||
- Eksiklere dayanan ilerleme göstergesi kullan. Salt AI uyum puanı veya PDF indirme, hazırlığın tamamlandığını göstermesin.
|
||||
- Menüleri Keşfet / Hazırlan / Pazar / Yönetim altında düzenle; normal kullanıcıya yönetici ayarlarını gösterme. Yardım metinlerini gerçek işleyişle güncelle.
|
||||
|
||||
**Kabul:** Bir kullanıcı beş dosyada sıradaki işi, eksik belgeyi ve sorumluyu tek ekrandan bulur. Her eksik doğrudan düzeltme alanına gider. Sağlık sektörüne özgü koşullar görünür; kullanıcı değişiklikleri kalıcıdır.
|
||||
|
||||
### D08 · Fiyat, kur ve eşleşme açıklığı — H10, H13
|
||||
|
||||
- Miktarı/birimi bilinmeyen kategori tutarını birim fiyat diye sunma; “medyan altına teklif ver” önerisini kaldır.
|
||||
- Birim fiyat karşılaştırmasını sadece miktar, birim, kapsam ve dönem bilgisi doğrulanmış kayıtlarda yap. Test/kutu/adet ve cihaz/hizmet tutarlarını karıştırma.
|
||||
- Kurda kaynak, kur türü, güncelleme zamanı ve veri alınamadı durumunu göster; eski veriyi “canlı” diye etiketleme.
|
||||
- “Sıfır alakasız sektör garantisi” yerine eşleşen terimleri, kapsamı ve belirsizlikleri göster.
|
||||
|
||||
**Kabul:** Birim verisi olmayan kayıt fiyat tavsiyesi üretmez. Kur servisi başarısızken yanlış canlılık algısı oluşmaz. Kullanıcı eşleşmenin nedenini görebilir.
|
||||
|
||||
## Aşama 5 — Eksik kapsamı kapat ve kontrollü yayınla
|
||||
|
||||
### D09 · Tam özellik kabul matrisi
|
||||
|
||||
| Alan | Tamamlanacak senaryo |
|
||||
|---|---|
|
||||
| Hesap ve yetki | Yeni kayıt, giriş, parola kurtarma; ADMIN/normal firma/ekip üyesi; iki firma arasında veri ayrımı |
|
||||
| Firma profili | İzinli test sitesiyle tarama, manuel ürün ekleme/silme, profil kaydı ve uyum sıralaması |
|
||||
| Arama | Ana/detaylı/ürün/OKAS, birleşik filtre, il, sıralama, sayfalama, boş sonuç |
|
||||
| Hazırlık | Takip, Kanban sürükleme, atama, not, checklist, eşzamanlı düzenleme ve kalıcılık |
|
||||
| Belge/AI | Metin PDF, taranmış PDF, bozuk/uygunsuz dosya, boyut sınırı, tekrar yükleme; soru, taslak üretme ve denetleme |
|
||||
| Çıktı | PDF/Excel içerik ve toplam; Türkçe; 100 kalem; ICS ve tarih güncellemesi |
|
||||
| Hesaplayıcılar | KDV/tevkifat/ters hesap, sınır değer kapsamı, yuvarlama ve hatalı girişler |
|
||||
| Pazar modülleri | Sonuç/sözleşme/rakip/bülten/analitik/TED/KİK; tekillik, filtre ve kaynak geçişi |
|
||||
| API | Anahtar oluşturma, listeleme, kullanma ve iptal; firma yetkisi; webhook varsa imza/tekrar/başarısız teslim, yoksa ürün vaadini düzeltme |
|
||||
| Planlar | Deneme ve paket limitleri; sağlayıcının test ortamında başarılı/başarısız ödeme ve iptal; gerçek tahsilat yok |
|
||||
| Bildirim/PWA | İzinli test alıcısıyla e-posta/push; zeyilname/sonuç uyarısı; tekrar engeli; offline/senkron ve güncelleme |
|
||||
| Model yönetimi | Yalnız yönetici erişimi; test ortamında ayar kalıcılığı, sağlayıcı hatası ve geri dönüş |
|
||||
| Mobil/erişilebilirlik | Gerçek 390/768/1440 genişlik; klavye, odak sırası, modal kapanışı, ekran okuyucu etiketleri |
|
||||
| Performans | Temsilî yoğun veriyle liste/arama/analiz süreleri; otomasyon zaman aşımından ayrı kullanıcı ölçümleri |
|
||||
|
||||
Testlerde gerçek firmaya mesaj, gerçek ödeme veya resmi teklif gönderimi yerine test hesapları ve sağlayıcı test ortamları kullanılacak. Dış sistem CAPTCHA/e-imza adımları yetkili kullanıcıya bırakılacak; otomasyonla aşılmayacak.
|
||||
|
||||
### D10 · Beş ihale ile nihai kabul
|
||||
|
||||
| Test vakası | Özellikle kanıtlanacak sonuç |
|
||||
|---|---|
|
||||
| Giresun 2026/1582991 | İki mikroskop kalemi, numune/demonstrasyon ve belgeler; doğru tarihli ICS; dolu cetvel |
|
||||
| Kayseri 2026/1534665 | 26 sarf kalemi, ürün/belge eşleşmesi, 15 günlük teslimat ve kalıcı görev |
|
||||
| Akdeniz 2026/1499061 | 13 kalem, cihaz kurulumu, 16 aylık yükümlülük ve maliyet bileşenleri |
|
||||
| Aksaray 2026/1573235 | 34 kısım içinde uygun kısımların seçimi; seçilmeyen kalemlerin toplam/çıktıya karışmaması |
|
||||
| Tepecik 2026/1523851 | 100 kalemde kullanılabilirlik, çok sayfalı çıktı, ilk teslimat ve 6 aylık süre |
|
||||
|
||||
Bu kayıtlar regresyon testinde tarih sabitlenerek kullanılacak. Yayın kabulü ihale tarihleri geçtikten sonra yapılırsa ayrıca o tarihte açık beş uygun aday seçilecek; süresi geçmiş kayda gerçek başvuru hazırlığı önerilmeyecek. Mevcut test raporundaki “ilan tarihi” özet başlığı da “ihale/son teklif tarihi” olarak ayrıştırılmalı.
|
||||
|
||||
**Başarı ölçütleri:**
|
||||
|
||||
- Beş dosyanın görev, belge, kalem, fiyat ve kontrol durumları oturumlar arasında eksiksiz korunur.
|
||||
- Beşinde de eksikler giderilmeden hazır durumu oluşmaz; uygun olmayan firma/ürün koşulunda sistem doğru engeli gösterir.
|
||||
- Resmi doküman ve firma uygunluğu sağlanan beş pozitif test dosyasında hazırlık tamamlanır ve çıktı kontrolü geçer.
|
||||
- Tarih/saat ve tutar alanlarında ekran, kaynak ve çıktı arasında fark yoktur.
|
||||
- P1 bulguların tamamı kapanır; H01–H13 için önce/sonra kanıtı ve tekrar test sonucu kaydedilir.
|
||||
- Kritik yolda açık hata varsa tam hazırlık sürümü yayımlanmaz. Doğrulanmamış özellik “test geçti” sayılmaz.
|
||||
|
||||
### D11 · Yayın ve geri dönüş
|
||||
|
||||
- Değişiklikleri kayıt/çıktı, veri, AI ve arayüz paketleri halinde küçük sürümlere ayır.
|
||||
- Migration’ı eski ve yeni uygulama sürümleriyle uyumlu tasarla; önce yeni alanı ekleme, veri dönüştürme ve kontrol, sonra kullanım değişikliği sırasını uygula. Hemen yıkıcı kolon silme yapma.
|
||||
- Önce test ortamında, sonra sınırlı test firmalarında doğrula; tüm kullanıcılara aşamalı aç.
|
||||
- Başarısız kayıt/analiz, belge işleme hatası, kopya veri ve tarih/çıktı doğrulama hatalarını iz sürme kimliğiyle ölç; kişisel belge içeriklerini loglama.
|
||||
- Kayıt/çıktı doğrulama hatası yeniden görülürse ilgili özelliği kapat veya son sağlam uygulama sürümüne dön. Veri dönüşümlerinde kör ters migration yerine doğrulanmış geri yükleme/ileri düzeltme prosedürünü kullan.
|
||||
- Hata başına uygulama değişikliği, test sonucu, kanıt, kalan sınır ve yayın sürümünü iş listesine ekle.
|
||||
|
||||
## Başlangıçta gerekli girdiler
|
||||
|
||||
Üretim kod deposu ve dağıtım bilgisi; test ortamı/veritabanı erişimi; gizlilik kurallarına uygun örnek firma profili ve belgeler; beş ihale için resmi dokümanlar; test e-posta/push ve ödeme ortamı. Bunlar mevcut yapıdan araştırılacak, bulunamayanlar tek eksik listesinde toplanacak. Her aşamada yeniden onay isteyen bir süreç yerine, kapsamı belli iş paketleri ve ölçülebilir kabul sonuçları kullanılacak.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Düzeltme planı — uygulama durumu
|
||||
|
||||
6 Eylül 2026. Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani.
|
||||
|
||||
Planın tamamı henüz bitmedi. Canlı yayın yapılmadı.
|
||||
|
||||
## Kanıtlanan ilk değişiklikler
|
||||
|
||||
- Eski belgeler tablosunda ikn indeksi kolon migration'ından önce oluşuyordu. Transaction içinde kolonlar önce ekleniyor; eski belgeyi koruyan, tekrarlanabilir migration testi geçti.
|
||||
- Veri deposu hataları artık boş hesap veya başarılı yazma gibi yutulmuyor.
|
||||
- İhale başına kullanıcıya ait hazırlık durumu, snapshot ve sürüm saklanıyor. Yeni API sürüm çakışmasında 409 döndürüyor. İki kullanıcı ve beş kayıt testi geçti.
|
||||
- Arayüz görev/takip/atama/not işlemlerini sunucu onayıyla güncelliyor. Tarayıcıyla uçtan uca doğrulama bekliyor.
|
||||
- ICS sabit tarih kullanmıyor. Beş tarih, yıl geçişi, artık gün, geçersiz tarih ve UTF-8 katlama testleri geçti.
|
||||
- Uydurma 150 TL fiyat/sürüklenen kaşe simülatörü gerçek hazırlık formuyla değiştirildi. Boş kalem, fiyat, süre ve firma kontrolleri eklendi. Bu alanlarda görsel/işlevsel test bekliyor.
|
||||
- Şehir filtresi mevcut 81 il tablosundan besleniyor.
|
||||
|
||||
## Doğrulama
|
||||
|
||||
`node --test tests/*.test.mjs`: 9/9 geçti.
|
||||
`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.
|
||||
|
||||
## Tamamlanacak işler (orijinal kapsam korunuyor)
|
||||
|
||||
- D01: kontrollü test API ortamı; arayüz kalıcılığı, hesap geçişi/önbellek ayrımı, tüm checklist/inceleme yolları, eski takiplerin aktarımı, dosya yaşam döngüsü. Production şema ve yedek/geri yükleme henüz doğrulanmadı.
|
||||
- D02: tüm kart/saat/geri sayım/takvim alanlarını ortak tarih modeline geçirmek; gerçek takvim içe aktarımı ve zeyilname güncellemesi.
|
||||
- D03: cetvel/form kalıcılığı, ortak miktar/para hesaplaması, çok sayfalı Türkçe PDF/Excel testleri; eski kullanılmayan PDF üreticisinin kaldırılması veya düzeltilmesi.
|
||||
- D04: veri havuzunda kaynaklar arası tekilleştirme ve kullanıcı referanslarını koruyan dönüşüm.
|
||||
- D05: tam ilan, metadata, arama yarışları/sorgu etiketi ve kapsamlı filtre testleri.
|
||||
- D06: sürümlü ortak belge/AI modeli, kaynak atıfları, belgeye sor ve diğer AI yollarının birleştirilmesi, doğru süre/kalem/teklif türü aktarımı ve çelişki engelleri. Şu an süre için ilk yerel çıkarım var; kapsam tamamlanmadı.
|
||||
- D07: tek sağlık hazırlık ekranı, kalem uygunluğu/belge durumu/servis ve numune takibi, menü düzeni.
|
||||
- D08: sabit kurun kaldırılması, gerçek kaynak/zaman bilgisi; fiyat karşılaştırma ve eşleşme iddialarının düzeltilmesi.
|
||||
- D09: normal hesaplar, mobil/erişilebilirlik, PDF/OCR, ödeme test ortamı, API, uyarılar, PWA ve tüm diğer modüllerin kabul matrisi.
|
||||
- D10: beş resmi doküman ve uygun test firma verisiyle gerçek hazırlık kabulü; tarihleri geçmişse güncel beş adayla ayrıca kabul.
|
||||
- D11: küçük sürümlerle yayın, yedek/geri dönüş ve canlı ölçümler.
|
||||
|
||||
Kod testleri bütün planın tamamlandığını kanıtlamaz. Uçtan uca tarayıcı ve canlı kabul henüz yoktur.
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js"
|
||||
"server": "node server/index.js",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
|
||||
@@ -1061,6 +1061,20 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/me/tender-state') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
if (req.method === 'GET') return json(res, 200, {success:true, records:store.getTenderStates(user.email)});
|
||||
if (req.method !== 'PUT') return json(res, 405, {success:false,error:'desteklenmeyen metot'});
|
||||
try {
|
||||
const body = await readBody(req, 512 * 1024);
|
||||
const record = store.saveTenderState(user.email, body.key, body.patch, body.snapshot, body.version);
|
||||
return json(res, 200, {success:true, record});
|
||||
} catch (e) {
|
||||
return json(res, e.kod === 'VERSION_CONFLICT' ? 409 : 400, {success:false,error:e.message,kod:e.kod});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kullanıcı verisi (localStorage yerine sunucu tarafı) ---
|
||||
if (url.pathname.startsWith('/api/me/data')) {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
|
||||
+56
-13
@@ -19,7 +19,7 @@ try {
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const DATA_DIR = process.env.IHALEIST_USER_DATA_DIR || path.join(__dirname, 'data');
|
||||
const DB_PATH = path.join(DATA_DIR, 'users.sqlite');
|
||||
export const BELGE_DIR = path.join(DATA_DIR, 'belgeler');
|
||||
|
||||
@@ -47,6 +47,11 @@ CREATE TABLE IF NOT EXISTS kullanici_verisi (
|
||||
PRIMARY KEY (email, anahtar)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tender_state (
|
||||
email TEXT NOT NULL, tender_key TEXT NOT NULL, data TEXT NOT NULL,
|
||||
snapshot TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (email, tender_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS api_anahtarlari (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
@@ -73,7 +78,7 @@ CREATE TABLE IF NOT EXISTS belgeler (
|
||||
olusturma TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_belge_email ON belgeler(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_belge_ikn ON belgeler(email, ikn);
|
||||
|
||||
`;
|
||||
|
||||
export function available() { return Boolean(DatabaseSync); }
|
||||
@@ -83,26 +88,34 @@ export function openDb() {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(BELGE_DIR, { recursive: true });
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(SCHEMA);
|
||||
// Geçiş: belgeler ihaleye bağlanabilsin diye ikn kolonu eklendi
|
||||
try {
|
||||
const k = db.prepare('PRAGMA table_info(belgeler)').all().map((c) => c.name);
|
||||
if (!k.includes('ikn')) {
|
||||
db.exec('ALTER TABLE belgeler ADD COLUMN ikn TEXT');
|
||||
console.log('[user-store] belgeler.ikn kolonu eklendi');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
db.exec(SCHEMA);
|
||||
const columns = new Set(db.prepare('PRAGMA table_info(belgeler)').all().map((c) => c.name));
|
||||
// Existing volumes predate these columns. Indexes must be created AFTER migration.
|
||||
for (const column of ['ikn', 'son_kullanma']) {
|
||||
if (!columns.has(column)) db.exec(`ALTER TABLE belgeler ADD COLUMN ${column} TEXT`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[user-store] belge şema geçişi atlandı:', e.message);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_belge_ikn ON belgeler(email, ikn)');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`);
|
||||
db.prepare('INSERT OR IGNORE INTO schema_migrations VALUES (?,?)')
|
||||
.run('20260906_document_columns', new Date().toISOString());
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
try { db.exec('ROLLBACK'); } catch { /* original error is authoritative */ }
|
||||
db.close();
|
||||
throw error;
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const withDb = (fn, fallback = null) => {
|
||||
const withDb = (fn) => {
|
||||
const db = openDb();
|
||||
if (!db) return fallback;
|
||||
if (!db) throw new Error('Kullanıcı veri deposu kullanılamıyor.');
|
||||
// Never report a failed write as a successful operation or a failed read as an empty account.
|
||||
try { return fn(db); }
|
||||
catch (e) { console.warn('[user-store]', e.message); return fallback; }
|
||||
finally { db.close(); }
|
||||
};
|
||||
|
||||
@@ -275,3 +288,33 @@ export function documentUsage(email) {
|
||||
return { adet: r?.adet ?? 0, bayt: r?.bayt ?? 0 };
|
||||
}, { adet: 0, bayt: 0 });
|
||||
}
|
||||
|
||||
// Per-tender compare-and-swap prevents stale tabs from overwriting newer preparation.
|
||||
export function getTenderStates(email) {
|
||||
return withDb(db => Object.fromEntries(db.prepare('SELECT * FROM tender_state WHERE email=?').all(email)
|
||||
.map(r => [r.tender_key, { data: JSON.parse(r.data), snapshot: JSON.parse(r.snapshot), version: r.version }])));
|
||||
}
|
||||
export function saveTenderState(email, key, patch, snapshot, expectedVersion) {
|
||||
const fields = new Set(['takipte','incelendi','kanbanStatus','atananPersonel','gorev','notes','checklist','bidDraft']);
|
||||
if (!key || key.length > 100 || !patch || typeof patch !== 'object' || Array.isArray(patch)
|
||||
|| Object.keys(patch).some(k => !fields.has(k)) || !Number.isInteger(expectedVersion) || expectedVersion < 0)
|
||||
throw new Error('Geçersiz hazırlık kaydı.');
|
||||
return withDb(db => {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM tender_state WHERE email=? AND tender_key=?').get(email,key);
|
||||
if ((row?.version || 0) !== expectedVersion) {
|
||||
const error = new Error('Bu ihale başka bir sekmede değişti. Güncel kayıt yüklendi; değişikliğinizi tekrar uygulayın.');
|
||||
error.kod = 'VERSION_CONFLICT'; throw error;
|
||||
}
|
||||
const record = { data: {...(row ? JSON.parse(row.data) : {}), ...patch},
|
||||
snapshot: row ? {...JSON.parse(row.snapshot), ...snapshot} : snapshot,
|
||||
version: expectedVersion + 1 };
|
||||
db.prepare(`INSERT INTO tender_state VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT(email,tender_key) DO UPDATE SET data=excluded.data,snapshot=excluded.snapshot,
|
||||
version=excluded.version,updated_at=excluded.updated_at`)
|
||||
.run(email,key,JSON.stringify(record.data),JSON.stringify(record.snapshot || {}),record.version,now());
|
||||
db.exec('COMMIT'); return record;
|
||||
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
||||
});
|
||||
}
|
||||
|
||||
+3
-1
@@ -39,12 +39,14 @@ import { LoginPage } from './components/LoginPage';
|
||||
import { LandingPage } from './components/LandingPage';
|
||||
|
||||
const MainContent = () => {
|
||||
const { activeTab, isAdmin } = useApp();
|
||||
const { activeTab, isAdmin, saveError, savingCount } = useApp();
|
||||
|
||||
return (
|
||||
<main className="max-w-[1600px] mx-auto px-4 pb-12">
|
||||
{/* 3-Column Main View */}
|
||||
<KotaUyarisi />
|
||||
{savingCount > 0 && <p role="status" className="p-3 bg-blue-50 text-blue-900">Kaydediliyor…</p>}
|
||||
{saveError && <p role="alert" className="p-3 bg-red-50 text-red-900 border border-red-200 rounded-lg">{saveError} Değişiklik kaydedilmedi; tekrar deneyin.</p>}
|
||||
|
||||
{activeTab === 'list' && (
|
||||
<>
|
||||
|
||||
@@ -35,7 +35,7 @@ const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
|
||||
|
||||
export const BidPackageBuilder = ({ results, tender }) => {
|
||||
const { userProfile } = useApp();
|
||||
const [acik, setAcik] = useState(false);
|
||||
const [acik, setAcik] = useState(true);
|
||||
const [hata, setHata] = useState(null);
|
||||
|
||||
const [firma, setFirma] = useState(() => {
|
||||
@@ -49,11 +49,14 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
||||
});
|
||||
|
||||
const [teklifTuru, setTeklifTuru] = useState('birim');
|
||||
const [gecerlilikGun, setGecerlilikGun] = useState('60');
|
||||
const kaynakGecerlilik = /(?:geçerlilik[^.]{0,100}?|ihale tarihinden itibaren\s*)(\d+)\s*(?:takvim\s*)?gün/i.exec(tender?.aciklama || '');
|
||||
const [gecerlilikGun, setGecerlilikGun] = useState(kaynakGecerlilik?.[1] || '');
|
||||
const [kisim, setKisim] = useState('');
|
||||
const [goturuBedel, setGoturuBedel] = useState('');
|
||||
|
||||
const kalemler = Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : [];
|
||||
const kalemler = tender?.items?.length
|
||||
? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]}))
|
||||
: (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []);
|
||||
const [fiyatlar, setFiyatlar] = useState({});
|
||||
|
||||
const alanYaz = (k, v) => {
|
||||
@@ -78,7 +81,13 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
||||
const toplam = teklifTuru === 'goturu' ? (Number(goturuBedel) || 0) : kalemToplami;
|
||||
|
||||
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
|
||||
const hazir = eksikler.length === 0 && toplam > 0;
|
||||
const kalemEksik = teklifTuru === 'birim' && (!satirlar.length || satirlar.some(r => {
|
||||
const {miktar,birim} = miktarAyristir(r.quantity);
|
||||
return !r.name || !(miktar > 0) || !birim || !(Number(r.birimFiyat) > 0);
|
||||
}));
|
||||
const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || '');
|
||||
const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1]));
|
||||
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik;
|
||||
|
||||
const formVerisi = () => ({
|
||||
...firma,
|
||||
@@ -94,6 +103,7 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
||||
const uret = (tip) => {
|
||||
setHata(null);
|
||||
try {
|
||||
if (!hazir) throw new Error('Kalem, miktar, birim, fiyat, firma ve geçerlilik süresindeki eksikleri giderin.');
|
||||
const f = formVerisi();
|
||||
if (tip === 'mektup') belgeYazdir('Teklif Mektubu', teklifMektubuHtml(f, toplam));
|
||||
if (tip === 'cetvel') belgeYazdir('Birim Fiyat Teklif Cetveli', teklifCetveliHtml(f, satirlar, toplam));
|
||||
@@ -262,7 +272,7 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded-lg p-3">
|
||||
Analizde kalem dökümü çıkmadı. Götürü bedel seçip toplam teklifinizi doğrudan girebilirsiniz.
|
||||
Kalem dökümü bulunamadı. Resmi teklif cetvelini İhale Dosyaları bölümünden yükleyin. Kalem eksikliği teklif türünü değiştirmez.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -284,6 +294,11 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(kalemEksik || turCeliskisi || sureEksik) && <p role="alert" className="text-xs bg-amber-50 p-3 text-amber-900">
|
||||
{kalemEksik && 'Seçilen her kalemde ad, miktar, birim ve fiyat gerekli. '}
|
||||
{turCeliskisi && 'İlan birim fiyat teklif belirtiyor; götürü bedel seçilemez. '}
|
||||
{sureEksik && 'Teklif geçerlilik süresini kaynak dokümana göre doğrulayın. '}
|
||||
</p>}
|
||||
{/* Belgeler */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="font-extrabold text-xs text-slate-900">Hazırlanacak belgeler</h5>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { BidPackageBuilder } from './BidPackageBuilder';
|
||||
import { TenderDocuments } from './TenderDocuments';
|
||||
import { TeklifAsistani } from './TeklifAsistani';
|
||||
import { TeklifCetveliKalemleri } from './TeklifCetveliKalemleri';
|
||||
@@ -7,13 +8,13 @@ import {
|
||||
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
|
||||
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
|
||||
Send, Stamp, ArrowLeft, ExternalLink, Printer, PenLine } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { createTenderICS } from '../services/tenderCalendar';
|
||||
|
||||
export const TenderDetailModal = () => {
|
||||
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders } = useApp();
|
||||
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError } = useApp();
|
||||
const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders'
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [savedNotes, setSavedNotes] = useState([]);
|
||||
const [actionError, setActionError] = useState(null);
|
||||
|
||||
// Kur Farkı Calculator State
|
||||
const [bidAmount, setBidAmount] = useState(15000);
|
||||
@@ -28,30 +29,23 @@ export const TenderDetailModal = () => {
|
||||
// Havuzdaki güncel kayıt: takip/görev değişince düğme etiketleri de değişsin.
|
||||
const tender = (activeTenderDetail && tenders.find((t) => t.id === activeTenderDetail.id)) || activeTenderDetail;
|
||||
|
||||
const handleAddNote = (e) => {
|
||||
const savedNotes = tender.notes || [];
|
||||
const handleAddNote = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!noteText.trim()) return;
|
||||
setSavedNotes([...savedNotes, {
|
||||
const saved = await mutateTender(tender.id, {notes: [...savedNotes, {
|
||||
id: Date.now(),
|
||||
author: userProfile.name,
|
||||
text: noteText.trim(),
|
||||
date: 'Az önce'
|
||||
}]);
|
||||
setNoteText('');
|
||||
date: new Date().toLocaleString('tr-TR')
|
||||
}]});
|
||||
if (saved) setNoteText('');
|
||||
};
|
||||
|
||||
const handleExportICS = () => {
|
||||
confetti({ particleCount: 50 });
|
||||
const icsData = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//İhaleist//İhale Takvimi//TR
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:İhale Son Teslim: ${tender.baslik}
|
||||
DESCRIPTION:İKN: ${tender.ikn} - ${tender.kurum}
|
||||
DTSTART:20260810T120000Z
|
||||
DTEND:20260810T130000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR`;
|
||||
let icsData;
|
||||
try { icsData = createTenderICS(tender); setActionError(null); }
|
||||
catch (e) { setActionError(e.message); return; }
|
||||
|
||||
const blob = new Blob([icsData], { type: 'text/calendar;charset=utf-8' });
|
||||
const link = document.createElement('a');
|
||||
@@ -60,6 +54,7 @@ END:VCALENDAR`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
};
|
||||
|
||||
const handleStampMouseDown = () => setIsDraggingStamp(true);
|
||||
@@ -77,6 +72,8 @@ END:VCALENDAR`;
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-xs flex justify-center p-2 sm:p-4 animate-fade-in">
|
||||
<div className="bg-white w-full max-w-6xl rounded-2xl shadow-2xl border border-slate-200 overflow-hidden my-auto flex flex-col max-h-[92vh]">
|
||||
|
||||
{(actionError || saveError) && <p role="alert" className="p-3 bg-red-50 text-red-900">{actionError || saveError}</p>}
|
||||
{savingCount > 0 && <p role="status" className="p-2 bg-blue-50">Kaydediliyor…</p>}
|
||||
{/* Top Header Controls */}
|
||||
<div className="bg-[#1f2937] text-white px-6 py-3.5 flex items-center justify-between border-b border-slate-700 shrink-0">
|
||||
<button
|
||||
@@ -383,86 +380,8 @@ END:VCALENDAR`;
|
||||
|
||||
{/* Tab 2: PDF Birim Fiyat Cetveli & Kaşe Editörü */}
|
||||
{activeTab === 'pdf_stamp' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 text-amber-900 p-4 rounded-xl text-xs flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Stamp className="w-5 h-5 text-amber-600" />
|
||||
<span>Teklif cetvelinizi doldurun ve Dijital Kaşenizi sürükleyerek belgenin altına yerleştirin.</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
import('../services/pdf').then(({ generateBidSheetPDF }) => {
|
||||
generateBidSheetPDF(tender, { firma: userProfile.company, stamp: true });
|
||||
});
|
||||
}}
|
||||
className="bg-amber-600 hover:bg-amber-500 text-white font-bold px-4 py-2 rounded-lg flex items-center gap-1.5 shadow-2xs"
|
||||
>
|
||||
<Download className="w-4 h-4" /> Kaşeli PDF İndir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Interactive PDF Sheet Canvas Simulator */}
|
||||
<div
|
||||
onMouseMove={handleStampMouseMove}
|
||||
onMouseUp={handleStampMouseUp}
|
||||
className="bg-slate-200 p-6 rounded-2xl border border-slate-300 relative select-none min-h-[500px] flex justify-center"
|
||||
>
|
||||
{/* Paper sheet */}
|
||||
<div className="bg-white w-full max-w-2xl p-8 rounded-lg shadow-xl border border-slate-300 relative space-y-6 text-slate-800">
|
||||
<div className="text-center border-b-2 border-slate-800 pb-4 space-y-1">
|
||||
<p className="font-extrabold text-sm uppercase">{tender.kurum}</p>
|
||||
<p className="font-bold text-xs text-blue-900">BİRİM FİYAT TEKLİF CETVELİ — İKN: {tender.ikn}</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-left text-xs border border-slate-400">
|
||||
<thead className="bg-slate-100 font-bold border-b border-slate-400">
|
||||
<tr>
|
||||
<th className="p-2 border-r border-slate-400">Sıra</th>
|
||||
<th className="p-2 border-r border-slate-400">Malzeme Adı</th>
|
||||
<th className="p-2 border-r border-slate-400">Miktar</th>
|
||||
<th className="p-2 border-r border-slate-400">Birim Fiyat (TL)</th>
|
||||
<th className="p-2">Toplam (TL)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-300">
|
||||
{tender.items?.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="p-2 border-r border-slate-400 font-bold text-center">{idx + 1}</td>
|
||||
<td className="p-2 border-r border-slate-400 font-semibold">{item.ad}</td>
|
||||
<td className="p-2 border-r border-slate-400">{item.miktar} {item.birim}</td>
|
||||
<td className="p-2 border-r border-slate-400 bg-yellow-50 font-bold text-blue-900">
|
||||
{item.birimFiyat || 150} TL
|
||||
</td>
|
||||
<td className="p-2 font-mono font-bold text-emerald-800">
|
||||
{((item.birimFiyat || 150) * parseFloat(item.miktar)).toLocaleString('tr-TR')} TL
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pt-12 text-right">
|
||||
<p className="font-bold text-xs text-slate-800">{userProfile.company}</p>
|
||||
<p className="text-[10px] text-slate-500">Yetkili İmza ve Kaşe Alanı</p>
|
||||
</div>
|
||||
|
||||
{/* Draggable Stamp Element */}
|
||||
<div
|
||||
onMouseDown={handleStampMouseDown}
|
||||
style={{ left: `${stampPos.x}px`, top: `${stampPos.y}px` }}
|
||||
className="absolute cursor-grab active:cursor-grabbing border-2 border-dashed border-red-500 bg-red-500/10 p-3 rounded-lg text-center transform -rotate-6 shadow-md transition-shadow hover:shadow-lg select-none"
|
||||
>
|
||||
<div className="w-32 h-16 border-2 border-red-700 rounded flex flex-col items-center justify-center p-1 text-red-700 font-black text-[9px] uppercase tracking-tighter">
|
||||
<span>{userProfile.company.slice(0, 25)}</span>
|
||||
<span className="text-[8px] text-red-600 font-bold mt-0.5">MÜDÜR İMZA & KAŞE</span>
|
||||
<span className="text-[7px] text-slate-500">2026-08-07 ONAYLI</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-extrabold text-red-600 block mt-1">↕ Sürükleyip Bırakın</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BidPackageBuilder key={tender.ikn || tender.id} tender={tender}
|
||||
results={aiAnalysisResults?.tenderKey === (tender.ikn || tender.id) ? aiAnalysisResults : null} />
|
||||
)}
|
||||
|
||||
{/* Tab 3: AI Asistanı */}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const ApiWebhooks = () => {
|
||||
|
||||
const yukle = () => {
|
||||
if (!authUser) return;
|
||||
setHata(null);
|
||||
listApiKeys().then(setDurum).catch((e) => setHata(e.message));
|
||||
};
|
||||
useEffect(yukle, [authUser]);
|
||||
@@ -53,6 +54,7 @@ export const ApiWebhooks = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{hata && <button onClick={yukle} className="px-4 py-2 border rounded-lg">Yeniden dene</button>}
|
||||
<div className="bg-linear-to-r from-slate-900 via-violet-950 to-indigo-900 text-white p-6 rounded-2xl shadow-lg border border-violet-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-violet-600 flex items-center justify-center shadow-md">
|
||||
@@ -111,7 +113,7 @@ export const ApiWebhooks = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!durum ? (
|
||||
{!durum && !hata && !hata ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
|
||||
) : durum.anahtarlar.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">Henüz anahtar yok.</p>
|
||||
|
||||
@@ -20,6 +20,7 @@ export const DocumentVault = () => {
|
||||
|
||||
const yukle = () => {
|
||||
if (!authUser) return;
|
||||
setHata(null);
|
||||
listDocuments().then(setDurum).catch((e) => setHata(e.message));
|
||||
};
|
||||
useEffect(yukle, [authUser]);
|
||||
@@ -106,6 +107,7 @@ export const DocumentVault = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{hata && <button onClick={yukle} className="px-4 py-2 border rounded-lg">Yeniden dene</button>}
|
||||
<div className="bg-linear-to-r from-slate-900 via-teal-950 to-cyan-900 text-white p-6 rounded-2xl shadow-lg border border-teal-800/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-2xl bg-teal-600 flex items-center justify-center shadow-md">
|
||||
@@ -234,7 +236,7 @@ export const DocumentVault = () => {
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{!durum ? (
|
||||
{!durum && !hata && !hata ? (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400"><Loader2 className="w-3.5 h-3.5 animate-spin" /> yükleniyor…</div>
|
||||
) : durum.belgeler.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-dashed border-slate-300 p-10 text-center">
|
||||
|
||||
+77
-49
@@ -5,6 +5,8 @@ import { getToken } from '../services/auth';
|
||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||||
import { getAllUserData, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
|
||||
import { applyTenderStates, tenderStateKey, tenderSnapshot } from '../services/tenderState';
|
||||
import { getTenderStates, saveTenderState } from '../services/account';
|
||||
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||
|
||||
const AppContext = createContext();
|
||||
@@ -42,7 +44,7 @@ export const AppProvider = ({ children }) => {
|
||||
try {
|
||||
const saved = localStorage.getItem(TENDER_CACHE_KEY);
|
||||
const parsed = saved ? JSON.parse(saved) : null;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
return Array.isArray(parsed) ? parsed.map(t => ({...t,takipte:false,gorev:null,notes:[],checklist:[],incelendi:false,atananPersonel:null,kanbanStatus:'incelenecek',bidDraft:null})) : [];
|
||||
} catch {
|
||||
try { localStorage.removeItem(TENDER_CACHE_KEY); } catch { /* yok say */ }
|
||||
return [];
|
||||
@@ -151,16 +153,26 @@ export const AppProvider = ({ children }) => {
|
||||
useEffect(() => {
|
||||
if (!authUser) { setVeriSenkron(false); setPlan(null); return; }
|
||||
planYenile();
|
||||
getAllUserData()
|
||||
.then((v) => {
|
||||
if (v.favori_kurumlar) setFavoriteKurumlar(v.favori_kurumlar);
|
||||
if (v.ekip) setTeamMembers(v.ekip);
|
||||
if (v.saved_filters) setSavedFilters(v.saved_filters);
|
||||
let cancelled = false;
|
||||
setVeriSenkron(false);
|
||||
ilkYazmaAtlanan.current.clear();
|
||||
savedTenderStates.current = {};
|
||||
Promise.all([getAllUserData(), getTenderStates()])
|
||||
.then(([v, records]) => {
|
||||
if (cancelled) return;
|
||||
setFavoriteKurumlar(v.favori_kurumlar || []);
|
||||
setTeamMembers(v.ekip || []);
|
||||
setSavedFilters(v.saved_filters || []);
|
||||
if (v.reminder) setReminderSettings((s) => ({ ...s, ...v.reminder }));
|
||||
if (v.rakip_izleme) setCompetitorWatchlist(v.rakip_izleme);
|
||||
setCompetitorWatchlist(v.rakip_izleme || []);
|
||||
if (v.firma_profil) setUserProfile(p => ({...p,...v.firma_profil}));
|
||||
savedTenderStates.current = records;
|
||||
setTenders(prev => applyTenderStates(prev, records));
|
||||
setVeriSenkron(true);
|
||||
setSaveError(null);
|
||||
})
|
||||
.catch((e) => console.warn('[veri] sunucudan alınamadı:', e.message))
|
||||
.finally(() => setVeriSenkron(true));
|
||||
.catch((e) => { if (!cancelled) setSaveError('Hesap verileri yüklenemedi: ' + e.message); });
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authUser?.email]);
|
||||
|
||||
@@ -168,6 +180,38 @@ export const AppProvider = ({ children }) => {
|
||||
// localStorage yalnızca çevrimdışı önbellek; kaynak doğruluk sunucuda.
|
||||
const [plan, setPlan] = useState(null);
|
||||
const [veriSenkron, setVeriSenkron] = useState(false);
|
||||
const savedTenderStates = useRef({});
|
||||
const inFlight = useRef(new Set());
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const [savingCount, setSavingCount] = useState(0);
|
||||
const mutateTender = async (tenderId, patch) => {
|
||||
const t = tenders.find(t => t.id === tenderId);
|
||||
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
|
||||
const key = tenderStateKey(t);
|
||||
if (inFlight.current.has(key)) return false;
|
||||
const owner = authUser.email;
|
||||
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
|
||||
try {
|
||||
const record = await saveTenderState(key, patch, tenderSnapshot(t), savedTenderStates.current[key]?.version || 0);
|
||||
if (getStoredUser()?.email !== owner) return false;
|
||||
savedTenderStates.current = {...savedTenderStates.current,[key]:record};
|
||||
setTenders(prev => applyTenderStates(prev, savedTenderStates.current));
|
||||
return true;
|
||||
} catch (e) {
|
||||
setSaveError(e.message);
|
||||
if (e.kod === 'VERSION_CONFLICT') {
|
||||
try {
|
||||
const records = await getTenderStates();
|
||||
if (getStoredUser()?.email === owner) {
|
||||
savedTenderStates.current = records;
|
||||
setTenders(prev => applyTenderStates(prev, records));
|
||||
}
|
||||
} catch { /* retain visible error */ }
|
||||
}
|
||||
return false;
|
||||
} finally { inFlight.current.delete(key); setSavingCount(n => n - 1); }
|
||||
};
|
||||
|
||||
// Sunucu verisi yüklendiğinde her effect bir kez tetiklenir; bu ilk turda
|
||||
// yazmak gereksiz (aynı veri) ve boş istemci state'iyle ezme riski taşır.
|
||||
const ilkYazmaAtlanan = useRef(new Set());
|
||||
@@ -182,7 +226,7 @@ export const AppProvider = ({ children }) => {
|
||||
if (!authUser) return;
|
||||
setUserData(anahtar, deger).catch((e) => {
|
||||
if (e.kod === 'KOTA_ASILDI') setKotaUyarisi(e.message);
|
||||
else console.warn('[veri] sunucuya yazılamadı:', e.message);
|
||||
else setSaveError('Kaydedilemedi: ' + e.message);
|
||||
});
|
||||
};
|
||||
const [kotaUyarisi, setKotaUyarisi] = useState(null);
|
||||
@@ -193,6 +237,9 @@ export const AppProvider = ({ children }) => {
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
setAuthUser(null);
|
||||
savedTenderStates.current = {};
|
||||
setTenders([]);
|
||||
setActiveTenderDetail(null);
|
||||
};
|
||||
|
||||
// Canlı ihale verisini (ilan.gov.tr) çeker; kullanıcının kanban/takip durumunu korur.
|
||||
@@ -201,7 +248,7 @@ export const AppProvider = ({ children }) => {
|
||||
setLiveDataError(null);
|
||||
try {
|
||||
const data = await fetchLiveTenders({ refresh });
|
||||
setTenders(prev => mergeUserState(data.tenders, prev));
|
||||
setTenders(prev => applyTenderStates(mergeUserState(data.tenders, prev), savedTenderStates.current));
|
||||
setLiveDataSource({ source: data.source, fetchedAt: data.fetchedAt });
|
||||
setLiveStats({
|
||||
toplamIhale: data.totalCount ?? 0,
|
||||
@@ -401,6 +448,7 @@ export const AppProvider = ({ children }) => {
|
||||
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode });
|
||||
const result = {
|
||||
...data.result,
|
||||
tenderKey: tender?.ikn || tender?.id || null,
|
||||
mode: data.mode || (mode === 'derin' ? 'derin' : 'standart'),
|
||||
fileName,
|
||||
model: `${data.model.label} (${data.model.provider})`,
|
||||
@@ -564,12 +612,8 @@ export const AppProvider = ({ children }) => {
|
||||
// gerçek /api/stats verisinden gelir.)
|
||||
|
||||
const toggleTrackTender = (tenderId) => {
|
||||
setTenders(prev => prev.map(t => {
|
||||
if (t.id === tenderId) {
|
||||
return { ...t, takipte: !t.takipte };
|
||||
}
|
||||
return t;
|
||||
}));
|
||||
const t = tenders.find(t => t.id === tenderId);
|
||||
return mutateTender(tenderId, {takipte: !t?.takipte});
|
||||
};
|
||||
|
||||
// Takipteki İKN'ler sunucuya yazılır: zeyilname/düzeltme ilanı ve sonuç
|
||||
@@ -589,28 +633,21 @@ export const AppProvider = ({ children }) => {
|
||||
* Hazırlanıyor" kolonunda düşer, takibe alınır — sonuç yayımlanınca bildirim
|
||||
* gelir. (Fikir: MOBIT-DURT "ilandan göreve, görevden sonuca".)
|
||||
*/
|
||||
const gorevAc = (tenderId, { kontrolListesi = [] } = {}) => {
|
||||
setTenders((prev) => prev.map((t) => {
|
||||
if (t.id !== tenderId) return t;
|
||||
return {
|
||||
...t,
|
||||
takipte: true,
|
||||
kanbanStatus: t.kanbanStatus && t.kanbanStatus !== 'incelenecek' ? t.kanbanStatus : 'teklif_hazirlaniyor',
|
||||
gorev: {
|
||||
acildi: new Date().toISOString(),
|
||||
teslim: t.tarih ? `${t.tarih}${t.saat ? ' ' + t.saat : ''}` : null,
|
||||
kontrolListesi: kontrolListesi.map((k) => ({ ad: typeof k === 'string' ? k : k.document, tamam: false })),
|
||||
},
|
||||
};
|
||||
}));
|
||||
notify('Hazırlık görevi açıldı', 'Teslim tarihi ihale saati; pano "Teklif Hazırlanıyor" kolonunda.');
|
||||
const gorevAc = async (tenderId, { kontrolListesi = [] } = {}) => {
|
||||
const t = tenders.find(t => t.id === tenderId);
|
||||
if (!t || t.gorev) return;
|
||||
const ok = await mutateTender(tenderId, {
|
||||
takipte: true,
|
||||
kanbanStatus: t.kanbanStatus && t.kanbanStatus !== 'incelenecek' ? t.kanbanStatus : 'teklif_hazirlaniyor',
|
||||
gorev: { acildi: new Date().toISOString(), teslim: t.tarih ? `${t.tarih} ${t.saat || ''}`.trim() : null,
|
||||
kontrolListesi: kontrolListesi.map(k => ({ad:typeof k === 'string' ? k : k.document,tamam:false})) },
|
||||
});
|
||||
if (ok) notify('Hazırlık görevi kaydedildi', 'Görev sunucuda saklandı; Kanban panosundan devam edebilirsiniz.');
|
||||
};
|
||||
const gorevKontrol = (tenderId, idx) => {
|
||||
setTenders((prev) => prev.map((t) => {
|
||||
if (t.id !== tenderId || !t.gorev) return t;
|
||||
const liste = t.gorev.kontrolListesi.map((k, i) => (i === idx ? { ...k, tamam: !k.tamam } : k));
|
||||
return { ...t, gorev: { ...t.gorev, kontrolListesi: liste } };
|
||||
}));
|
||||
const t = tenders.find(t => t.id === tenderId);
|
||||
if (!t?.gorev) return;
|
||||
return mutateTender(tenderId, {gorev:{...t.gorev,kontrolListesi:t.gorev.kontrolListesi.map((k,i) => i === idx ? {...k,tamam:!k.tamam} : k)}});
|
||||
};
|
||||
|
||||
// Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster
|
||||
@@ -655,17 +692,8 @@ export const AppProvider = ({ children }) => {
|
||||
}, [veriSenkron]);
|
||||
|
||||
const updateKanbanStatus = (tenderId, newStatus, assignedUser = null) => {
|
||||
setTenders(prev => prev.map(t => {
|
||||
if (t.id === tenderId) {
|
||||
return {
|
||||
...t,
|
||||
kanbanStatus: newStatus,
|
||||
atananPersonel: assignedUser !== null ? assignedUser : t.atananPersonel,
|
||||
takipte: true
|
||||
};
|
||||
}
|
||||
return t;
|
||||
}));
|
||||
const t = tenders.find(t => t.id === tenderId);
|
||||
return mutateTender(tenderId, {kanbanStatus:newStatus,atananPersonel:assignedUser ?? t?.atananPersonel ?? null,takipte:true});
|
||||
};
|
||||
|
||||
const addSavedFilter = (newFilter) => {
|
||||
@@ -793,7 +821,7 @@ export const AppProvider = ({ children }) => {
|
||||
planYenile,
|
||||
kotaUyarisi,
|
||||
setKotaUyarisi,
|
||||
veriSenkron
|
||||
veriSenkron, saveError, savingCount, mutateTender
|
||||
}}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IL_PLAKA } from '../../server/il-plaka.mjs';
|
||||
/**
|
||||
* Sabit referans listeleri (mock veri DEĞİL — açılır menü/filtre seçenekleri).
|
||||
* Sektörler, kurum türleri ve iller Türkiye kamu ihale sisteminin gerçek
|
||||
@@ -44,10 +45,4 @@ export const INSTITUTION_TYPES_LIST = [
|
||||
"Ağız ve Diş Sağlığı Merkezleri",
|
||||
];
|
||||
|
||||
export const CITIES_LIST = [
|
||||
"Adana", "Adıyaman", "Afyonkarahisar", "Aksaray", "Amasya", "Ankara", "Antalya", "Ardahan", "Artvin",
|
||||
"Aydın", "Balıkesir", "Bolu", "Bursa", "Çanakkale", "Denizli", "Diyarbakır", "Edirne", "Eskişehir",
|
||||
"Gaziantep", "Giresun", "Isparta", "İstanbul", "İzmir", "Kahramanmaraş", "Kars", "Kastamonu", "Kayseri",
|
||||
"Kocaeli", "Konya", "Kütahya", "Malatya", "Manisa", "Mardin", "Muğla", "Ordu", "Rize", "Sakarya",
|
||||
"Samsun", "Sivas", "Şanlıurfa", "Tekirdağ", "Trabzon", "Van", "Zonguldak",
|
||||
];
|
||||
export const CITIES_LIST = Object.values(IL_PLAKA).sort((a,b) => a.localeCompare(b, 'tr'));
|
||||
|
||||
@@ -102,3 +102,7 @@ export const telegramSil = () => iste('/api/me/telegram', { method: 'DELETE' });
|
||||
// --- Takip bildirimleri (zeyilname / sonuç) ---
|
||||
export const bildirimleriGetir = () => iste('/api/me/bildirimler').then((d) => d.bildirimler || []);
|
||||
export const bildirimOkundu = (idler) => iste('/api/me/bildirimler', { method: 'POST', body: JSON.stringify({ idler }) });
|
||||
|
||||
export const getTenderStates = () => iste('/api/me/tender-state').then(d => d.records || {});
|
||||
export const saveTenderState = (key, patch, snapshot, version) =>
|
||||
iste('/api/me/tender-state', {method:'PUT',body:JSON.stringify({key,patch,snapshot,version})}).then(d => d.record);
|
||||
|
||||
@@ -27,8 +27,9 @@ export function yaziylaTL(n) {
|
||||
if (!isFinite(sayi)) return '';
|
||||
const negatif = sayi < 0;
|
||||
const mutlak = Math.abs(sayi);
|
||||
const tam = Math.floor(mutlak);
|
||||
const kurus = Math.round((mutlak - tam) * 100);
|
||||
const toplamKurus = Math.round((mutlak + Number.EPSILON) * 100);
|
||||
const tam = Math.floor(toplamKurus / 100);
|
||||
const kurus = toplamKurus % 100;
|
||||
|
||||
const gruplar = [];
|
||||
let kalan = tam;
|
||||
@@ -126,7 +127,7 @@ sınırlama yapılmadan bütün koşullarıyla kabul edilmiştir.</p>
|
||||
<p>İhale dokümanında yer alan bütün şartları kabul ederek, söz konusu ${bedelCumle}
|
||||
kabul ve taahhüt ediyoruz.</p>
|
||||
|
||||
<p>Teklifimizin, ihale tarihinden itibaren <strong>${kacir(f.gecerlilikGun || '60')}</strong>
|
||||
<p>Teklifimizin, ihale tarihinden itibaren <strong>${kacir(f.gecerlilikGun || '[DOĞRULAYIN]')}</strong>
|
||||
takvim günü süreyle geçerli olduğunu beyan ediyoruz.</p>
|
||||
|
||||
${f.kisim ? `<p>Teklifimiz <strong>${kacir(f.kisim)}</strong> kısmına/kısımlarına ilişkindir.</p>` : ''}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function fetchTenderDetail(tenderId) {
|
||||
}
|
||||
|
||||
// Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir.
|
||||
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel'];
|
||||
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel', 'gorev', 'notes', 'checklist', 'bidDraft'];
|
||||
|
||||
export function mergeUserState(liveTenders, previousTenders) {
|
||||
const prevById = new Map((previousTenders || []).map((t) => [t.id, t]));
|
||||
@@ -30,7 +30,7 @@ export function mergeUserState(liveTenders, previousTenders) {
|
||||
if (!prev) return tender;
|
||||
const merged = { ...tender };
|
||||
for (const f of USER_FIELDS) {
|
||||
if (prev[f] !== undefined && prev[f] !== null && prev[f] !== false) merged[f] = prev[f];
|
||||
if (prev[f] !== undefined) merged[f] = prev[f];
|
||||
}
|
||||
if (prev.aciklama && !merged.aciklama) merged.aciklama = prev.aciklama;
|
||||
return merged;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Calendar dates in Turkish procurement announcements are Europe/Istanbul (+03:00).
|
||||
// Never manufacture a deadline when the source omitted its time.
|
||||
export function tenderDeadline(t) {
|
||||
const source = String(t.aciklama || '');
|
||||
const date = /^(\d{2})\.(\d{2})\.(\d{4})/.exec(String(t.tarih || ''));
|
||||
if (!date) throw new Error('İhale tarihi doğrulanamadı; takvime eklenemedi.');
|
||||
const [,day,month,year] = date;
|
||||
const time = /^(\d{2}):(\d{2})/.exec(String(t.saat || '').trim())
|
||||
|| new RegExp(`${day}\\.${month}\\.${year}\\s*[-–]?\\s*(\\d{2}):(\\d{2})`).exec(source);
|
||||
if (!time) throw new Error('İhale saati kaynakta bulunamadı; önce tarih ve saati doğrulayın.');
|
||||
const hour = Number(time[1]), minute = Number(time[2]);
|
||||
const calendarDate = new Date(Date.UTC(Number(year), Number(month)-1, Number(day)));
|
||||
if (hour > 23 || minute > 59 || calendarDate.getUTCFullYear() !== Number(year)
|
||||
|| calendarDate.getUTCMonth()+1 !== Number(month) || calendarDate.getUTCDate() !== Number(day))
|
||||
throw new Error('Geçersiz ihale tarihi veya saati.');
|
||||
return new Date(Date.UTC(Number(year),Number(month)-1,Number(day),hour-3,minute));
|
||||
}
|
||||
const escapeICS = v => String(v || '').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/;/g,'\\;').replace(/,/g,'\\,');
|
||||
const stamp = date => date.toISOString().replace(/[-:]/g,'').replace(/\.\d{3}Z$/,'Z');
|
||||
function fold(line) {
|
||||
const result = []; let current = '';
|
||||
for (const char of line) {
|
||||
if (new TextEncoder().encode(current + char).length > 75) { result.push(current); current = ' '; }
|
||||
current += char;
|
||||
}
|
||||
result.push(current); return result.join('\r\n');
|
||||
}
|
||||
export function createTenderICS(t, now = new Date()) {
|
||||
const start = tenderDeadline(t);
|
||||
const uid = encodeURIComponent(String(t.ikn || t.id || '').trim());
|
||||
if (!uid) throw new Error('İhale kimliği gerekli.');
|
||||
return ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Ihaleist//Tender Calendar//TR','CALSCALE:GREGORIAN',
|
||||
'BEGIN:VEVENT',`UID:${uid}@ihaleist.com`,`DTSTAMP:${stamp(now)}`,
|
||||
`DTSTART:${stamp(start)}`,`DTEND:${stamp(start)}`,
|
||||
`SUMMARY:${escapeICS('İhale son teklif: '+t.baslik)}`,
|
||||
`DESCRIPTION:${escapeICS('İKN: '+t.ikn+' — '+t.kurum)}`,
|
||||
'END:VEVENT','END:VCALENDAR',''].map(fold).join('\r\n');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const tenderStateKey = t => String(t?.ikn || t?.id || '').trim();
|
||||
export const USER_STATE_FIELDS = ['takipte', 'incelendi', 'kanbanStatus', 'atananPersonel', 'gorev', 'notes', 'checklist', 'bidDraft'];
|
||||
export function applyTenderStates(tenders, records = {}) {
|
||||
const seen = new Set();
|
||||
const result = tenders.map(t => {
|
||||
const key = tenderStateKey(t); seen.add(key);
|
||||
const record = records[key];
|
||||
return record ? { ...t, ...record.data } : t;
|
||||
});
|
||||
for (const [key, record] of Object.entries(records)) {
|
||||
if (!seen.has(key) && record.snapshot && (record.data.takipte || record.data.gorev))
|
||||
result.push({ ...record.snapshot, ...record.data });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function tenderSnapshot(t) {
|
||||
return Object.fromEntries(['id','ikn','baslik','kurum','tarih','saat','sehir','alimTuru'].map(k => [k,t[k] ?? '']));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTenderICS, tenderDeadline } from '../src/services/tenderCalendar.js';
|
||||
const t = {ikn:'2026/1582991',baslik:'Mikroskop',kurum:'Giresun',tarih:'10.09.2026',saat:'',aciklama:'Tarih ve Saati : 10.09.2026 - 10:00'};
|
||||
test('Giresun date is September 10 at 10:00 Turkey, not hard-coded August', () => {
|
||||
assert.equal(tenderDeadline(t).toISOString(),'2026-09-10T07:00:00.000Z');
|
||||
assert.match(createTenderICS(t), /DTSTART:20260910T070000Z/);
|
||||
});
|
||||
test('all five tender deadlines and year rollover', () => {
|
||||
for (const [date,time,expected] of [
|
||||
['09.09.2026','10:00','2026-09-09T07:00'],['11.09.2026','10:00','2026-09-11T07:00'],
|
||||
['22.09.2026','10:30','2026-09-22T07:30'],['22.09.2026','11:00','2026-09-22T08:00'],
|
||||
['01.01.2027','01:00','2026-12-31T22:00'],['29.02.2028','10:00','2028-02-29T07:00']])
|
||||
assert.ok(tenderDeadline({...t,tarih:date,saat:time}).toISOString().startsWith(expected));
|
||||
});
|
||||
test('missing time, impossible date, invalid time are rejected', () => {
|
||||
for (const patch of [{saat:'',aciklama:''},{tarih:'31.02.2026',saat:'10:00'},{saat:'25:00'}])
|
||||
assert.throws(() => tenderDeadline({...t,...patch}));
|
||||
});
|
||||
test('ICS escapes text, folds UTF-8 lines, keeps stable UID', () => {
|
||||
const ics = createTenderICS({...t,baslik:'Ş'.repeat(100)+'\nEND:VEVENT;,'});
|
||||
assert.ok(ics.split('\r\n').every(l => Buffer.byteLength(l) <= 75));
|
||||
assert.equal(ics.split('UID:')[1].split('\r\n')[0],'2026%2F1582991@ihaleist.com');
|
||||
assert.ok(ics.includes('\\nEND:VEVENT\\;\\,'));
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ihaleist-store-'));
|
||||
process.env.IHALEIST_USER_DATA_DIR = dir;
|
||||
const legacy = new DatabaseSync(join(dir, 'users.sqlite'));
|
||||
legacy.exec(`CREATE TABLE belgeler (id TEXT PRIMARY KEY,email TEXT NOT NULL,ad TEXT NOT NULL,kategori TEXT,boyut INTEGER NOT NULL,mime TEXT,dosya TEXT NOT NULL,olusturma TEXT);
|
||||
INSERT INTO belgeler VALUES ('old','a@example.test','Eski belge','Yasal',5,'text/plain','old.txt','2026-09-01');`);
|
||||
legacy.close();
|
||||
const store = await import('../server/user-store.mjs');
|
||||
after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
test('old volume migrates before indexing, preserves files and migration is repeatable', () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const db = store.openDb();
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM schema_migrations').get().n, 1);
|
||||
assert.ok(db.prepare('PRAGMA table_info(belgeler)').all().some(c => c.name === 'ikn'));
|
||||
db.close();
|
||||
}
|
||||
assert.equal(store.listDocuments('a@example.test')[0].ad, 'Eski belge');
|
||||
assert.deepEqual(store.listDocuments('b@example.test'), []);
|
||||
});
|
||||
test('five preparation records persist across DB close/reopen and stay account scoped', () => {
|
||||
const records = Object.fromEntries(['1573235','1582991','1523851','1499061','1534665'].map(id => ['2026/'+id, { takipte: true, gorev: { kontrolListesi: [{ad:'ÜTS',tamam:false}] }, kanbanStatus:'teklif_hazirlaniyor' }]));
|
||||
assert.equal(store.setData('a@example.test', 'gorevler', records), true);
|
||||
assert.deepEqual(store.getAllData('a@example.test').gorevler, records);
|
||||
assert.equal(store.getData('b@example.test', 'gorevler'), null);
|
||||
});
|
||||
test('document and API key ownership enforced', () => {
|
||||
const id = store.addDocument('a@example.test', {ad:'Şartname',boyut:10,dosya:'spec.pdf',ikn:'2026/1582991'});
|
||||
assert.equal(store.listDocuments('a@example.test', {ikn:'2026/1582991'}).length, 1);
|
||||
assert.equal(store.getDocument('b@example.test', id), null);
|
||||
const key = store.createApiKey('a@example.test', 'Test');
|
||||
assert.equal(store.verifyApiKey(key.anahtar).email, 'a@example.test');
|
||||
assert.equal(store.revokeApiKey('b@example.test', key.id), false);
|
||||
store.revokeApiKey('a@example.test', key.id);
|
||||
assert.equal(store.verifyApiKey(key.anahtar), null);
|
||||
});
|
||||
test('database write failure propagates instead of pretending success', () => {
|
||||
const db = store.openDb();
|
||||
db.exec(`CREATE TRIGGER reject_write BEFORE INSERT ON kullanici_verisi BEGIN SELECT RAISE(FAIL,'test write failure'); END;`);
|
||||
db.close();
|
||||
assert.throws(() => store.setData('a@example.test','takip',[]), /test write failure/);
|
||||
});
|
||||
test('per-tender versions prevent stale updates; five tasks persist', () => {
|
||||
for (const id of ['1573235','1582991','1523851','1499061','1534665']) {
|
||||
const key = '2026/'+id;
|
||||
const saved = store.saveTenderState('c@example.test', key, {takipte:true,gorev:{kontrolListesi:[]}}, {id:key,ikn:key}, 0);
|
||||
assert.equal(saved.version,1);
|
||||
assert.throws(() => store.saveTenderState('c@example.test', key, {takipte:false}, {}, 0), e => e.kod === 'VERSION_CONFLICT');
|
||||
assert.equal(store.saveTenderState('c@example.test', key, {notes:[{text:'test'}]}, {}, 1).version,2);
|
||||
}
|
||||
assert.equal(Object.keys(store.getTenderStates('c@example.test')).length,5);
|
||||
assert.deepEqual(store.getTenderStates('d@example.test'),{});
|
||||
});
|
||||
Reference in New Issue
Block a user