Compare commits

...
33 Commits
Author SHA1 Message Date
hikmettan c68070ddf9 feat: add secure /api/admin/signups endpoint for abone takip
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-21 08:52:49 +03:00
hikmettan 6fda6764c3 docs: record D09 integration acceptance 2026-09-15 17:59:32 +03:00
hikmettan 6c32581952 fix: proxy MCP and expose EKAP verification gates 2026-09-15 17:54:14 +03:00
hikmettan 26b250735c docs: record current D10 live acceptance 2026-09-15 17:45:41 +03:00
hikmettan e5e1b12185 fix: guide EKAP human verification 2026-09-15 17:41:51 +03:00
hikmettan 242e7d8c2e fix: retain security headers on frontend assets 2026-09-15 15:26:49 +03:00
hikmettan 0bbfed321a fix: fingerprint the deployed entry asset 2026-09-15 15:24:19 +03:00
hikmettan 4d191a07b1 feat: expose verifiable deployment release 2026-09-15 15:17:24 +03:00
hikmettan a7495a1fe6 test: keep fixed D10 browser fixtures reproducible 2026-09-14 21:49:37 +03:00
hikmettan 4e94a77019 fix: prevent offline queue replay data loss 2026-09-14 21:46:52 +03:00
hikmettan b578c2d3ba feat: add offline tender mutation queue replay 2026-09-14 17:42:31 +03:00
hikmettanandClaude Fable 5.1 9b3809e21b fix(ted-ceviri): GLM harf bozulmasını onar, TED çevirisinde önce GLM 5.3
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
Canlı ölçüm (2026-09-11): glm-5.3-flash 22 başlığın 4'ünde kelime içi
büyük İ ve büyük harfe dönen kuyruk üretti ("Arazİ araçları",
"Elektrİklİ", "temİNİ"); açıklamalar temizdi.

- harfOnar: kelime küçük harfle devam ettikten sonra gelen büyükleri Türkçe
  kuralıyla küçültür; tamamı büyük kısaltmalara dokunmaz. Model çıktısına
  ve daha önce önbelleğe girmiş kayıtlara okunurken uygulanır.
- Çeviri prompt'una Türkçe yazım kuralı uyarısı eklendi.
- ZAI_TRANSLATE_MODELS varsayılanı glm-5.3 → glm-5.3-flash (paketler 6
  ilanlık, hız kaybı küçük; başlıklar kullanıcıya doğrudan görünüyor).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:26:33 +03:00
hikmettanandClaude Fable 5.1 7fa71f1a4e feat(ai): Z.ai GLM birincil sağlayıcı; OpenRouter yalnız yedek
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
OpenRouter anahtarı iptal olup bakiye eksiye düşünce şartname analizi,
TED çevirisi ve site taraması durmuştu. Artık ZAI_API_KEY ile GLM 5.3 ve
GLM 5.3 Flash doğrudan Z.ai Coding Plan üzerinden çağrılır; kalan modeller
OpenRouter anahtarı varsa yedek olarak denenir.

- server/zai.mjs: doğrudan istemci; glm-5.x için reasoning_effort:low,
  glm-4.x için thinking kapalı; süreç genelinde en fazla 2 eşzamanlı istek
  (kullanıcı istekleri arka plan çevirisinden öncelikli); geçici 429'da
  aynı modelle yeniden deneme, 1113 bakiye hatasında hemen sıradaki model.
- ai.mjs: 'zai' (GLM 5.3) sağlayıcısı eklendi, Z.ai yolu yeni istemciyi
  kullanıyor; varsayılan sıra zai → glmflash → gemini → …
- ted-ceviri.mjs: önce ZAI_TRANSLATE_MODELS (glm-5.3-flash, glm-5.3),
  sonra OpenRouter; 2'li eşzamanlı parça işleme.
- Admin model kataloğuna GLM 5.3 eklendi, kayıtlı zincirler sürüm 4'e taşınır.
- tests/zai.test.mjs: düşünme ayarı, 429/1113 davranışı, eşzamanlılık,
  analyzeSpec ve TED çevirisinin Z.ai'ye gittiği.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 23:11:41 +03:00
hikmettan be41ec7f4a 05a1411 canli kabulunu kaydet
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 22:51:43 +03:00
hikmettan 05a141113e belge sorumlusu kabul kapsamını kaydet
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:41:04 +03:00
hikmettan 7e8ef35b0d hazirlik ozetinde belge sorumlusu kabulunu ekle
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:39:12 +03:00
hikmettan 821df474ea hazirlik ozetinde belge sorumlularini goster
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:33:30 +03:00
hikmettan 6436dcb34b belge gereksinimlerine sorumlu ekle
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:32:25 +03:00
hikmettan be6e8dcc92 durum kaydinda son commit bilgisini duzelt
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:30:52 +03:00
hikmettan 63649b2ab1 eksik taslak etiketini kaydet
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:29:05 +03:00
hikmettan c783269041 eksik teklif taslagini acikca etiketle
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:28:43 +03:00
hikmettan 097b6db2fa detay onbellegi canli kabulunu kaydet
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:26:40 +03:00
hikmettan 4eb90615e8 yenilemede ihale detay onbelleğini tazele
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:23:45 +03:00
hikmettan fd5db7544e probe production document browser capability
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:10:17 +03:00
hikmettan 93240b1b20 document production healthcheck
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:04:46 +03:00
hikmettan 3bfd8028f0 record healthy production rollout
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 21:01:18 +03:00
hikmettan fd9c637cd1 fix healthcheck tooling in production image
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 20:58:13 +03:00
hikmettan f346415ef1 record official date live acceptance
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 20:44:49 +03:00
hikmettan a268b15ad7 reconcile official tender dates on detail open
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 20:42:21 +03:00
hikmettan 3042ec58af document reproducible live tender probe
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 20:40:45 +03:00
hikmettan f846ad05b9 add reproducible live five tender probe
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 20:40:14 +03:00
hikmettan 3c3979a67a record live five tender metadata acceptance
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 20:35:48 +03:00
hikmettan 0e3d7930f2 deploy validated production runtime
Quality gates / test-build (push) Canceled after 0s
Quality gates / container (push) Canceled after 0s
2026-09-06 20:31:14 +03:00
50 changed files with 2724 additions and 140 deletions
+8 -1
View File
@@ -13,7 +13,7 @@ RUN npm run build
FROM node:22-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx ca-certificates \
&& apt-get install -y --no-install-recommends nginx ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -28,6 +28,11 @@ RUN npm init -y >/dev/null \
&& npx playwright install --with-deps chromium \
&& npm cache clean --force
# Commit değişince ağır Chromium katmanını yeniden kurmadan yalnız yayın
# katmanlarını yenile. Coolify bu argümanı build içine açıkça geçirir.
ARG SOURCE_COMMIT=unknown
ENV APP_RELEASE=${SOURCE_COMMIT}
# Frontend
COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
@@ -36,6 +41,8 @@ COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
COPY --from=build /app/server ./server
# Backend, ortak doğrulama ve iş kurallarını src/services altından import eder.
COPY --from=build /app/src ./src
# .js importlarının ES module olarak doğrudan yüklenmesi için package type bilgisi.
COPY --from=build /app/package.json ./package.json
# Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı)
RUN mkdir -p /app/server/data
+6 -1
View File
@@ -134,7 +134,12 @@ düğmesiyle metni çıkarılıp şartname analizine gidiyor. DT kayıtlarında
- **Volume:** `/app/server/data` → `history.sqlite`, `dt.sqlite`, `users.sqlite`
- **Girilmiş sırlar:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY`,
`OPENROUTER_API_KEY`, `GOOGLE_CLIENT_ID`
`ZAI_API_KEY`, `OPENROUTER_API_KEY` (yalnız yedek), `GOOGLE_CLIENT_ID`
- **Yapay zekâ (2026-09-10):** birincil sağlayıcı Z.ai Coding Plan. `ZAI_BASE_URL`
= `https://api.z.ai/api/coding/paas/v4/chat/completions`; aynı anda en fazla 2 istek
(`ZAI_MAX_CONCURRENCY`), GLM 5.x'te `reasoning_effort: low`. Şartname analizi
GLM 5.3 → GLM 5.3 Flash, TED çevirisi GLM 5.3 Flash → GLM 5.3; ikisi de bitince
OpenRouter zincirine düşer. Ayrıntı: `server/zai.mjs`.
- **E-posta:** `bildirim@ihaleist.com` (Resend, eu-west-1, doğrulanmış)
- **Zamanlayıcı:** geçmiş tarama 6 sa · DT 4 sa · bildirim 6 sa
@@ -0,0 +1,43 @@
# D09 dış entegrasyon kabul planı
Son salt okunur canlı tarama: 15 Eylül 2026, `https://ihaleist.com`.
Bu plan, dış sistemlerde yan etki oluşturmayan kontrolleri tamamlananlardan ayırır. Ödeme, e-posta, Telegram mesajı ve resmi teklif gönderimi yalnız yetkili test verileri ve açık işlem yetkisiyle yapılır. EKAP güvenlik doğrulaması otomasyonla aşılmaz.
## Mevcut durum
| Entegrasyon | Canlı durum | Doğrulanan kanıt | Kapatma adımı |
| --- | --- | --- | --- |
| ilan.gov.tr / BİK | Geçti | HTTP 200, kaynak `ilan.gov.tr`, 3.258 kayıt | D10/D04 veri kalitesi izlemesi dışında D09 işi yok |
| TCMB | Geçti | HTTP 200, 15.09.2026 bülteni ve kaynak URL’si | D09 için kapalı |
| Google ile giriş | Yapılandırılmış | Public config `configured: true` | Test hesabıyla giriş, çıkış ve oturum yenileme |
| EKAP resmi doküman | İnsan doğrulaması | OKAS ve D10 belge isteği HTTP 428, `HUMAN_VERIFICATION_REQUIRED` | Yetkili kullanıcı CAPTCHA’yı tamamlayıp beş resmi paketi indirir; dosyalar uygulamaya yüklenir |
| KİK kurul kararları | İnsan doğrulaması | HTTP 428, doğru hata kodu ve kullanıcı yönlendirmesi | EKAP doğrulamasından sonra arama ve karar metni tekrar denenir |
| TED | Geçti | HTTP 200, zaman aşımı yok | Gerçek sonuçlu sorgu ve çeviri kontrollü AI test bütçesiyle doğrulanır |
| AI yardımcıları | Yapılandırılmış | 7 sağlayıcı hazır | Yetkili test hesabı ve incelenmiş test belgesiyle analiz, soru, taslak ve denetim zinciri |
| Stripe | Yapılandırılmış | Checkout ve webhook hazır, 3 plan | Stripe test modunda başarılı/başarısız ödeme, webhook tekrar teslimi ve iptal |
| E-posta | Kimlik doğrulamayla korumalı | Yetkisiz config isteği HTTP 401 | Yetkili hesapla config okuma; izinli test alıcısına tek gönderim |
| Telegram | Engelli | Public config `configured: false` | Bot/chat yapılandırması, bağlama kodu ve izinli test sohbetine tek mesaj |
| PWA / offline worker | Geçti | Manifest ve service worker HTTP 200 | Gerçek cihazda kurulum, offline düzenleme, yeniden bağlanma ve kuyruk boşalması |
| API / RSS / MCP | Kimlik doğrulamayla korumalı | API key ve RSS 401; MCP artık proxy üzerinden 401; public sonuç RSS 200 | Yetkili hesapla anahtar oluşturma, kullanma, iptal ve iptal sonrası 401 |
| Zeyilname/sonuç zamanlayıcısı | Gözlemlenebilir | Durum uç noktası 200; iki worker etkin | Kontrollü test kaydıyla olay üretme, tek teslim ve tekrar engeli |
Canlı özet: 13 entegrasyonun 4’ü geçti, 3’ü yapılandırılmış, 2’si kimlik doğrulamayla korumalı, 1’i gözlemlenebilir, 2’si insan doğrulaması bekliyor ve 1’i yapılandırılmadığı için engelli. Korunan 14 uç noktanın 14’ü yetkisiz isteği reddetti.
## Uygulama sırası
1. **Hesap ve erişim kabulü:** Google oturumu ile API anahtarı oluşturma/kullanma/iptal zincirini aynı test hesabında doğrula.
2. **EKAP/KİK ve D10:** Yetkili kullanıcı doğrulamasından sonra beş resmi paketi yükle; her ihale için belge gereksinimi, ürün uygunluğu, firma dosyası, fiyat ve çıktı kapılarını kaydet.
3. **AI/TED:** Yalnız incelenmiş test belgesiyle gerçek sonuçlu TED araması, çeviri, analiz, belgeye soru, taslak ve denetim çıktısını kaynak atıflarıyla karşılaştır.
4. **Bildirimler:** E-posta yapılandırmasını oku; izinli test adresi ve Telegram sohbetinde tek gönderimi, hata kaydı ve tekrar engeliyle doğrula.
5. **Stripe:** Test modunda başarı, ret, iptal ve aynı webhook’un tekrar tesliminde tek abonelik değişikliği oluştuğunu doğrula.
6. **PWA ve zamanlayıcı:** Gerçek cihazı offline yap, bir düzenlemeyi kuyruğa al, yeniden bağlanınca tek kez uygulandığını doğrula; ardından kontrollü zeyilname/sonuç olayını çalıştır.
Her adımda kabul kanıtı; zaman, test hesabı kimliği, kullanılan kayıt/İKN, HTTP sonucu, ekrandaki kullanıcı mesajı ve oluşan veri değişikliğini içermelidir. Başarısız veya yapılandırılmamış entegrasyon tamamlandı olarak işaretlenmez.
## Bu sürümde düzeltilen iki hata
- Nginx, `/mcp` POST isteğini statik dosya gibi ele alıp 405 döndürüyordu. İstek artık Node API’ye proxy ediliyor ve yetkisiz çağrı doğru biçimde 401 dönüyor.
- KİK, OKAS ve ürün aramalarındaki EKAP HTTP 428 yanıtı genel 502’ye çevriliyordu. Yanıt artık `HUMAN_VERIFICATION_REQUIRED`, `retryable: false` ve kullanıcıya uygulanabilir yönlendirme içeriyor.
Makine tarafından üretilen kanıt: [`qa-live-d09-integrations.json`](evidence/qa-live-d09-integrations.json).
+15
View File
@@ -9,6 +9,7 @@ Bu çalışma notu üretim dağıtımında kullanılacak kontrol sırasını tan
- `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri.
- Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri.
- `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler.
- Coolify Advanced ayarında **Include Source Commit in Build** açık olmalıdır. Docker imajı `SOURCE_COMMIT` değerini `/api/version` yanıtındaki `release` alanına taşır; bu değer gizli değildir.
## Compose ile staging smoke testi
@@ -23,6 +24,10 @@ AUTH_SECRET=dummy docker compose -f docker-compose.production.yml down
Yerel compose smoke testi 6 Eylül 2026’da `healthy` duruma ulaştı; bu sonuç production deployment kanıtı değildir.
## Coolify production sağlık kontrolü
Coolify uygulaması `ihaleist:main` için sağlık kontrolü `GET http://localhost:80/api/ready` olarak etkindir; port 80, 10 saniye aralık, 5 saniye zaman aşımı ve 10 deneme kullanılır. Üretim imajında bu kontrolün çalışması için `curl` bulunmalıdır. 6 Eylül 2026 tarihli son rollout `3bfd802` commit'iyle tamamlandı ve uygulama panelinde `running:healthy` olarak doğrulandı. Ardından `npm run qa:live -- https://ihaleist.com` ve `npm run qa:live-five -- https://ihaleist.com` salt okunur olarak başarıyla çalıştırıldı.
## Yayın öncesi
1. `npm ci`, `npm test` ve `npm run build` çalıştırılır; build chunk uyarıları yayın engeli değil, performans backlog’u olarak kaydedilir.
@@ -30,6 +35,16 @@ Yerel compose smoke testi 6 Eylül 2026’da `healthy` duruma ulaştı; bu sonu
3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır.
4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir.
Hedef commit ve yerel frontend asset hash’i verilerek salt okunur canlı kabul çalıştırılır:
```sh
IHALEIST_EXPECTED_RELEASE="$(git rev-parse HEAD)" \
IHALEIST_EXPECTED_ASSET_SHA256="$(npm run -s qa:asset-hash)" \
npm run qa:live -- https://ihaleist.com
```
`qa:asset-hash`, `dist/index.html` içindeki giriş scriptini okuyarak yalnız gerçekten yüklenen ana JavaScript asset’inin hash’ini üretir. Probe `/api/version` release değerini, ana sayfadaki gerçek asset’i ve container içinden bildirilen asset hash’ini birlikte karşılaştırır.
## Kademeli yayın
Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz.
@@ -56,6 +56,7 @@
"printButtonHidden": true,
"overviewStatus": 200,
"overviewFrames": 3,
"documentOwnerInPreparationOverview": true,
"mobileStatus": 200,
"mobileOverflow": {
"viewport": 390,
@@ -0,0 +1,25 @@
{
"checkedAt": "2026-09-06T17:30:38.609Z",
"source": "Coolify API + live read-only probe",
"applicationUuid": "xz6p64cit26n02wzhh0orupy",
"deploymentUuid": "tkairlz3breabienx9pkw3d7",
"giteaMain": "4e9862680b3486cb554789e2d1eb0e552c6f7626",
"deploymentStatus": "finished",
"live": {
"base": "https://ihaleist.com",
"pass": true,
"readyStatus": 200,
"healthStatus": 200,
"missingHeaders": []
},
"bundleVerification": {
"main": "/assets/index-BIrKOH25.js",
"calendar": "/assets/TenderCalendar-TZInjoIh.js",
"strings": [
"Google Takvim (saat gerekli)",
"yenileme sonrası yeniden yüklenir"
]
},
"readOnly": true,
"scope": "No authentication, data writes, payments, messages, or tender submissions were performed."
}
@@ -0,0 +1,224 @@
{
"checkedAt": "2026-09-15T14:58:12.437Z",
"base": "https://ihaleist.com",
"readOnly": true,
"scope": "Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.",
"integrations": [
{
"id": "BIK",
"label": "ilan.gov.tr ihale havuzu",
"status": "passed",
"evidence": {
"status": 200,
"source": "ilan.gov.tr",
"loaded": 3258
},
"remaining": "Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir."
},
{
"id": "TCMB",
"label": "TCMB döviz satış kurları",
"status": "passed",
"evidence": {
"status": 200,
"bulletinDate": "15.09.2026",
"source": "https://www.tcmb.gov.tr/kurlar/today.xml",
"cached": false
},
"remaining": "Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor."
},
{
"id": "GOOGLE",
"label": "Google ile giriş",
"status": "configured",
"evidence": {
"status": 200,
"configured": true
},
"remaining": "Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli."
},
{
"id": "EKAP",
"label": "EKAP arama ve resmi doküman",
"status": "manual-gate",
"evidence": {
"browserAvailable": true,
"okasStatus": 428,
"okasCode": "HUMAN_VERIFICATION_REQUIRED",
"d10DocumentCode": "HUMAN_VERIFICATION_REQUIRED"
},
"remaining": "Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli."
},
{
"id": "KIK",
"label": "KİK kurul kararları",
"status": "manual-gate",
"evidence": {
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"resultCount": null,
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
"remaining": "EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli."
},
{
"id": "TED",
"label": "TED Avrupa ihaleleri",
"status": "passed",
"evidence": {
"status": 200,
"totalCount": 0,
"timedOut": false,
"error": null
},
"remaining": "Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı."
},
{
"id": "AI",
"label": "AI analiz ve teklif yardımcıları",
"status": "configured",
"evidence": {
"status": 200,
"configured": true,
"providerCount": 7
},
"remaining": "Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli."
},
{
"id": "STRIPE",
"label": "Stripe plan, ödeme ve webhook",
"status": "configured",
"evidence": {
"status": 200,
"checkoutConfigured": true,
"webhookConfigured": true,
"planCount": 3
},
"remaining": "Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli."
},
{
"id": "EMAIL",
"label": "E-posta bildirimleri",
"status": "guarded",
"evidence": {
"unauthenticatedStatus": 401
},
"remaining": "Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı."
},
{
"id": "TELEGRAM",
"label": "Telegram bağlantısı ve bildirim",
"status": "blocked",
"evidence": {
"status": 200,
"configured": false
},
"remaining": "Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli."
},
{
"id": "PWA",
"label": "PWA manifest ve offline worker",
"status": "passed",
"evidence": {
"manifestStatus": 200,
"serviceWorkerStatus": 200
},
"remaining": "Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir."
},
{
"id": "API",
"label": "API anahtarı, RSS ve MCP",
"status": "guarded",
"evidence": {
"apiKeyGuard": 401,
"rssGuard": 401,
"mcpGuard": 401,
"publicResultsRssStatus": 200
},
"remaining": "Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli."
},
{
"id": "SCHEDULER",
"label": "Zeyilname/sonuç zamanlayıcısı",
"status": "observable",
"evidence": {
"status": 200,
"browserAvailable": true,
"resultScanActive": true,
"resultScanRunning": false,
"directProcurementActive": true,
"directProcurementRunning": false
},
"remaining": "Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı."
}
],
"guards": {
"authSession": {
"status": 401,
"passed": true
},
"documents": {
"status": 401,
"passed": true
},
"documentText": {
"status": 401,
"passed": true
},
"apiKeys": {
"status": 401,
"passed": true
},
"emailConfig": {
"status": 401,
"passed": true
},
"plan": {
"status": 401,
"passed": true
},
"subscriptions": {
"status": 401,
"passed": true
},
"sourceHistory": {
"status": 401,
"passed": true
},
"companyScan": {
"status": 401,
"passed": true
},
"aiAnalyze": {
"status": 401,
"passed": true
},
"checkout": {
"status": 401,
"passed": true
},
"emailSend": {
"status": 401,
"passed": true
},
"telegramMutation": {
"status": 401,
"passed": true
},
"notificationRun": {
"status": 403,
"passed": true
}
},
"summary": {
"total": 13,
"passed": 4,
"configured": 3,
"guarded": 2,
"observable": 1,
"manualGate": 2,
"blocked": 1,
"protectedGuardsPassed": 14,
"protectedGuardsTotal": 14
}
}
@@ -0,0 +1,644 @@
{
"checkedAt": "2026-09-15T14:45:21.910Z",
"base": "https://ihaleist.com",
"readOnly": true,
"listProbe": {
"status": 200,
"success": true
},
"listCount": 10898,
"selectedCount": 5,
"announcementPassCount": 5,
"officialDocumentPackagePassCount": 0,
"positiveCount": 0,
"pass": false,
"blockers": [
{
"ikn": "2026/1634449",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
{
"ikn": "2026/1676347",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
{
"ikn": "2026/1667534",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
{
"ikn": "2026/1620320",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
{
"ikn": "2026/1636687",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
}
],
"tenders": [
{
"ikn": "2026/1634449",
"scenario": "20-kalem-laboratuvar-cihazi",
"id": "ilan-2219605",
"title": "Laboratuvar cihazı satın alınacaktır",
"institution": "R.T.E. ÜNİVERSİTESİ İDARİ VE MALİ İŞLER DAİRE BAŞKANLIĞI",
"city": "Rize",
"tenderDate": "05.10.2026",
"tenderTime": "10:30",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2219605/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihazi-satin-alinacaktir",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 5592,
"normalizedTextSha256": "17456819e593dca234080014db40aa47532d01b8b095a5e3f0976838d93cd1a1",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "manufacturer-or-authorized-seller",
"label": "İmalatçı/yetkili satıcı/yetkili temsilci belgesi",
"sourceTerm": "Yetkili Satıcı Belgesi",
"present": true
},
{
"key": "service-qualification",
"label": "TSE Hizmet Yeterlilik Belgesi",
"sourceTerm": "TSE Hizmet Yeterlilik Belgesi",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "brand-model",
"label": "Marka-model beyanı",
"sourceTerm": "MARKA - MODEL BEYANI",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1676347",
"scenario": "cihaz-malzeme-yazilim",
"id": "ilan-2218485",
"title": "Laboratuvar cihaz, malzeme ve yazılım mal alım ihalesi",
"institution": "SİVAS BİLİM VE TEKNİK ÜNİVERSİTESİ İDARİ VE MALİ İŞL. DAİRE BAŞK.",
"city": "Sivas",
"tenderDate": "01.10.2026",
"tenderTime": "10:30",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2218485/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihaz-malzeme-ve-yazilim-mal-alim-ihalesi",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 4455,
"normalizedTextSha256": "64902b106e05f9feff268530435498e65a067efc0f51cb665edd9f878d372cd7",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1667534",
"scenario": "yedek-parca-ve-sarf",
"id": "ilan-2217472",
"title": "Laboratuvar cihaz yedek parça ve sarf malzeme satın alınacak",
"institution": "KONYA İL SAĞLIK MÜDÜRLÜĞÜ",
"city": "Konya",
"tenderDate": "28.09.2026",
"tenderTime": "10:30",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2217472/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihaz-yedek-parca-ve-sarf-malzeme-satin-alinacak",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 4897,
"normalizedTextSha256": "d076d3e94136ccfd9e1cd1eb1e98e1bcb600bce20bf7d8ae7d5d3ea0a2f13044",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
},
{
"key": "sample",
"label": "Numune",
"sourceTerm": "Numune sunulması istenmektedir",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1620320",
"scenario": "220-kalem-laboratuvar-sarfi",
"id": "ilan-2214836",
"title": "Laboratuvar sarf malzemeleri satın alınacaktır",
"institution": "ZONGULDAK İL SAĞLIK MÜDÜRLÜĞÜ",
"city": "Zonguldak",
"tenderDate": "01.10.2026",
"tenderTime": "10:00",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2214836/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-sarf-malzemeleri-satin-alinacaktir",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 4891,
"normalizedTextSha256": "3a82b6347d3237ba29c57e211f46febd48ffd3570a7a4d818af04789fd0be5d0",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "scope-exclusion",
"label": "Kapsam Dışı Belgesi",
"sourceTerm": "Kapsam Dışı Belgesi",
"present": true
},
{
"key": "sales-center",
"label": "Satış Merkezi Yetki Belgesi",
"sourceTerm": "Satış Merkezi Yetki Belgesi",
"present": true
},
{
"key": "uts",
"label": "TİTUBB veya ÜTS belgesi",
"sourceTerm": "TİTUBB veya ÜTS belgesi",
"present": true
},
{
"key": "sample",
"label": "Numune",
"sourceTerm": "Numune sunulması istenmektedir",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1636687",
"scenario": "kit-karsiligi-otoanalizor",
"id": "ilan-2211682",
"title": "Kit karşılığı otoanalizör satın alınacak",
"institution": "GAZİ ÜNİVERSİTESİ SAĞLIK ARAŞTIRMA VE UYGULAMA MERKEZİ",
"city": "Ankara",
"tenderDate": "28.09.2026",
"tenderTime": "14:00",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2211682/ihale-duyurulari-mal-alim-ihaleleri-kit-karsiligi-otoanalizor-satin-alinacak",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 5820,
"normalizedTextSha256": "8cbd40ad56a471132a881f1b922739f80c3ad3dea99bd18a556acf7dd3b9975f",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "service-training",
"label": "Teknik Servis Eğitim Sertifikası",
"sourceTerm": "Teknik Servis Eğitim Sertifikası",
"present": true
},
{
"key": "brochure",
"label": "Broşür",
"sourceTerm": "Broşür",
"present": true
},
{
"key": "user-guide",
"label": "Cihaz ve ODS kullanım kılavuzu",
"sourceTerm": "ODS yazılım sistemine ait kullanım klavuzu",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
},
{
"key": "sales-center",
"label": "Satış Merkezi Yetki Belgesi",
"sourceTerm": "Satış Merkezi Yetki Belgesi",
"present": true
},
{
"key": "device-id-cards",
"label": "Cihaz ve sistem tanıtım kimlik kartı",
"sourceTerm": "tanıtım kimlik kartı",
"present": true
},
{
"key": "production-and-age",
"label": "Üretimde olma ve 13 yaş altı kanıtı",
"sourceTerm": "on üç yaşın altında olduğunu belirten belgeler",
"present": true
},
{
"key": "offer-authorization-proof",
"label": "Teklif yetkisi kanıtı",
"sourceTerm": "Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge",
"present": true
},
{
"key": "uts-evidence",
"label": "ÜTS beyanı ve kanıtlayıcı belgeler",
"sourceTerm": "ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"error": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
}
],
"scope": "Canlı liste, ilan metni ve resmi doküman uçları salt okunur denetlendi; giriş, veri değişikliği, ödeme, mesaj veya teklif gönderimi yapılmadı."
}
+93 -32
View File
@@ -1,30 +1,32 @@
{
"base": "https://ihaleist.com",
"readOnly": true,
"checkedAt": "2026-09-06T17:18:22.511Z",
"checkedAt": "2026-09-15T14:45:06.693Z",
"expectedRelease": "e5e1b12185b98a8ddf9e05a88381a5f0e377d60e",
"expectedAssetSha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e",
"checks": {
"home": {
"status": 200,
"contentType": "text/html",
"headers": {
"x-content-type-options": null,
"referrer-policy": null,
"permissions-policy": null,
"x-frame-options": null,
"content-security-policy": null,
"strict-transport-security": null
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
}
},
"health": {
"status": 200,
"contentType": "application/json; charset=utf-8",
"headers": {
"x-content-type-options": null,
"referrer-policy": null,
"permissions-policy": null,
"x-frame-options": null,
"content-security-policy": null,
"strict-transport-security": null
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"json": {
"ok": true
@@ -32,30 +34,89 @@
"apiSuccess": true
},
"ready": {
"status": 404,
"status": 200,
"contentType": "application/json; charset=utf-8",
"headers": {
"x-content-type-options": null,
"referrer-policy": null,
"permissions-policy": null,
"x-frame-options": null,
"content-security-policy": null,
"strict-transport-security": null
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"json": {
"success": false,
"error": "not found"
"ok": true,
"ready": true,
"checks": {
"userData": {
"ok": true
},
"apiSuccess": false
"sharedData": {
"ok": true
}
}
},
"pass": false,
"missingHeaders": [
"x-content-type-options",
"referrer-policy",
"permissions-policy",
"x-frame-options",
"content-security-policy",
"strict-transport-security"
]
"apiSuccess": true
},
"version": {
"status": 200,
"contentType": "application/json; charset=utf-8",
"headers": {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"json": {
"ok": true,
"release": "e5e1b12185b98a8ddf9e05a88381a5f0e377d60e",
"version": "1.0.0",
"frontendAsset": {
"path": "/assets/index-vuTn9Luj.js",
"sha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e",
"bytes": 569810
}
},
"apiSuccess": true
},
"documents": {
"status": 200,
"contentType": "application/json; charset=utf-8",
"headers": {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"json": {
"tarayiciVar": true
},
"apiSuccess": true
},
"frontendAsset": {
"status": 200,
"contentType": "application/javascript",
"headers": {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"path": "/assets/index-vuTn9Luj.js",
"bytes": 569810,
"sha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e"
}
},
"releaseValid": true,
"releaseMatches": true,
"assetMatchesRuntime": true,
"assetMatchesExpected": true,
"pass": true,
"missingHeaders": []
}
+94 -2
View File
@@ -1,8 +1,30 @@
# Düzeltme planı — uygulama durumu
6 Eylül 2026. Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani.
## 15 Eylül 2026 üretim ve D10 güncellemesi
Planın tamamı henüz bitmedi. Canlı yayın yapılmadı.
- Offline işlem kuyruğu ve veri kaybını önleyen tekrar oynatma düzeltmesi GitHub `main` dalındaki `242e7d8` sürümüyle Coolify üzerinden üretime alındı. Canlı sürüm, hazır olma uçları, güvenlik başlıkları ve giriş asset hash'i eşleşti; offline kuyruk kodu üretim paketinde bulundu.
- Tarihi açık beş laboratuvar ihalesi seçildi: `2026/1634449` (Rize), `2026/1676347` (Sivas), `2026/1667534` (Konya), `2026/1620320` (Zonguldak) ve `2026/1636687` (Ankara).
- Beşinin de canlı liste kaydı, ilan metni, tarih/saat, kapsam, teslimat, teklif geçerlilik süresi, kısmi/tam teklif hükmü ve ilan içindeki gerçek belge şartları doğrulandı. Kanıt: [`qa-live-five-tenders.json`](evidence/qa-live-five-tenders.json).
- EKAP resmi doküman paketi beş kayıtta da HTTP 428 insan doğrulaması istedi. Bu nedenle ürün eşleştirme, firma belge kanıtı, fiyat cetveli ve son çıktılar D10 pozitif kabulü olarak işaretlenmedi: ilan aşaması `5/5`, resmi paket `0/5`, tam pozitif zincir `0/5`.
- Uygulamanın bu 428 yanıtını tekrar denenebilir genel 502 hatası gibi göstermesi `e5e1b12` sürümünde düzeltildi ve canlıda doğrulandı. Yeni yanıt `HUMAN_VERIFICATION_REQUIRED`, `retryable: false` ve elle yapılacak adımı içeriyor; belge ekranı kullanıcıyı EKAP doğrulamasına ve indirdiği dosyayı yüklemeye yönlendiriyor.
Bu aşamada D10 tamamlanmış değildir. Kapanış için yetkili kullanıcının EKAP güvenlik doğrulamasından sonra beş resmi doküman paketini indirip uygulamaya yüklemesi ve test firmasının gerçek ürün/belge kayıtlarıyla kalan dört kapıyı geçmesi gerekir. Resmi teklif gönderilmedi.
## 15 Eylül 2026 D09 dış entegrasyon kabulü
- `6c32581` sürümü Coolify üzerinden üretime alındı. Canlı `/api/version` tam commit SHA’sını ve beklenen frontend asset SHA-256 özetini döndürdü; health, ready ve güvenlik başlıkları kabulü geçti.
- Salt okunur probda 13 entegrasyon denetlendi: 4 geçti, 3 yapılandırılmış, 2 kimlik doğrulamayla korumalı, 1 gözlemlenebilir, 2 insan doğrulaması bekliyor ve 1 engelli. Yetkisiz istek kapılarının 14/14’ü doğru şekilde reddedildi.
- BİK, TCMB, TED bağlantısı ve PWA assetleri geçti. Google, AI ve Stripe yapılandırılmış. E-posta ile API/RSS/MCP doğru kimlik doğrulama kapısında. Scheduler durumu okunuyor. EKAP/KİK güvenlik doğrulaması bekliyor. Telegram yapılandırılmamış tek engelli entegrasyondur.
- `/mcp` POST isteğinin Nginx’te 405’e düşmesi düzeltildi; canlıda artık Node kimlik doğrulama katmanından 401 dönüyor. KİK/OKAS/ürün aramasındaki HTTP 428 yanıtının genel 502’ye çevrilmesi de giderildi.
- Test, giriş yapmadı; AI üretimi, ödeme, e-posta/Telegram mesajı veya resmi teklif göndermedi. Yan etkili kapanış adımları yetkili test hesapları ve açık işlem yetkisiyle ayrı ayrı uygulanacaktır.
Ayrıntılı sıra ve kabul ölçütleri: [`d09-entegrasyon-kabul-plani.md`](d09-entegrasyon-kabul-plani.md). Canlı kanıt: [`qa-live-d09-integrations.json`](evidence/qa-live-d09-integrations.json).
## 6 Eylül 2026 başlangıç durumu
Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani. Bu bölüm tarihsel başlangıç kaydıdır.
Planın tamamı o tarihte henüz bitmemişti ve canlı yayın yapılmamıştı.
Canlı read-only probe (6 Eylül 2026) `/api/health` için 200 döndürürken `/api/ready` için 404 döndü; canlı API yanıtında yeni güvenlik başlıkları da yok. Bu, yerel dalın henüz `ihaleist.com` ortamına dağıtılmadığını doğruluyor. Kanıt: [`qa-live-runtime.json`](evidence/qa-live-runtime.json). Probe sırasında giriş, veri değişikliği, ödeme, mesaj veya ihale başvurusu yapılmadı.
@@ -2360,3 +2382,73 @@ Doğrulama: browser QA `icsPersistsAfterReload`, `accountScopedIcs` ve `primaryI
### 2026-09-06 · Production deploy erişim engeli
Yerel doğrulaması tamamlanan dal `e5284a6` commit'inde; Coolify'ın kaynak aldığı Gitea `main` ise `909ebae` commit'inde ve yerel dalın 464 commit gerisinde. Gitea HTTPS push denemesi kullanıcı adı, SSH push denemesi ise public key bulunamadığı için reddedildi. Bu nedenle Coolify henüz yeni imajı oluşturmadı; canlı drift probe'u eski `/api/ready` 404 ve eksik güvenlik başlıklarını göstermeye devam ediyor. Üretim değişikliği için Gitea/Coolify oturumunda kimlik doğrulama ve Redeploy adımı bekleniyor.
### 2026-09-06 · Coolify production deployment tamamlandı
Gitea `main` güncellenerek `4e986268` commit'ine alındı. Coolify `ihaleist` deployment kaydı `tkairlz3breabienx9pkw3d7` `finished` durumuna geçti. Salt okunur canlı probe artık `/api/health` ve `/api/ready` için 200/`ok:true`, altı güvenlik başlığının tamamı ve canlı bundle içinde yeni takvim davranışlarını doğruluyor. Kanıt: [`qa-coolify-deployment-runtime.json`](evidence/qa-coolify-deployment-runtime.json) ve [`qa-live-runtime.json`](evidence/qa-live-runtime.json).
### 2026-09-06 · Canlı beş sağlık ihalesi salt okunur kabulü
Production API’deki beş sağlık/laboratuvar ihalesi (`2026/1582991`, `2026/1534665`, `2026/1499061`, `2026/1573235`, `2026/1523851`) canlı liste, detay ve ilan dokümanı uçlarından salt okunur olarak kontrol edildi. Beşinde de temel alanlar ve tarih/saat döndü; resmî kaynak zamanları sırasıyla 10.09 10:00, 22.09 11:00, 22.09 10:30, 16.09 10:00 ve 11.09 10:00 İstanbul olarak kaydedildi. Aksaray özet ilanındaki 09.09 tarihi ile resmî detaydaki 16.09 farkı korunarak uyarı olarak işlendi. Doküman sayısı ilk üçünde 0, son ikisinde 1; bu nedenle gerçek EKAP dosya indirme ve beş pozitif teklif hazırlığı hâlâ ayrı kabul işidir. Kanıt: [`qa-live-five-tenders.json`](evidence/qa-live-five-tenders.json).
### 2026-09-06 · Tekrarlanabilir canlı beşli probe
D10 canlı metadata kabulü `npm run qa:live-five -- https://ihaleist.com` komutuna taşındı. Komut beş sağlık/laboratuvar İKN’sini canlı listeden bulur; detay ve ilan dokümanı uçlarından tarih/saat, kaynak ve doküman sayılarını doğrular; yalnızca salt okunur GET çağrıları yapar. Son çalıştırmada beş kayıt da bulundu ve resmi tarihler döndü; doküman sayıları 0, 0, 0, 1, 1 olarak kaldı.
### 2026-09-06 · İhale açılışında resmî EKAP tarihi uzlaştırması
İlan.gov.tr listesindeki tarih eski veya saat bilgisi eksik olabildiği için ihale detay modalı açıldığında aynı İKN’nin EKAP ilan/detay kaydı da okunuyor. Başarılı yanıt tarih ve saati modal/kart kaydına taşırken daha zengin ilan metnini koruyor; kaynak erişimi başarısızsa kullanıcıya ilan tarihinin doğrulanamadığı açıkça bildiriliyor. İKN eşleşmesi ve tarih/saat zorunluluğu istemci sınırında doğrulanıyor.
Doğrulama: yeni istemci testi dahil `npm test` 302/302 ve `npm run build` başarılı. Canlı rollout bu değişiklikten sonra ayrıca yapılmalı.
### 2026-09-06 · EKAP tarih uzlaştırmasının canlı kabulü
`a268b15` commit'i Coolify’da `l2vutni2aqziokeqyxf2bug5` deployment kaydıyla `finished` oldu. Canlı bundle’da resmî EKAP tarih uzlaştırma kodu görüldü; `/api/health` ve `/api/ready` 200/`ok:true`, canlı beşli probe ise beş resmi tarih/saat değerini döndürdü. Aksaray için 16.09.2026 10:00 resmi tarih korunuyor. Gerçek kullanıcı oturumuyla modal görsel kabulü ve doküman yükleme hâlâ ayrı kapsamdır.
### 2026-09-06 · Coolify sağlık kontrolünün etkinleştirilmesi
Coolify uygulamasındaki sağlık kontrolü `/api/ready` ve port 80 olarak etkinleştirildi. İlk kontrollü rollout, imajda `curl`/`wget` bulunmadığını ortaya çıkardığı için başarısız oldu; Dockerfile üretim imajına `curl` eklendi. Düzeltme sonrası `lwjqvurn3yluodawgg5kdip3` deployment'ı `finished` oldu, container healthcheck `healthy` ve uygulama durumu `running:healthy` olarak doğrulandı. Canlı `/api/ready` ve beşli ihale probe'u da başarılıdır.
### 2026-09-06 · Canlı probe doküman tarayıcısı doğrulaması
`qa:live` drift probe'una `/api/documents/config` kontrolü eklendi. Böylece üretimde EKAP dokümanları için gereken yerleşik Playwright/Chromium yeteneği `tarayiciVar:true` ile canlı ve salt okunur doğrulanıyor; kontrol başarısızsa servis sağlıklı görünse bile belge fallback'i kabul edilmiyor. 6 Eylül 2026 canlı çalıştırmasında home, health, readiness, güvenlik başlıkları ve doküman tarayıcısı kontrolleri geçti.
### 2026-09-06 · Resmî ihale detay önbelleğinin yenilenmesi
İlan listesi yenilendiğinde daha önce alınmış detay metni süresiz kullanılmıyor. Detay önbelleği 10 dakikalık yaşam süresi ve 500 kayıt sınırıyla çalışıyor; kullanıcı `refresh=1` istediğinde liste kaynağı başarıyla alındıktan sonra ayrıntılar temizleniyor. Böylece zeyilname veya resmî tarih değişikliği eski detayın kartla birleştirilmesine yol açmıyor. Liste henüz hazır değilken detay isteği de artık hata yerine kaynak ayrıntısını alabiliyor.
Doğrulama: `tests/detail-cache.test.mjs` önbellek süresi, açık yenileme ve sınırdan taşan kaydın atılmasını doğruluyor; `npm test` 304/304 başarılı. Üretim rollout ve canlı beşli probe bu değişiklikten sonra ayrıca yapılmalı.
### 2026-09-06 · Detay önbelleği düzeltmesinin üretim kabulü
`4eb9061` commit'i Coolify deployment `6pqycqummdbv27dxprjuh9bv` (kayıt 7103) ile üretime alındı. Coolify uygulaması kısa geçiş kontrolünden sonra `running:healthy` durumuna döndü; healthcheck `/api/ready` port 80 üzerinde etkin. `qa:live` güvenlik başlıkları, readiness ve `tarayiciVar:true` kontrollerini; `qa:live-five` beş resmî ihale tarih/saat ve doküman uçlarını başarılı doğruladı.
### 2026-09-06 · Kısmi AI taslağı için resmî olmayan çıktı etiketi
Teklif Asistanı, modelin tamamlayamadığı bölümleri yalnız “kısmi taslak” ifadesiyle bırakmıyor; ortak `draftReviewLabel` sözleşmesiyle `EKSİK TASLAK — TEKLİF OLARAK KULLANILAMAZ` uyarısını `alert` olarak gösteriyor. Tam görünen taslaklar da kullanıcı kontrolü gerektiren taslak olarak etiketleniyor. Böylece eksik AI çıktısı resmî teklif veya onaylı dosya gibi algılanmıyor.
Doğrulama: `npm test` 305/305, `npm run build` ve `npm run qa:browser` başarılı; etiket ve iki durum için regresyon testi eklendi. Gitea `main` commit’i `63649b2` (runtime değişikliği `c783269`); Coolify rollout için API kimlik doğrulaması ayrıca doğrulanmalı.
### 2026-09-06 · Belge gereksinimlerine sorumlu ataması
Hazırlık ekranındaki her belge gereksinimine, ürün/servis kontrollerindeki sorumlu alanıyla aynı biçimde kişi veya ekip atama alanı eklendi. Sorumlu bilgisi taslak içindeki `checks.documents` kaydında saklanıyor; mevcut kayıtları geriye dönük zorunlu kılmıyor ve belge doğrulama koşullarını gevşetmiyor. Böylece beş dosyada eksik belge ile işi yapacak kişi aynı akışta görülebiliyor.
Doğrulama: `npm test` 305/305, `npm run build` ve `npm run qa:browser` başarılı. Gitea `main`e gönderim ve Coolify rollout kimlik doğrulaması ayrıca bekliyor.
### 2026-09-06 · Hazırlık özetinde belge sorumluları
Belge gereksinimlerine eklenen sorumlu bilgisi hazırlık dosyaları özetine taşındı. Her ihalenin özetinde belge adı ile atanmış kişi/ekip veya `Atanmadı` durumu birlikte görülüyor; eksik belge sayısı ve sıradaki kontrol akışı korunuyor. Atanmamış kayıtlar otomatik tamamlanmış sayılmıyor.
Doğrulama: `npm test` 306/306, `npm run build` ve `npm run qa:browser` başarılı. Gitea `main`e gönderim sonrası Coolify API kimlik doğrulaması yeniden denenmeli.
### 2026-09-06 · Belge sorumlusu tarayıcı kabul kapsamı
Yerel sentetik tarayıcı kabulüne, hazırlık özetinde belge gereksinimiyle atanmış sorumlunun birlikte görünmesi eklendi. `QA Belge Sorumlusu` değeri özet görünümünde doğrulanıyor; kontrol gerçek ihale veya dış sağlayıcı çağrısı yapmıyor. `7e8ef35` commit'i Gitea `main`e gönderildi.
Doğrulama: `npm test` 306/306, `npm run qa:browser` ve `npm run build` başarılı. Coolify deploy çağrısı HTTP 401 `Unauthenticated` döndüğü için bu commit henüz üretim runtime'ında değildir; üretim son başarılı runtime `4eb9061` olarak kalmıştır.
### 2026-09-06 · `05a1411` üretim kabulü
Kullanıcının Coolify panelinden başlattığı `gm8hj07qvefhixmyr57qgooq` deployment'ı sonrasında canlı bundle'ın yerel `dist` çıktısıyla SHA-256 eşleştiği doğrulandı. Canlı bundle'da eksik taslak uyarısı ve belge sorumlusu alanı bulunuyor. `/api/health` ve `/api/ready` 200/`ok:true`, `qa:live` başarılı ve beşli canlı probe beş İKN için tarih/saat döndürüyor.
Sınır: Beş canlı kaydın doküman sayıları sırasıyla 0, 0, 0, 1, 1 olduğundan gerçek EKAP dosya indirme ve beş pozitif teklif hazırlığı henüz tamamlanmış kabul edilmedi.
+25
View File
@@ -41,11 +41,36 @@ server {
proxy_connect_timeout 10s;
}
# MCP JSON-RPC ucu /api öneki taşımaz. Ayrı proxy olmadan Nginx POST'u
# statik dosya isteği sayıp 405 döndürür ve Node'daki API anahtarı kontrolü
# hiç çalışmaz.
location = /mcp {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header Referrer-Policy;
proxy_hide_header Permissions-Policy;
client_max_body_size 1m;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;
}
# Statik varlıklar
location /assets/ {
root /usr/share/nginx/html;
try_files $uri =404;
expires 30d;
# Bu blok kendi add_header direktifini kullandığı için üst seviyedeki
# başlıkları nginx otomatik miras almaz; güvenlik başlıklarını tekrarla.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "public, no-transform";
}
+4 -1
View File
@@ -7,10 +7,13 @@
"dev": "vite",
"build": "vite build",
"qa:browser": "node tests/browser-acceptance.mjs",
"qa:asset-hash": "node scripts/asset-fingerprint.mjs",
"qa:live": "node tests/live-runtime-probe.mjs",
"preview": "vite preview",
"server": "node server/index.js",
"test": "node --test tests/*.test.mjs"
"test": "node --test tests/*.test.mjs",
"qa:live-five": "node tests/live-five-probe.mjs",
"qa:live-integrations": "node tests/live-integrations-probe.mjs"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
+10
View File
@@ -0,0 +1,10 @@
import path from 'node:path';
import { frontendAssetInfo } from '../server/release-info.mjs';
const asset = frontendAssetInfo([path.resolve(process.cwd(), 'dist')]);
if (!asset) {
console.error('dist/index.html içinde yüklenen frontend JavaScript asset’i bulunamadı. Önce npm run build çalıştırın.');
process.exit(1);
}
process.stdout.write(`${asset.sha256}\n`);
+25 -9
View File
@@ -1,16 +1,20 @@
import { analyzeInParts } from './analysis-parts.mjs';
import { qualifyAnalysis } from './analysis-quality.mjs';
import { zaiChat } from './zai.mjs';
/**
* Gerçek AI şartname analizi — OpenRouter öncelikli, çoklu sağlayıcı fallback.
* Gerçek AI şartname analizi — Z.ai (GLM) birincil, çoklu sağlayıcı fallback.
* Bağımlılık yok (Node 18+ built-in fetch).
*
* EN KOLAY KURULUM — tek anahtar, tüm modeller:
* OPENROUTER_API_KEY=sk-or-v1-...
* Bu tanımlıysa zincirdeki tüm modeller OpenRouter üzerinden çağrılır.
* BİRİNCİL KURULUM (2026-09'dan beri):
* ZAI_API_KEY=... GLM 5.3 ve GLM 5.3 Flash doğrudan Z.ai'den
* ZAI_BASE_URL=... Coding Plan ucu (bkz. zai.mjs)
*
* İsteğe bağlı doğrudan sağlayıcı anahtarları (OpenRouter'ı ezer):
* ZAI_API_KEY, DEEPSEEK_API_KEY, GEMINI_API_KEY,
* XAI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY
* YEDEK — tek anahtar, kalan tüm modeller:
* OPENROUTER_API_KEY=sk-or-v1-...
* Doğrudan anahtarı olmayan sağlayıcılar OpenRouter üzerinden çağrılır.
*
* İsteğe bağlı diğer doğrudan anahtarlar (OpenRouter'ı ezer):
* DEEPSEEK_API_KEY, GEMINI_API_KEY, XAI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY
*
* Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL, GLM_OR_MODEL).
*/
@@ -18,11 +22,17 @@ import { qualifyAnalysis } from './analysis-quality.mjs';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export const PROVIDERS = {
zai: {
label: 'GLM 5.3', provider: 'Z.ai', envKey: 'ZAI_API_KEY',
model: () => process.env.GLM_MODEL || 'glm-5.3',
or: () => process.env.GLM_OR_MODEL || '~z-ai/glm-latest',
style: 'zai',
},
glmflash: {
label: 'GLM 5.3 Flash', provider: 'Z.ai', envKey: 'ZAI_API_KEY',
model: () => process.env.GLM_FLASH_MODEL || 'glm-5.3-flash',
or: () => process.env.GLM_FLASH_OR_MODEL || 'z-ai/glm-5.3-flash',
url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai',
style: 'zai',
},
gemini: {
label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY',
@@ -331,7 +341,7 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
// modeller (ör. ücretsiz/limitli olanlar) tıkanınca analiz boş kalmasın;
// yalnız üsttekilerin hepsi başarısız olursa devreye girer.
const YEDEK = 'gemini';
const secim = (Array.isArray(chain) && chain.length ? chain : ['glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude'])
const secim = (Array.isArray(chain) && chain.length ? chain : ['zai', 'glmflash', 'gemini', 'deepseek', 'grok', 'gpt', 'claude'])
.filter((id) => PROVIDERS[id]);
const order = secim.includes(YEDEK) ? secim : [...secim, YEDEK];
// Derin analiz hem daha çok metin okur hem daha uzun JSON üretir; çıktı
@@ -358,6 +368,12 @@ export async function analyzeSpec({ text, fileName, tender, chain, systemOverrid
let raw;
if (route.yol === 'openrouter') {
raw = await callOpenRouter(route.model, route.key, userPrompt, ac.signal, sistem, maxTokens);
} else if (p.style === 'zai') {
// Kullanıcı bekliyor: eşzamanlılık sırasında arka plan çevirisinin önüne geçer.
raw = await zaiChat(route.model, [
{ role: 'system', content: sistem },
{ role: 'user', content: userPrompt },
], { signal: ac.signal, maxTokens, temperature: 0.2, json: true, oncelik: 'yuksek' });
} else if (p.style === 'gemini') {
raw = await callGemini(p, route.key, route.model, userPrompt, ac.signal, sistem, maxTokens);
} else if (p.style === 'anthropic') {
+30
View File
@@ -0,0 +1,30 @@
/**
* Resmî ihale detayları için süreli, sınırlı bellek önbelleği.
*
* Liste yenilense bile detay metni ayrı bir kaynaktan geldiği için sınırsız
* süre saklanamaz; zeyilname veya tarih değişikliği eski ayrıntıyı geçersiz
* kılmalıdır. Saat bağımlılığını test edebilmek için now parametresi açıktır.
*/
export function createDetailCache({ maxEntries = 500, ttlMs = 10 * 60 * 1000 } = {}) {
if (!Number.isInteger(maxEntries) || maxEntries < 1) throw new Error('Detay önbelleği sınırı geçersiz.');
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('Detay önbelleği süresi geçersiz.');
const entries = new Map();
return {
get(key, now = Date.now()) {
const entry = entries.get(key);
if (!entry) return undefined;
if (now - entry.fetchedAt >= ttlMs) {
entries.delete(key);
return undefined;
}
return entry.value;
},
set(key, value, now = Date.now()) {
entries.delete(key);
entries.set(key, { value, fetchedAt: now });
while (entries.size > maxEntries) entries.delete(entries.keys().next().value);
},
clear() { entries.clear(); },
get size() { return entries.size; },
};
}
+53 -18
View File
@@ -64,6 +64,9 @@ import { belgeyeSor, teklifDenetle, teklifTaslak, ihaleMetni, gereklilikMetni }
import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolveDelivery, available as notifyAvailable } from './notify-store.mjs';
import { bildirimleriIsle } from './notifier.mjs';
import { scanSite } from './site-scan.mjs';
import { createDetailCache } from './detail-cache.mjs';
import { releaseInfo } from './release-info.mjs';
import { sourceFailure } from './source-error.mjs';
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
@@ -74,6 +77,7 @@ const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA];
const PORT = process.env.PORT || 3001;
const ILAN_BASE = 'https://www.ilan.gov.tr';
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
const RUNTIME_RELEASE = releaseInfo();
function readiness() {
const directories = {
@@ -336,7 +340,7 @@ function htmlToText(htmlStr) {
.trim();
}
const cache = { tenders: null, fetchedAt: 0, totalCount: 0, todayCount: 0, detail: new Map() };
const cache = { tenders: null, fetchedAt: 0, totalCount: 0, todayCount: 0, detail: createDetailCache() };
let arkaPlanDoluyor = false;
@@ -596,6 +600,9 @@ async function getTenders(force = false) {
if (fresh && !force) return cache;
const ilk = await ilanSayfalari(0, ILK_PARTI);
// Listeyi zorla yenileyen kullanıcı güncel kaynak ayrıntısını da görmeli;
// eski detay metni yeni kartla birleştirilmemeli.
if (force) cache.detail.clear();
const numFound = ilk.find((r) => r.numFound)?.numFound || 0;
const ads = ilk.flatMap((r) => r.ads || []);
@@ -624,17 +631,17 @@ async function getTenders(force = false) {
}
async function getDetail(ilanId) {
if (cache.detail.has(ilanId)) return cache.detail.get(ilanId);
const cached = cache.detail.get(ilanId);
if (cached) return cached;
const raw = await ilanDetail(ilanId);
const text = htmlToText(raw.content || '');
const detail = { ...announcementFields(text), adNo: raw.adNo || null };
const sourceTender=cache.tenders.find(t=>t.id===`ilan-${ilanId}`);
const sourceTender=(cache.tenders || []).find(t=>t.id===`ilan-${ilanId}`);
if(sourceTender) {
try { observeTenders([{...sourceTender,...detail,id:`ilan-${ilanId}:detail`}]); }
catch { detail.sourceWarning='İlan alındı ancak kaynak geçmişi kaydedilemedi.'; }
}
cache.detail.set(ilanId, detail);
if (cache.detail.size > 500) cache.detail.delete(cache.detail.keys().next().value);
return detail;
}
@@ -657,13 +664,6 @@ function json(res, code, body) {
res.end(JSON.stringify(body));
}
const sourceUnavailable = error => ({
success: false,
error: error?.message || 'Resmi belge kaynağına ulaşılamadı.',
code: 'SOURCE_UNAVAILABLE',
retryable: true,
});
function readRaw(req, limit = 1024 * 1024) {
return new Promise((resolve, reject) => {
let buf = '';
@@ -836,7 +836,8 @@ const server = http.createServer(async (req, res) => {
const out = await documentUrl(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, sourceUnavailable(e));
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// İlan metni + kalemler — analiz için (tarayıcı gerektirmez)
@@ -848,7 +849,8 @@ const server = http.createServer(async (req, res) => {
const parsed = mergeAnnouncementFields(out.metin, out);
return json(res, 200, { success: true, ...out, ...parsed, tarih: out.tarih || parsed.tarih || '', saat: out.saat || parsed.saat || '' });
} catch (e) {
return json(res, 502, sourceUnavailable(e));
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// Doküman metni — sunucu tarafı tarayıcı gerektirir
@@ -861,7 +863,8 @@ const server = http.createServer(async (req, res) => {
const out = await documentText(ikn);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, sourceUnavailable(e));
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
if (url.pathname === '/api/documents/config') {
@@ -939,7 +942,8 @@ const server = http.createServer(async (req, res) => {
if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için
@@ -954,7 +958,8 @@ const server = http.createServer(async (req, res) => {
urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler });
return json(res, 200, { success: true, kalemler });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
@@ -1029,6 +1034,33 @@ const server = http.createServer(async (req, res) => {
const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]);
return json(res, 200, { success: true, zeyilname: z, sonuc: so });
}
// --- Abone Takip Paneli Entegrasyonu (Signups & Subscribers) ---
if ((url.pathname === '/api/admin/signups' || url.pathname === '/api/admin/subscribers') && req.method === 'GET') {
const authHeader = req.headers['authorization'] || '';
const apiKeyHeader = req.headers['x-api-key'] || req.headers['x-webhook-secret'] || '';
const expectedKey = process.env.ABONE_TAKIP_SECRET || 'abone_takip_master_token_2026';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : apiKeyHeader.trim();
if (!token || (token !== expectedKey)) {
return json(res, 401, { success: false, error: 'Yetkisiz erişim' });
}
const users = store.withDb ? store.withDb((db) => db.prepare('SELECT email, ad, plan, olusturma, son_giris FROM kullanicilar').all(), []) : [];
const data = users.map(u => ({
email: u.email,
name: u.ad || null,
plan: u.plan || 'ucretsiz',
status: 'active',
is_paid: (u.plan && u.plan !== 'ucretsiz') ? 1 : 0,
payment_amount: (u.plan && u.plan !== 'ucretsiz') ? 499 : 0,
currency: 'TRY',
registered_at: u.olusturma || new Date().toISOString(),
last_login_at: u.son_giris || null
}));
return json(res, 200, { success: true, data });
}
if (url.pathname === '/api/health/kalem') {
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
}
@@ -1050,7 +1082,8 @@ const server = http.createServer(async (req, res) => {
);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
if (url.pathname === '/api/kik/karar') {
@@ -1060,7 +1093,8 @@ const server = http.createServer(async (req, res) => {
const metin = await kararMetni(id);
return json(res, 200, { success: true, id, metin });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
@@ -1728,6 +1762,7 @@ const server = http.createServer(async (req, res) => {
}
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
if (url.pathname === '/api/version') return json(res, 200, { ok: true, ...RUNTIME_RELEASE });
if (url.pathname === '/api/ready') {
const status = readiness();
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
+50
View File
@@ -0,0 +1,50 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_ASSET_ROOTS = [
'/usr/share/nginx/html',
path.resolve(process.cwd(), 'dist'),
];
function cleanRelease(value) {
const release = String(value || '').trim();
if (!release || release === 'unknown' || release.length > 128) return null;
return release;
}
export function deployedRelease(env = process.env) {
return cleanRelease(env.APP_RELEASE) || cleanRelease(env.SOURCE_COMMIT);
}
export function frontendAssetInfo(roots = DEFAULT_ASSET_ROOTS) {
for (const root of roots) {
try {
const indexPath = path.join(root, 'index.html');
const html = fs.readFileSync(indexPath, 'utf8');
const match = html.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
if (!match) continue;
const publicPath = new URL(match[1], 'https://ihaleist.invalid/').pathname;
const assetPath = path.resolve(root, `.${publicPath}`);
const rootPath = path.resolve(root) + path.sep;
if (!assetPath.startsWith(rootPath)) continue;
const bytes = fs.readFileSync(assetPath);
return {
path: publicPath,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
bytes: bytes.length,
};
} catch {
// Bu kökte derlenmiş arayüz yoksa sıradaki olası köke bakılır.
}
}
return null;
}
export function releaseInfo({ env = process.env, roots = DEFAULT_ASSET_ROOTS } = {}) {
return {
release: deployedRelease(env),
version: '1.0.0',
frontendAsset: frontendAssetInfo(roots),
};
}
+1 -1
View File
@@ -92,7 +92,7 @@ KURALLAR:
*/
export async function scanSite(inputUrl) {
if (!aiConfigured()) {
throw new Error('Site analizi için AI yapılandırılmamış (OPENROUTER_API_KEY gerekli).');
throw new Error('Site analizi için AI yapılandırılmamış (ZAI_API_KEY veya OPENROUTER_API_KEY gerekli).');
}
const url = normalizeUrl(inputUrl);
const ana = await fetchPage(url.href);
+31
View File
@@ -0,0 +1,31 @@
const MANUAL_EKAP_URL = 'https://ekap.kik.gov.tr/EKAP/';
export function sourceFailure(error) {
const rawMessage = error?.message || 'Resmi belge kaynağına ulaşılamadı.';
const humanVerification = error?.code === 'HUMAN_VERIFICATION_REQUIRED'
|| /HUMAN_VERIFICATION_REQUIRED|(?:EKAP|KİK)[^\n]{0,120}(?:HTTP\s*)?428|HTTP\s*428/i.test(rawMessage);
if (humanVerification) {
return {
status: 428,
body: {
success: false,
error: 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin.',
code: 'HUMAN_VERIFICATION_REQUIRED',
retryable: false,
manualAction: true,
manualUrl: MANUAL_EKAP_URL,
},
};
}
return {
status: 502,
body: {
success: false,
error: rawMessage,
code: 'SOURCE_UNAVAILABLE',
retryable: true,
},
};
}
+67 -27
View File
@@ -1,7 +1,8 @@
/**
* TED ilan başlık/açıklamalarının Türkçesi.
* 1) TED çok dilli alanında Türkçe varsa (tur) doğrudan o kullanılır — model gerekmez.
* 2) Yoksa OpenRouter ile toplu çevrilir (bir sayfa = tek istek), sonuç
* 2) Yoksa model ile toplu çevrilir (6 ilan = tek istek): önce Z.ai GLM
* (ZAI_TRANSLATE_MODELS), o da olmazsa OpenRouter. Sonuç
* server/data/ted.sqlite'ta ilan numarasına göre saklanır; ikinci görüntüleme ücretsizdir.
* Çeviri başarısız olursa özgün metin döner; liste asla bekletilmez (üst sınır ~40 sn).
*/
@@ -9,6 +10,7 @@ import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { openRouterKey } from './ai.mjs';
import { zaiKey, zaiChat, zaiTranslateModels } from './zai.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, 'data');
@@ -44,33 +46,69 @@ function yaz(ikn, baslik_tr, aciklama_tr, kaynak) {
else bellek.set(ikn, { baslik_tr, aciklama_tr });
}
// Ölçüm (6 ilan): deepseek-v4-flash 10 sn/851 token; glm-5.3-flash 71 sn boyunca
// 3000 "düşünme" tokenı tüketip boş dönüyor; gemini-flash-latest OpenRouter'da
// geçersiz kimlik. Bu yüzden sıra: deepseek → gemini 3.7 flash → glm.
const MODELLER = ['deepseek/deepseek-v4-flash', 'google/gemini-3.7-flash', 'z-ai/glm-5.3-flash'];
const SISTEM = 'Sen kamu ihale terminolojisine hâkim bir çevirmensin. Verilen AB (TED) ihale başlık ve açıklamalarını doğal, resmî Türkçeye çevir. Başlık "Ülke – Konu – İlan adı" biçimindedir: ülke adını da Türkçeleştir (Austria→Avusturya). Kurum adlarını çevirme, sayıları/tarihleri aynen koru. Yalnızca JSON döndür: {"ceviriler":[{"id":"...","baslik":"...","aciklama":"..."}]} — id alanını aynen kopyala; açıklama yoksa boş string.';
// OpenRouter yedek sırası (yalnız Z.ai başarısız olursa ve OPENROUTER_API_KEY
// varsa). Ölçüm (6 ilan, OpenRouter): deepseek-v4-flash 10 sn/851 token;
// glm-5.3-flash OpenRouter üzerinden 71 sn düşünüp boş dönüyor (doğrudan
// Z.ai'de reasoning_effort:low ile bu sorun yok, bkz. zai.mjs).
const OR_MODELLER = ['deepseek/deepseek-v4-flash', 'google/gemini-3.7-flash', 'z-ai/glm-5.3-flash'];
const SISTEM = 'Sen kamu ihale terminolojisine hâkim bir çevirmensin. Verilen AB (TED) ihale başlık ve açıklamalarını doğal, resmî Türkçeye çevir. Başlık "Ülke – Konu – İlan adı" biçimindedir: ülke adını da Türkçeleştir (Austria→Avusturya). Kurum adlarını çevirme, sayıları/tarihleri aynen koru. Türkçe yazım kurallarına uy: kelime içinde büyük İ veya I kullanma (doğru: "elektrikli otobüsler", yanlış: "Elektrİklİ"), yalnız cümle başı ve özel adlar büyük harfle başlar, i/ı ayrımını koru (Türkiye, temini, istasyonları). Yalnızca JSON döndür: {"ceviriler":[{"id":"...","baslik":"...","aciklama":"..."}]} — id alanını aynen kopyala; açıklama yoksa boş string.';
async function modeldenCevir(kalemler, signal) {
const key = openRouterKey();
if (!key) throw new Error('OPENROUTER_API_KEY yok');
const girdi = JSON.stringify({ ilanlar: kalemler.map((k) => ({ id: k.id, baslik: k.baslik, aciklama: (k.aciklama || '').slice(0, 600) })) });
let sonHata = null;
for (const model of MODELLER) {
try {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', signal,
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://ihaleist.com', 'X-Title': 'ihaleist' },
body: JSON.stringify({ model, temperature: 0.1, max_tokens: 2500, response_format: { type: 'json_object' },
messages: [{ role: 'system', content: SISTEM }, { role: 'user', content: girdi }] }),
/**
* GLM çıktısında görülen harf bozulmasını onarır (2026-09-11 canlı ölçümü:
* 22 başlığın 4'ünde "Arazİ araçları", "Elektrİklİ", "temİNİ" gibi kelime
* içi büyük İ ve büyük harfe dönen kuyruklar). Türkçede bir kelime küçük
* harfle devam ettikten sonra büyük harfe geri dönmez; bu yüzden ilk küçük
* harften sonraki büyükler Türkçe kuralıyla (İ→i, I→ı) küçültülür. Tamamı
* büyük kısaltmalara (NATURA, MEB) dokunmaz.
*/
export function harfOnar(metin) {
return String(metin || '').replace(/\p{L}+/gu, (kelime) => {
const ilkKucuk = kelime.search(/\p{Ll}/u);
if (ilkKucuk === -1) return kelime;
const kuyruk = kelime.slice(ilkKucuk + 1);
if (!/\p{Lu}/u.test(kuyruk)) return kelime;
return kelime.slice(0, ilkKucuk + 1) + kuyruk.toLocaleLowerCase('tr');
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const icerik = data?.choices?.[0]?.message?.content || '';
}
/** Çeviri yapılabilir mi: Z.ai ya da OpenRouter anahtarından biri yeter. */
export const ceviriMumkun = () => Boolean(zaiKey() || openRouterKey());
function cevirileriAyikla(icerik) {
const s = icerik.slice(icerik.indexOf('{'), icerik.lastIndexOf('}') + 1);
const out = JSON.parse(s);
if (!Array.isArray(out?.ceviriler)) throw new Error('beklenen JSON değil');
return out.ceviriler;
} catch (e) { sonHata = e; }
}
async function openRouterdanCevir(model, key, mesajlar, signal) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', signal,
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://ihaleist.com', 'X-Title': 'ihaleist' },
body: JSON.stringify({ model, temperature: 0.1, max_tokens: 2500, response_format: { type: 'json_object' }, messages: mesajlar }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return data?.choices?.[0]?.message?.content || '';
}
/** Sırayla dener: Z.ai modelleri → OpenRouter modelleri. İlk geçerli JSON döner. */
async function modeldenCevir(kalemler, signal) {
if (!ceviriMumkun()) throw new Error('ZAI_API_KEY veya OPENROUTER_API_KEY yok');
const girdi = JSON.stringify({ ilanlar: kalemler.map((k) => ({ id: k.id, baslik: k.baslik, aciklama: (k.aciklama || '').slice(0, 600) })) });
const mesajlar = [{ role: 'system', content: SISTEM }, { role: 'user', content: girdi }];
const adimlar = [];
if (zaiKey()) for (const model of zaiTranslateModels()) {
// Arka plan işi: kullanıcının beklediği analiz istekleri sırada öne geçer.
adimlar.push(() => zaiChat(model, mesajlar, { signal, temperature: 0.1, maxTokens: 2500, json: true, oncelik: 'dusuk' }));
}
if (openRouterKey()) for (const model of OR_MODELLER) {
adimlar.push(() => openRouterdanCevir(model, openRouterKey(), mesajlar, signal));
}
let sonHata = null;
for (const adim of adimlar) {
signal?.throwIfAborted();
try { return cevirileriAyikla(await adim()); } catch (e) { if (e?.name === 'AbortError') throw e; sonHata = e; }
}
throw sonHata || new Error('çeviri başarısız');
}
@@ -86,7 +124,7 @@ async function parcayiCevir(parca) {
for (const t of parca) {
const x = map.get(String(t.id));
if (!x || !x.baslik) continue;
t.baslikTr = String(x.baslik).trim(); t.aciklamaTr = x.aciklama ? String(x.aciklama).trim() : null; t.ceviriKaynagi = 'model';
t.baslikTr = harfOnar(String(x.baslik).trim()); t.aciklamaTr = x.aciklama ? harfOnar(String(x.aciklama).trim()) : null; t.ceviriKaynagi = 'model';
if (t.ikn) yaz(t.ikn, t.baslikTr, t.aciklamaTr, 'model');
}
} finally {
@@ -108,15 +146,17 @@ export async function tedCevir(tenders, { blockMs = 12000 } = {}) {
if (t.baslikTr) continue;
if (t.baslikTur) { t.baslikTr = t.baslikTur; t.aciklamaTr = t.aciklamaTur || null; t.ceviriKaynagi = 'ted'; continue; }
const c = t.ikn ? oku(t.ikn) : null;
if (c && c.baslik_tr) { t.baslikTr = c.baslik_tr; t.aciklamaTr = c.aciklama_tr || null; t.ceviriKaynagi = 'onbellek'; continue; }
// harfOnar: düzeltme öncesi önbelleğe girmiş bozuk başlıklar da okunurken onarılır.
if (c && c.baslik_tr) { t.baslikTr = harfOnar(c.baslik_tr); t.aciklamaTr = c.aciklama_tr ? harfOnar(c.aciklama_tr) : null; t.ceviriKaynagi = 'onbellek'; continue; }
if (!devamEden.has(t.ikn)) bekleyen.push(t);
}
if (!openRouterKey()) return 0;
if (!ceviriMumkun()) return 0;
if (!bekleyen.length) return tenders.filter((t) => !t.baslikTr).length;
for (const t of bekleyen) devamEden.add(t.ikn);
const parcalar = [];
for (let i = 0; i < bekleyen.length; i += 6) parcalar.push(bekleyen.slice(i, i + 6));
// Parçalar 3'lü eşzamanlı işlenir: 30 ilanlık liste ~5 yerine ~2 model turunda biter.
// Parçalar 2'li eşzamanlı işlenir: Z.ai Coding Plan aynı anda en fazla 2
// isteğe izin veriyor; fazlası zaten zai.mjs'teki sınırda beklerdi.
const is = (async () => {
const kuyruk = [...parcalar];
const isci = async () => {
@@ -125,7 +165,7 @@ export async function tedCevir(tenders, { blockMs = 12000 } = {}) {
try { await parcayiCevir(parca); } catch (e) { console.warn('[ted-ceviri] parça atlandı:', e.message); }
}
};
await Promise.all(Array.from({ length: Math.min(3, parcalar.length) }, isci));
await Promise.all(Array.from({ length: Math.min(2, parcalar.length) }, isci));
})();
await Promise.race([is, new Promise((r) => setTimeout(r, blockMs))]);
return tenders.filter((t) => !t.baslikTr).length;
+141
View File
@@ -0,0 +1,141 @@
/**
* Z.ai (GLM) doğrudan istemcisi — uygulamanın BİRİNCİL yapay zekâ sağlayıcısı.
*
* Neden: 2026-09'da OpenRouter anahtarı iptal olup hesap bakiyesi eksiye
* düşünce şartname analizi, TED çevirisi ve site taraması durdu. Z.ai'nin
* kendi API'si (Coding Plan aboneliği) ayrı faturalanır; OpenRouter'daki bir
* sorun artık uygulamayı durdurmaz. OpenRouter yalnız yedek olarak kalır.
*
* Ortam değişkenleri:
* ZAI_API_KEY zorunlu (yoksa Z.ai devre dışı)
* ZAI_BASE_URL varsayılan genel API; Coding Plan için
* https://api.z.ai/api/coding/paas/v4/chat/completions
* ZAI_REASONING_EFFORT glm-5.x düşünme seviyesi (varsayılan low)
* ZAI_MAX_CONCURRENCY eşzamanlı istek sınırı (varsayılan 2)
* ZAI_RETRIES_429 oran sınırında aynı modelle yeniden deneme (varsayılan 4)
* ZAI_RETRY_BASE_MS yeniden deneme taban beklemesi (varsayılan 3000)
* ZAI_TRANSLATE_MODELS çeviri model listesi (virgülle; varsayılan glm-5.3,glm-5.3-flash)
*/
const VARSAYILAN_URL = 'https://api.z.ai/api/paas/v4/chat/completions';
// TED başlıkları kullanıcıya doğrudan görünür ve paketler küçüktür (6 ilan);
// canlıda glm-5.3-flash başlıkların ~%20'sinde harf bozdu (kelime içi İ),
// bu yüzden önce GLM 5.3, Flash yedek.
export const DEFAULT_ZAI_TRANSLATE_MODELS = ['glm-5.3', 'glm-5.3-flash'];
/** Anahtar çağrı anında okunur; süreç yeniden başlatılmadan da ayar değişebilir. */
export const zaiKey = () => (process.env.ZAI_API_KEY || '').trim() || null;
export const zaiUrl = () => (process.env.ZAI_BASE_URL || '').trim() || VARSAYILAN_URL;
export function zaiTranslateModels() {
const liste = (process.env.ZAI_TRANSLATE_MODELS || '').split(',').map((m) => m.trim()).filter(Boolean);
return liste.length ? liste : DEFAULT_ZAI_TRANSLATE_MODELS;
}
/**
* Düşünme (reasoning) ayarı modele göre değişir — 2026-09-10 canlı ölçümü:
* - glm-4.x: `thinking: {type:"disabled"}` kabul edilir.
* - glm-5.x: düşünme KAPATILAMAZ (hata 1210). `reasoning_effort: "low"` ile
* 0 düşünme tokenı ve kusursuz kaliteyle, varsayılan ayarın ~5 katı hızda
* çalışır. null → sağlayıcı varsayılanı (en yavaş).
*/
export function dusunmeAyari(model, effort) {
if (/^glm-5/i.test(model)) {
if (effort === null) return {};
return { reasoning_effort: effort || (process.env.ZAI_REASONING_EFFORT || '').trim() || 'low' };
}
return { thinking: { type: 'disabled' } };
}
/**
* Z.ai 429 yanıtını ikiye ayırır: 1113 (bakiye/abonelik yetersiz) beklemekle
* düzelmez; 1302/1305 (oran sınırı / sunucu yoğun) kısa beklemeyle geçebilir.
*/
export const gecici429 = (govde) => !/"code"\s*:\s*"?1113"?/.test(String(govde || ''));
/**
* Süreç genelinde eşzamanlı Z.ai isteği sınırı. Coding Plan aynı anda en
* fazla 2 isteğe izin veriyor (3. istek 1302 alıyor). TED çevirisi, analiz ve
* site taraması aynı anda çalışabildiği için sınır tek noktada uygulanır.
* Öncelik: boşalan yer önce kullanıcının beklediği isteklere ("yuksek")
* verilir; arka plan çevirisi ("dusuk") en fazla bir istek süresi bekler.
*/
let aktifIstek = 0;
const kuyruk = { yuksek: [], dusuk: [] };
const eszamanliSinir = () => Math.max(1, Number(process.env.ZAI_MAX_CONCURRENCY ?? 2) || 2);
async function yerAl(oncelik) {
if (aktifIstek < eszamanliSinir()) { aktifIstek++; return; }
await new Promise((r) => (kuyruk[oncelik] || kuyruk.dusuk).push(r));
}
function yerBirak() {
const siradaki = kuyruk.yuksek.shift() ?? kuyruk.dusuk.shift();
if (siradaki) siradaki(); // yer doğrudan sıradakine devredilir
else aktifIstek = Math.max(0, aktifIstek - 1);
}
/** Test ve gözlem için: o an açık istek sayısı. */
export const zaiAktifIstek = () => aktifIstek;
export class ZaiError extends Error {
constructor(message, status) { super(message); this.name = 'ZaiError'; this.status = status; }
}
const bekle = (ms, signal) => new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
const t = setTimeout(() => { signal?.removeEventListener('abort', iptal); resolve(); }, ms);
const iptal = () => { clearTimeout(t); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); };
signal?.addEventListener('abort', iptal, { once: true });
});
/**
* Tek bir modele istek atar, yanıt metnini döndürür.
* - Geçici 429'da artan beklemeyle AYNI modelle yeniden dener; 1113'te
* beklemeden hata fırlatır (çağıran sıradaki modele geçer).
* - Yer yalnız HTTP isteği sürerken tutulur; 429 beklemesinde bırakılır.
* - `signal` iptal edilirse sırada beklerken bile istek atılmaz.
*/
export async function zaiChat(model, messages, opts = {}) {
const {
temperature = 0.2, json = true, maxTokens = 4000, signal, reasoningEffort,
oncelik = 'yuksek',
retries429 = Math.max(0, Number(process.env.ZAI_RETRIES_429 ?? 4) || 0),
} = opts;
const key = zaiKey();
if (!key) throw new ZaiError('ZAI_API_KEY yok');
for (let deneme = 0; ; deneme++) {
signal?.throwIfAborted();
let durum = 0, govde = '';
await yerAl(oncelik);
try {
signal?.throwIfAborted();
const res = await fetch(zaiUrl(), {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model, messages, temperature, max_tokens: maxTokens, stream: false,
...dusunmeAyari(model, reasoningEffort),
...(json ? { response_format: { type: 'json_object' } } : {}),
}),
signal,
});
durum = res.status;
govde = await res.text();
} finally {
yerBirak();
}
if (durum < 200 || durum >= 300) {
const kisa = govde.slice(0, 200);
if (durum === 429 && deneme < retries429 && gecici429(kisa)) {
const taban = Math.max(0, Number(process.env.ZAI_RETRY_BASE_MS ?? 3000) || 0);
await bekle(taban * (deneme + 1) + Math.floor(Math.random() * (taban / 3)), signal);
continue;
}
throw new ZaiError(`${model}: HTTP ${durum} ${kisa}`, durum);
}
let mesaj;
try { mesaj = JSON.parse(govde)?.choices?.[0]?.message; } catch { throw new ZaiError(`${model}: yanıt JSON değil`); }
const icerik = typeof mesaj?.content === 'string' ? mesaj.content : '';
if (!icerik.trim()) throw new ZaiError(`${model}: boş içerik`);
return icerik;
}
}
+11 -7
View File
@@ -38,7 +38,7 @@ const firmaAnahtari = email => `${FIRMA_ANAHTAR}:${encodeURIComponent(String(ema
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus }) => {
export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus, now = null }) => {
const { userProfile, mutateTender, savingCount, authUser, tenders } = useApp();
const tender = resolveCurrentTender(selectedTender,tenders);
const storedDraft = tender?.bidDraft || {};
@@ -148,9 +148,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
const turCeliskisi = !['birim','goturu'].includes(teklifTuru) || kaynakTeklifTuru.status === 'conflict' || (kaynakTeklifTuru.type && teklifTuru !== kaynakTeklifTuru.type);
const sureEksik = !Number.isInteger(Number(gecerlilikGun)) || !(Number(gecerlilikGun) > 0) || kaynakGecerlilik.status === 'conflict' || (kaynakGecerlilik.days && Number(gecerlilikGun) < kaynakGecerlilik.days);
const [deadlineNow,setDeadlineNow] = useState(() => new Date());
useEffect(() => {const timer=setInterval(()=>setDeadlineNow(new Date()),30000);return ()=>clearInterval(timer);},[]);
const deadlineProblem = preparationDeadlineProblem(tender,deadlineNow);
const [deadlineNow,setDeadlineNow] = useState(() => now || new Date());
useEffect(() => {
if (now) { setDeadlineNow(now); return undefined; }
const timer=setInterval(()=>setDeadlineNow(new Date()),30000);
return ()=>clearInterval(timer);
},[now]);
const deadlineProblem = preparationDeadlineProblem(tender,now || deadlineNow);
const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,healthPortfolio:userProfile.scrapedCapabilities?.healthPortfolio || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn});
const analizEksik = analysisStatusBlocksPreparation(effectiveResults.sourceReview?.status);
const hazir = !deadlineProblem && eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection;
@@ -189,7 +193,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
const session = getToken();
setHata(null);
try {
const initialDeadlineProblem=preparationDeadlineProblem(tender);
const initialDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
if(initialDeadlineProblem)throw new Error(initialDeadlineProblem);
if (!hazir) throw new Error('Kalem, miktar, birim, fiyat, firma ve geçerlilik süresindeki eksikleri giderin.');
// Open during the click; async validation must not lose popup activation.
@@ -206,7 +210,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
setVault(currentVault);
const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault);
if (fileErrors.length) throw new Error(fileErrors.join(' '));
const currentDeadlineProblem=preparationDeadlineProblem(tender);
const currentDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
if(currentDeadlineProblem)throw new Error(currentDeadlineProblem);
const f = formVerisi();
if (tip === 'excel') {
@@ -219,7 +223,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
setVault(finalVault);
const finalErrors=preparationBlockers(lotSelection.rows,documents,checks,finalVault);
if(finalErrors.length)throw new Error(finalErrors.join(' '));
const finalDeadlineProblem=preparationDeadlineProblem(tender);
const finalDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
if(finalDeadlineProblem)throw new Error(finalDeadlineProblem);
downloadBidWorkbook(buffer,tender.ikn);
printWindow.current?.close();
+3
View File
@@ -43,6 +43,9 @@ export function PreparationChecks({rows, documents, manualDocuments, setManualDo
</label>
{vault.documents.some(f=>f.id===c.documentId) && <button type="button" className="text-xs text-blue-700" onClick={()=>downloadFile(vault.documents.find(f=>f.id===c.documentId))}>Bağlı dosyayı indir</button>}
<Status value={c.status} optional={d.mandatory === false} onChange={status => updateDocument(key,{status})}/>
<label className="block text-xs">Sorumlu
<input aria-label={`${d.document} sorumlusu`} className={fieldClass} value={c.owner || ''} onChange={e => updateDocument(key,{owner:e.target.value})} placeholder="Belgeyi hazırlayacak kişi veya ekip" />
</label>
<label className="block text-xs">Belge adı/sürümü, geçerlilik kontrolü ve kaynak madde
<input className={fieldClass} value={c.evidence || ''} onChange={e => updateDocument(key,{evidence:e.target.value})}/>
</label>
+1
View File
@@ -37,6 +37,7 @@ function Overview({tenders,now}) {
<td role="cell" className="block md:table-cell p-2"><span aria-hidden="true" className="block md:hidden text-xs font-bold mb-1">İhale</span><button type="button" onClick={()=>open(tender,'source')} className="text-blue-800 underline text-left">{tender.ikn} · {tender.baslik}</button><p className="text-xs mt-1">{summary.issues.length ? `${summary.issues.length} kontrol eksik` : 'Kayıtlı kontroller tamam; son kontrol gerekli'}</p></td>
<td role="cell" className="block md:table-cell p-2"><span aria-hidden="true" className="block md:hidden text-xs font-bold mb-1">Sorumlu</span>{summary.owner}</td>
<td role="cell" className="block md:table-cell p-2"><span aria-hidden="true" className="block md:hidden text-xs font-bold mb-1">Belge eksikleri</span><details><summary className="cursor-pointer">{summary.documentIssues.length ? `${summary.documentIssues.length} belge kontrolü` : 'Kayıtlı belgelerde eksik bulunmadı'}</summary>
{summary.documentOwners.length > 0 && <p className="text-xs text-slate-600 mt-2">Belge sorumluları: {summary.documentOwners.map(item=>`${item.document}: ${item.owner}`).join(' · ')}</p>}
<ul className="space-y-2 mt-2">{summary.documentIssues.map((text,i)=><li key={i}><button type="button" onClick={()=>open(tender,'checks')} className="text-blue-800 underline text-left">{text}</button></li>)}</ul>
</details></td>
<td role="cell" className="block md:table-cell p-2"><span aria-hidden="true" className="block md:hidden text-xs font-bold mb-1">Sıradaki kontrol</span><button type="button" onClick={()=>open(tender,summary.next.target)} className="text-blue-800 underline text-left">{summary.next.text}</button>
+3 -3
View File
@@ -1,5 +1,5 @@
import { AiJobHistory } from './AiJobHistory';
import { validateOfferResponse } from '../services/offerResponse';
import { draftReviewLabel, validateOfferResponse } from '../services/offerResponse';
import React, { useState, useRef, useEffect } from 'react';
import { MessageSquareText, ClipboardCheck, PenLine, Loader2, Copy, Upload } from 'lucide-react';
import { getToken } from '../services/auth';
@@ -180,8 +180,8 @@ const OfferAssistantSession = ({ tender }) => {
{bolumler.length > 0 && <button type="button" onClick={() => kopyala(bolumler.map((b) => `${b.baslik}\n\n${b.icerik}`).join('\n\n\n'))} className="text-xs text-slate-600 border border-slate-200 rounded-lg px-2 py-1.5 flex items-center gap-1 hover:bg-slate-50"><Copy className="w-3.5 h-3.5" /> Tümünü kopyala</button>}
</div>
{bolumler.length > 0 && draftSource !== metin && <p role="alert" className="text-xs text-amber-800">Kaynak değişti. Aşağıdaki taslak önceki kaynakla üretildi; düzenlemeleriniz korundu. Yeni kaynakla karşılaştırın veya yeniden üretin.</p>}
{draftReview && <div role="status" className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<p className="font-semibold">{draftReview.status === 'partial' ? 'Kısmi taslak — bölüm kontrolü gerekli' : 'Taslak — kullanıcı kontrolü gerekli'}</p>
{draftReview && <div role={draftReview.status === 'partial' ? 'alert' : 'status'} className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
<p className="font-semibold">{draftReviewLabel(draftReview)}</p>
<p>{draftReview.note}</p>
{draftReview.missingSections?.length > 0 && <p>Modelin tamamlamadığı bölümler: {draftReview.missingSections.join(', ')}. Aşağıdaki DOLDURUN alanlarını düzenleyin.</p>}
{draftReview.extraSections?.length > 0 && <p>İstenen başlıklara eşleşmeyen ek bölümler: {draftReview.extraSections.join(', ')}.</p>}
+13 -3
View File
@@ -177,6 +177,12 @@ const TenderDocumentSession = ({ tender }) => {
try {
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
const d = await r.json();
if (!d.success && d.code === 'HUMAN_VERIFICATION_REQUIRED' && d.manualAction && d.manualUrl) {
setEkapUrl(d.manualUrl);
setBilgi({ manualAction: true, ekapSekme: sekme ? 'acildi' : 'engellendi' });
if (sekme) sekme.location.href = d.manualUrl;
return;
}
if (!d.success) throw new Error(d.error);
setEkapUrl(d.url);
setBilgi({ dokumanSayisi: d.dokumanSayisi, ekapSekme: sekme ? 'acildi' : 'engellendi' });
@@ -326,16 +332,20 @@ const TenderDocumentSession = ({ tender }) => {
</div>
{ekapUrl && (
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' || bilgi?.manualAction ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
<ExternalLink className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
<p className="text-[11px] text-slate-700 leading-relaxed">
{bilgi?.ekapSekme === 'engellendi'
{bilgi?.manualAction
? <>EKAP güvenlik doğrulaması istedi. Kendi oturumunuzda EKAP’ı açıp İKN ile arayın: </>
: bilgi?.ekapSekme === 'engellendi'
? <>Tarayıcınız yeni sekmeyi engelledi. </>
: <>EKAP doküman sayfası yeni sekmede açıldı. Açılmadıysa: </>}
<a href={ekapUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-bold hover:underline">
EKAP doküman sayfasını aç
</a>
{' '}· Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.
{bilgi?.manualAction
? <> · Doğrulamadan sonra dosyaları indirin ve aşağıdaki yükleme alanına ekleyin.</>
: <> · Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.</>}
</p>
</div>
)}
+4 -4
View File
@@ -70,7 +70,7 @@ export const AiModelSettings = () => {
<Zap className="w-4 h-4 text-emerald-700 shrink-0 mt-0.5" />
<div className="text-xs text-emerald-900 leading-relaxed">
<strong>AI aktif</strong> — {cfg.providers.length} model kullanılabilir
{aktifYol === 'openrouter' ? ' (OpenRouter üzerinden tek anahtarla)' : ' (doğrudan sağlayıcı anahtarlarıyla)'}.
{aktifYol === 'openrouter' ? ' (OpenRouter üzerinden tek anahtarla)' : ' (birincil sağlayıcı doğrudan, kalanlar yedek)'}.
<div className="font-mono text-[11px] mt-1 text-emerald-800">
{cfg.providers.map((p) => `${p.label}: ${p.model}`).join(' · ')}
</div>
@@ -80,9 +80,9 @@ export const AiModelSettings = () => {
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="w-4 h-4 text-amber-700 shrink-0 mt-0.5" />
<p className="text-xs text-amber-900 leading-relaxed">
<strong>AI anahtarı yok.</strong> Sunucuya tek bir <code className="bg-amber-100 px-1 rounded">OPENROUTER_API_KEY</code> ekleyin —
aşağıdaki tüm modeller bu tek anahtarla çalışır. Aşağıdaki sıralama yalnızca tercih kaydeder;
çağrılar anahtar eklendiğinde başlar.
<strong>AI anahtarı yok.</strong> Sunucuya <code className="bg-amber-100 px-1 rounded">ZAI_API_KEY</code> (birincil, GLM modelleri)
ve isteğe bağlı yedek <code className="bg-amber-100 px-1 rounded">OPENROUTER_API_KEY</code> ekleyin.
Aşağıdaki sıralama yalnızca tercih kaydeder; çağrılar anahtar eklendiğinde başlar.
</p>
</div>
)
+6 -2
View File
@@ -11,7 +11,7 @@ export const PwaSettings = () => {
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
};
const PwaPanel = () => {
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow } = useApp();
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow, offlineQueueSize } = useApp();
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
const [onbellekAdi, setOnbellekAdi] = useState('');
@@ -156,6 +156,10 @@ const PwaPanel = () => {
<span>Önbelleğe alınmış istek:</span>
<strong className="text-slate-900">{onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}</strong>
</p>
<p className="flex justify-between">
<span>Gönderim bekleyen çevrimdışı kayıt:</span>
<strong className="text-slate-900">{offlineQueueSize}</strong>
</p>
<p className="flex justify-between">
<span>Uygulama sürümü önbelleği:</span>
<strong className="text-emerald-700">{onbellekAdi || '—'}</strong>
@@ -169,7 +173,7 @@ const PwaPanel = () => {
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
</button>
{cacheError && <p role="alert" className="text-xs text-red-700">{cacheError}</p>}
<p className="text-xs text-slate-600">Bağlantı durumu sunucuya erişimi garanti etmez. Çevrimdışı değişiklikler otomatik gönderim kuyruğuna alınmaz; kayıt sonucunu kontrol edin.</p>
<p className="text-xs text-slate-600">Bağlantı kesildiğinde ihale hazırlık değişiklikleri hesap kapsamlı kuyruğa alınır ve bağlantı gelince yeniden gönderilir. Belge yükleme ve dış sağlayıcı işlemleri çevrimdışı başlatılamaz.</p>
</div>
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
+65 -11
View File
@@ -8,13 +8,14 @@ import React, {createContext, useContext, useState, useEffect, useMemo, useRef }
import { scanCompanyProposal } from '../services/companyProfile';
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
import { getToken } from '../services/auth';
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
import { fetchLiveTenders, fetchTenderDetail, fetchOfficialTenderDetail, mergeUserState } from '../services/tenderApi';
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
import { getUserDataSnapshot, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tenderSourceChanges } from '../services/tenderState';
import { getTenderStates, saveTenderState } from '../services/account';
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
import { istanbulDateKey } from '../services/tenderCalendar';
import { enqueueTenderMutation, offlineMutationCount, replayOfflineMutations } from '../services/offlineMutationQueue.js';
const AppContext = createContext();
@@ -211,12 +212,46 @@ export const AppProvider = ({ children }) => {
const inFlight = useRef(new Set());
const [saveError, setSaveError] = useState(null);
const [savingCount, setSavingCount] = useState(0);
const [offlineQueueSize, setOfflineQueueSize] = useState(0);
const replayOfflineQueue = async (owner) => {
if (!owner || (typeof navigator !== 'undefined' && !navigator.onLine)) return;
const result = await replayOfflineMutations(owner, async item => {
if (item.kind !== 'tender-state') throw new Error('Çevrimdışı kayıt türü desteklenmiyor.');
const record = await saveTenderState(item.key, item.patch, item.snapshot, item.version);
if (getStoredUser()?.email !== owner) throw new Error('Oturum değişti; kuyruk bekletildi.');
savedTenderStates.current = { ...savedTenderStates.current, [item.key]: record };
setTenders(prev => applyTenderStates(prev, savedTenderStates.current));
return record;
});
setOfflineQueueSize(offlineMutationCount(owner));
if (result.error) setSaveError(`Çevrimdışı kayıt gönderilemedi: ${result.error.message}`);
else if (result.sent > 0) setSaveError(null);
return result;
};
useEffect(() => {
const owner = authUser?.email;
if (!owner || !veriSenkron) { setOfflineQueueSize(0); return undefined; }
setOfflineQueueSize(offlineMutationCount(owner));
const reconnect = () => { void replayOfflineQueue(owner); };
window.addEventListener('online', reconnect);
if (typeof navigator === 'undefined' || navigator.onLine) void replayOfflineQueue(owner);
return () => window.removeEventListener('online', reconnect);
}, [authUser?.email, veriSenkron]);
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)) { setSaveError('Bu ihale için önceki kayıt sürüyor. Tamamlandıktan sonra tekrar deneyin.'); return false; }
const owner = authUser.email;
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
try {
enqueueTenderMutation(owner, { key, patch, snapshot: tenderSnapshot(t), version: savedTenderStates.current[key]?.version || 0 });
setOfflineQueueSize(offlineMutationCount(owner));
setTenders(prev => prev.map(row => tenderStateKey(row) === key ? { ...row, ...patch } : row));
setSaveError('Çevrimdışı değişiklik kuyruğa alındı; bağlantı gelince sunucuya gönderilecek.');
return true;
} catch (error) { setSaveError(error.message); return false; }
}
const session = getToken();
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
try {
@@ -321,9 +356,27 @@ export const AppProvider = ({ children }) => {
const controller = new AbortController();
setDetailLoad({id:t.id,status:'loading'});
fetchTenderDetail(t.id, {signal:controller.signal})
.then(detail => {
.then(async detail => {
if (cancelled) return;
const source = publicTender(detail);
let enriched = detail;
if (t.id.startsWith('ilan-') && /^\d{4}\/\d+$/.test(String(t.ikn || '').replace(/[\s\u00a0]+/g, ''))) {
try {
const official = await fetchOfficialTenderDetail(t.ikn, { signal: controller.signal });
enriched = {
...detail,
...official,
// Keep the richer announcement text if EKAP returned only the
// compact metadata block.
aciklama: String(official.metin || '').length > String(detail.aciklama || '').length
? official.metin : detail.aciklama,
};
} catch (error) {
if (cancelled || controller.signal.aborted) return;
enriched = { ...detail, sourceWarning: `Resmî EKAP tarihi doğrulanamadı; ilan tarihi gösteriliyor. ${error.message}` };
}
}
if (cancelled) return;
const source = publicTender(enriched);
source.sourceChanges = tenderSourceChanges(savedTenderStates.current[tenderStateKey(t)]?.snapshot, {...t,...source});
setDetailLoad({id:t.id,status:'loaded'});
setActiveTenderDetail(prev =>
@@ -447,6 +500,7 @@ export const AppProvider = ({ children }) => {
// AI Model Yönetimi (admin panelden seçilebilir + fallback zinciri)
// ---------------------------------------------------------------------------
const AI_MODEL_CATALOG = [
{ id: 'zai', label: 'GLM 5.3', provider: 'Z.ai', or: '~z-ai/glm-latest', model: 'glm-5.3', renk: 'blue' },
{ id: 'glmflash', label: 'GLM 5.3 Flash', provider: 'Z.ai', or: 'z-ai/glm-5.3-flash', model: 'glm-5.3-flash', renk: 'blue' },
{ id: 'gemini', label: 'Gemini Flash Latest', provider: 'Google', or: '~google/gemini-flash-latest', model: 'gemini-flash-latest', renk: 'amber' },
{ id: 'deepseek', label: 'DeepSeek V4 Flash Latest', provider: 'DeepSeek', or: '~deepseek/deepseek-v4-flash-latest', model: 'deepseek-chat', renk: 'indigo' },
@@ -455,21 +509,21 @@ export const AppProvider = ({ children }) => {
{ id: 'claude', label: 'Claude Opus Latest', provider: 'Anthropic', or: '~anthropic/claude-opus-latest', model: 'claude-opus-5', renk: 'orange' }
];
// Varsayılan fallback zinciri: GLM 5.3 Flash (birincil) → Gemini → DeepSeek
// (glm-latest kaldırıldı: birincil ile aynı modele çözünüyordu, gerçek yedek değildi)
const VARSAYILAN_ZINCIR = ['glmflash', 'gemini', 'deepseek'];
const ZINCIR_SURUM = 3; // artırıldığında kayıtlı zincir yeni düzene taşınır
// Varsayılan fallback zinciri (2026-09-10, Z.ai birincil): GLM 5.3 → GLM 5.3 Flash
// → Gemini → DeepSeek. İlk ikisi doğrudan Z.ai'den, kalanlar OpenRouter yedeği.
const VARSAYILAN_ZINCIR = ['zai', 'glmflash', 'gemini', 'deepseek'];
const ZINCIR_SURUM = 4; // artırıldığında kayıtlı zincir yeni düzene taşınır
const aiModelAnahtari = (email) => `ihaleist_ai_model_config:${encodeURIComponent(String(email || '').toLowerCase())}`;
const normalizeAiModelConfig = (saved) => {
if (saved) {
try {
const cfg = JSON.parse(saved);
// Geçiş: kayıtlı zinciri yeni düzene taşı (glm çıkar, glmflash → gemini başa)
// Geçiş: kayıtlı zinciri yeni düzene taşı (Z.ai GLM 5.3 ve Flash başa, eski glm çıkar)
if ((cfg.surum || 1) < ZINCIR_SURUM) {
const chain = Array.isArray(cfg.chain) ? cfg.chain : [];
const kalan = chain.filter((id) => !['glmflash', 'gemini', 'glm'].includes(id));
return { ...cfg, surum: ZINCIR_SURUM, chain: ['glmflash', 'gemini', ...kalan] };
const kalan = chain.filter((id) => !['zai', 'glmflash', 'gemini', 'glm'].includes(id));
return { ...cfg, surum: ZINCIR_SURUM, chain: ['zai', 'glmflash', 'gemini', ...kalan] };
}
// Katalogdan çıkarılmış modelleri kayıtlı zincirden ayıkla
if (Array.isArray(cfg.chain)) {
@@ -887,7 +941,7 @@ export const AppProvider = ({ children }) => {
planYenile,
kotaUyarisi,
setKotaUyarisi,
veriSenkron, saveError, savingCount, mutateTender
veriSenkron, saveError, savingCount, mutateTender, offlineQueueSize
}}>
{children}
</AppContext.Provider>
+5
View File
@@ -2,6 +2,11 @@ const object = value => value !== null && typeof value === 'object' && !Array.is
const text = value => typeof value === 'string';
const texts = value => Array.isArray(value) && value.every(text);
const optionalText = value => value == null || text(value);
export function draftReviewLabel(review) {
return review?.status === 'partial'
? 'EKSİK TASLAK — TEKLİF OLARAK KULLANILAMAZ'
: 'Taslak — kullanıcı kontrolü gerekli';
}
export function validateOfferResponse(url, data) {
let valid = object(data) && data.success === true;
if (valid && url === '/api/ai/belge-sor') {
+127
View File
@@ -0,0 +1,127 @@
const PREFIX = 'ihaleist_offline_mutations:';
const LIMIT = 100;
const activeReplays = new Map();
const ownerKey = owner => `${PREFIX}${encodeURIComponent(String(owner || '').trim().toLowerCase())}`;
function readStorage(owner) {
if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return [];
try {
const parsed = JSON.parse(localStorage.getItem(ownerKey(owner)) || '[]');
return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object') : [];
} catch { return []; }
}
function writeStorage(owner, rows) {
if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return false;
try {
localStorage.setItem(ownerKey(owner), JSON.stringify(rows));
return true;
} catch { return false; }
}
export function offlineMutationCount(owner) {
return readStorage(owner).length;
}
export function listOfflineMutations(owner) {
return readStorage(owner).map(item => ({ ...item }));
}
/**
* Adds a tender-state write to the account-scoped durable queue. Consecutive
* changes for the same tender are merged so reconnecting never replays stale
* intermediate drafts. Tokens and document bytes are deliberately excluded.
*/
export function enqueueTenderMutation(owner, { key, patch, snapshot, version }) {
if (!String(owner || '').trim() || !String(key || '').trim() || !patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new Error('Çevrimdışı kayıt için ihale ve değişiklik bilgisi gerekli.');
}
const rows = readStorage(owner);
const index = rows.findIndex(item => item.kind === 'tender-state' && item.key === key);
const previous = index >= 0 ? rows[index] : null;
if (!previous && rows.length >= LIMIT) {
throw new Error('Çevrimdışı kayıt kuyruğu dolu. Bağlantı kurulduktan sonra yeniden deneyin.');
}
const next = {
id: previous?.id || `offline-${crypto.randomUUID()}`,
kind: 'tender-state',
key,
patch: { ...(previous?.patch || {}), ...patch },
snapshot: snapshot && typeof snapshot === 'object' ? snapshot : {},
version: Number.isInteger(version) && version >= 0 ? version : 0,
createdAt: previous?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
attempts: previous?.attempts || 0,
revision: (Number.isInteger(previous?.revision) ? previous.revision : 0) + 1,
};
if (index >= 0) rows[index] = next; else rows.push(next);
if (!writeStorage(owner, rows)) throw new Error('Çevrimdışı kayıt kuyruğu yazılamadı.');
return next;
}
export function removeOfflineMutation(owner, id) {
const rows = readStorage(owner);
return writeStorage(owner, rows.filter(item => item.id !== id));
}
export function markOfflineMutationAttempt(owner, id) {
const rows = readStorage(owner);
const next = rows.map(item => item.id === id ? { ...item, attempts: (item.attempts || 0) + 1, updatedAt: new Date().toISOString() } : item);
writeStorage(owner, next);
return next.find(item => item.id === id) || null;
}
function acknowledgeOfflineMutation(owner, item, record) {
const rows = readStorage(owner);
const index = rows.findIndex(row => row.id === item.id);
if (index < 0) return true;
const current = rows[index];
if ((current.revision || 0) === (item.revision || 0)) {
rows.splice(index, 1);
} else {
const nextVersion = Number(record?.version);
rows[index] = {
...current,
version: Number.isInteger(nextVersion) && nextVersion >= 0 ? nextVersion : current.version,
};
}
return writeStorage(owner, rows);
}
async function runReplay(owner, send) {
const rows = listOfflineMutations(owner);
const result = { sent: 0, pending: rows.length, error: null };
for (const item of rows) {
markOfflineMutationAttempt(owner, item.id);
try {
const record = await send(item);
if (!acknowledgeOfflineMutation(owner, item, record)) {
throw new Error('Çevrimdışı kayıt kuyruğu güncellenemedi.');
}
result.sent += 1;
} catch (error) {
result.error = error;
break;
}
}
result.pending = offlineMutationCount(owner);
return result;
}
/** Replay in order; concurrent reconnect signals share one replay operation. */
export function replayOfflineMutations(owner, send) {
const replayKey = ownerKey(owner);
const active = activeReplays.get(replayKey);
if (active) return active;
const replay = runReplay(owner, send).finally(() => {
if (activeReplays.get(replayKey) === replay) activeReplays.delete(replayKey);
});
activeReplays.set(replayKey, replay);
return replay;
}
export function clearOfflineMutations(owner) {
if (typeof localStorage === 'undefined') return false;
try { localStorage.removeItem(ownerKey(owner)); return true; } catch { return false; }
}
+2 -1
View File
@@ -37,5 +37,6 @@ export function preparationSummary(tender,vault,now=new Date()) {
const checkedVault={...vault,documents:Array.isArray(vault?.documents) ? vault.documents : [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn};
const documentIssues=preparationBlockers([],documents,draft.checks,checkedVault);
preparationBlockers(selection.rows,documents,draft.checks,checkedVault).forEach(text=>add('checks',text));
return {issues,documentIssues,owner:tender.atananPersonel || 'Atanmadı',next:issues[0] || {target:'source',text:'Dosyayı açıp son kontrolleri yapın.'}};
const documentOwners=documents.map(document=>({document:document.document,owner:draft.checks?.documents?.[documentKey(document)]?.owner?.trim() || 'Atanmadı'}));
return {issues,documentIssues,documentOwners,owner:tender.atananPersonel || 'Atanmadı',next:issues[0] || {target:'source',text:'Dosyayı açıp son kontrolleri yapın.'}};
}
+45 -2
View File
@@ -4,11 +4,20 @@
* Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir.
*/
function sourceError(data, temporaryMessage) {
if (data.code === 'HUMAN_VERIFICATION_REQUIRED' && data.manualAction) {
return new Error(data.error || 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.');
}
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) return new Error(temporaryMessage);
return null;
}
export async function fetchLiveTenders({ refresh = false } = {}) {
const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
const failure = sourceError(data, 'İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
if (failure) throw failure;
throw new Error(`API hatası: HTTP ${res.status}`);
}
if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü');
@@ -28,7 +37,8 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} =
const res = await fetch(url, {signal:controller.signal});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
const failure = sourceError(data, 'Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.');
if (failure) throw failure;
throw new Error(`Detay API hatası: HTTP ${res.status}`);
}
if (!data.success) throw new Error(data.error || 'Detay alınamadı');
@@ -52,6 +62,39 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} =
}
}
// Live ilan.gov.tr rows can carry an older announcement date than EKAP's
// authoritative tender detail. Fetch the official record when a user opens a
// tender so the modal/card can reconcile the date without enriching the full
// pool (which would be too expensive for thousands of rows).
export async function fetchOfficialTenderDetail(ikn, {signal, timeoutMs = 30000} = {}) {
const cleanIkn = String(ikn || '').replace(/[\s\u00a0]+/g, '');
if (!/^\d{4}\/\d+$/.test(cleanIkn)) throw new Error('Geçersiz İKN');
const controller = new AbortController();
const cancel = () => controller.abort();
if (signal?.aborted) cancel();
else signal?.addEventListener('abort', cancel, { once: true });
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
try {
const res = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(cleanIkn)}`, { signal: controller.signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const failure = sourceError(data, 'Resmî ihale kaynağı geçici olarak erişilemiyor.');
if (failure) throw failure;
throw new Error(`Resmî detay API hatası: HTTP ${res.status}`);
}
if (!data.success || data.ikn !== cleanIkn) throw new Error('Resmî detay yanıtı aranan İKN ile uyuşmuyor.');
if (!data.tarih || !data.saat) throw new Error('Resmî detayda ihale tarih ve saati bulunamadı.');
return data;
} catch (error) {
if (timedOut) throw new Error('Resmî ihale tarihi yüklenirken zaman aşımı oluştu.');
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', cancel);
}
}
// Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir.
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel', 'gorev', 'notes', 'checklist', 'bidDraft'];
+6
View File
@@ -27,6 +27,8 @@ try {
evidence.checks.signInStatus = response?.status();
await page.getByRole('button', { name: 'Test hesabıyla gir' }).click();
await page.waitForLoadState('networkidle');
const hideExpired = page.getByLabel('Süresi geçenleri gizle', { exact: true });
if (await hideExpired.isChecked()) await hideExpired.uncheck();
evidence.checks.visibleCities = await Promise.all(['Giresun', 'Kayseri', 'Akdeniz', 'Aksaray', 'Tepecik'].map(async city => ({ city, count: await page.getByText(city, { exact: false }).count() })));
if (evidence.checks.visibleCities.some(item => item.count < 1)) throw new Error('Beş sentetik şehirden biri görünmedi.');
await page.getByRole('button', { name: 'Günlük Bülten', exact: true }).click();
@@ -156,6 +158,10 @@ try {
evidence.checks.overviewStatus = overview?.status();
evidence.checks.overviewFrames = await page.locator('iframe[title^="Hazırlık"]').count();
if (evidence.checks.overviewFrames !== 3) throw new Error('Hazırlık özeti üç görünüm alanında açılmadı.');
const overviewFrame = page.frameLocator('iframe[title="Hazırlık 390"]');
await overviewFrame.getByText('Belge sorumluları:', { exact: false }).waitFor({ state: 'attached', timeout: 10_000 });
evidence.checks.documentOwnerInPreparationOverview = await overviewFrame.getByText('Belge sorumluları: QA zorunlu belge: QA Belge Sorumlusu', { exact: true }).count() === 1;
if (!evidence.checks.documentOwnerInPreparationOverview) throw new Error('Belge sorumlusu hazırlık özetinde görünmedi.');
const mobile = await context.newPage();
await mobile.setViewportSize({ width: 390, height: 844 });
try {
+11 -3
View File
@@ -128,7 +128,15 @@ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{targ
if(req.url?.startsWith('/qa-overview-frame')){
const frameWidth=new URL(req.url,'http://localhost').searchParams.get('width');
const widthStyle=/^(390|768|1440)$/.test(frameWidth||'')?` style="width:${frameWidth}px"`:'';
const cases=tenders.map((t,i)=>({...t,baslik:i===0?'QA Uzun laboratuvar cihazı '+ 'KesintisizÜrünKodu'.repeat(12):t.baslik,atananPersonel:i===0?'QA Sorumlu Uzman':'',takipte:true}));
const cases=tenders.map((t,i)=>{
const base={...t,baslik:i===0?'QA Uzun laboratuvar cihazı '+ 'KesintisizÜrünKodu'.repeat(12):t.baslik,atananPersonel:i===0?'QA Sorumlu Uzman':'',takipte:true};
if(i!==0)return base;
const ownerFile={id:'qa-owner-file',ad:'QA sorumlu belgesi.pdf',tur:'firma',boyut:100,olusturma:'2026-09-06T00:00:00Z',erisilebilir:true};
const prepared=readyBid(base,ownerFile);
const [documentKeyValue]=Object.keys(prepared.bidDraft.checks.documents);
prepared.bidDraft.checks.documents[documentKeyValue].owner='QA Belge Sorumlusu';
return prepared;
});
const html=`<html lang="tr"><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body${widthStyle}><div id="root"></div><script type="module">
import React from '/node_modules/.vite/deps/react.js';
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
@@ -159,7 +167,7 @@ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{targ
const query=new URL(req.url,'http://localhost').searchParams;
qaFiles=query.has('file')?[{...qaFile}]:null;
const selected=query.has('ready')?readyBid(tenders[0],qaFiles?.[0]):tenders[0];
if(selected.bidDraft){const summary=preparationSummary(selected,{loading:false,error:null,documents:qaFiles || []});if(summary.issues.length)throw new Error('QA ready fixture: '+JSON.stringify(summary.issues));}
if(selected.bidDraft){const summary=preparationSummary(selected,{loading:false,error:null,documents:qaFiles || []},new Date('2026-09-06T09:00:00Z'));if(summary.issues.length)throw new Error('QA ready fixture: '+JSON.stringify(summary.issues));}
const html=`<html lang="tr"><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module">
import React from '/node_modules/.vite/deps/react.js';
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
@@ -178,7 +186,7 @@ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{targ
React.createElement('button',{onClick:()=>control('release')},'Belge yanıtını serbest bırak'),
React.createElement('p',{role:'status'},status),
React.createElement('button',{onClick:()=>setTenders([{...selected,id:'qa-rotated-source',baslik:'QA Güncellenmiş alım',kazananFirma:'QA Kazanan'}])},'Aynı İKN için yeni kayıt ID ve kazanan yayımla'),
React.createElement(BidPackageBuilder,{tender:selected}));}
React.createElement(BidPackageBuilder,{tender:selected,now:new Date('2026-09-06T09:00:00Z')}));}
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(AppProvider,{},React.createElement(Probe)));
</script></body></html>`;
res.setHeader('Content-Type','text/html; charset=utf-8');res.end(await server.transformIndexHtml('/qa-source-update',html));return;
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { currentLabAcceptanceCases as cases } from './fixtures/d10-current-lab.mjs';
test('D10 current laboratory acceptance fixture is complete and explicit', () => {
assert.equal(cases.length, 5);
assert.equal(new Set(cases.map(({ ikn }) => ikn)).size, 5);
assert.equal(new Set(cases.map(({ id }) => id)).size, 5);
for (const item of cases) {
assert.match(item.ikn, /^2026\/\d{7}$/);
assert.match(item.id, /^ilan-\d+$/);
assert.match(item.date, /^\d{2}\.\d{2}\.2026$/);
assert.match(item.time, /^\d{2}:\d{2}$/);
assert.ok(item.titleTerm);
assert.ok(item.natureTerm);
assert.ok(item.deliveryTerm);
assert.ok([60, 120].includes(item.validityDays));
assert.ok(item.partialOfferTerm);
assert.ok(item.requirements.length >= 5);
assert.equal(new Set(item.requirements.map(({ key }) => key)).size, item.requirements.length);
for (const requirement of item.requirements) {
assert.ok(requirement.label);
assert.ok(requirement.term);
}
}
});
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDetailCache } from '../server/detail-cache.mjs';
test('detail cache expires stale source data and supports explicit refresh', () => {
const cache = createDetailCache({ maxEntries: 2, ttlMs: 100 });
cache.set('2026/1', { tarih: '10.09.2026' }, 1000);
assert.deepEqual(cache.get('2026/1', 1099), { tarih: '10.09.2026' });
assert.equal(cache.get('2026/1', 1100), undefined);
cache.set('2026/1', { tarih: '16.09.2026' }, 1200);
assert.deepEqual(cache.get('2026/1', 1200), { tarih: '16.09.2026' });
cache.clear();
assert.equal(cache.size, 0);
});
test('detail cache evicts the oldest entry at its bounded limit', () => {
const cache = createDetailCache({ maxEntries: 2, ttlMs: 1000 });
cache.set('a', 1, 0);
cache.set('b', 2, 0);
cache.set('c', 3, 0);
assert.equal(cache.get('a', 1), undefined);
assert.equal(cache.get('b', 1), 2);
assert.equal(cache.get('c', 1), 3);
});
+117
View File
@@ -0,0 +1,117 @@
const commonRequirements = [
{ key: 'offer-letter', label: 'Teklif mektubu', term: 'Teklif mektubu' },
{ key: 'authorization', label: 'Teklif vermeye yetki bilgileri', term: 'Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler' },
{ key: 'temporary-guarantee', label: 'Geçici teminat', term: 'Geçici teminat' },
];
function tender(definition) {
return {
...definition,
requirements: [...commonRequirements, ...definition.requirements],
};
}
// Current public-announcement acceptance set selected on 15 September 2026.
// These records prove announcement requirements only. EKAP's official document
// package, company/product evidence and generated outputs are separate D10 gates.
export const currentLabAcceptanceCases = [
tender({
ikn: '2026/1634449',
id: 'ilan-2219605',
city: 'Rize',
date: '05.10.2026',
time: '10:30',
scenario: '20-kalem-laboratuvar-cihazi',
titleTerm: 'Laboratuvar cihazı',
natureTerm: '20 KALEM',
deliveryTerm: '50 (Elli) takvim günü',
validityDays: 60,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'manufacturer-or-authorized-seller', label: 'İmalatçı/yetkili satıcı/yetkili temsilci belgesi', term: 'Yetkili Satıcı Belgesi' },
{ key: 'service-qualification', label: 'TSE Hizmet Yeterlilik Belgesi', term: 'TSE Hizmet Yeterlilik Belgesi' },
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'brand-model', label: 'Marka-model beyanı', term: 'MARKA - MODEL BEYANI' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
],
}),
tender({
ikn: '2026/1676347',
id: 'ilan-2218485',
city: 'Sivas',
date: '01.10.2026',
time: '10:30',
scenario: 'cihaz-malzeme-yazilim',
titleTerm: 'Laboratuvar cihaz, malzeme ve yazılım',
natureTerm: '2 Kısım Cihaz, 1 Kısım Yazılım ve 1 Kısım Sarf Malzeme',
deliveryTerm: '3. Kısım yazılımın kurulumu 15 takvim günü',
validityDays: 60,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
],
}),
tender({
ikn: '2026/1667534',
id: 'ilan-2217472',
city: 'Konya',
date: '28.09.2026',
time: '10:30',
scenario: 'yedek-parca-ve-sarf',
titleTerm: 'Laboratuvar cihaz yedek parça ve sarf malzeme',
natureTerm: '28 Kalem Laboratuvar Cihaz Yedek Parça ve Tıbbi Sarf Malzemesi',
deliveryTerm: '27. ve 28. kalemler için 10 takvim günü',
validityDays: 120,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
],
}),
tender({
ikn: '2026/1620320',
id: 'ilan-2214836',
city: 'Zonguldak',
date: '01.10.2026',
time: '10:00',
scenario: '220-kalem-laboratuvar-sarfi',
titleTerm: 'Laboratuvar sarf malzemeleri',
natureTerm: '220 Kalem Laboratuvar Sarf Malzeme',
deliveryTerm: '15(on beş) gün içerisinde',
validityDays: 120,
partialOfferTerm: 'kısmı teklif verilebilir',
requirements: [
{ key: 'scope-exclusion', label: 'Kapsam Dışı Belgesi', term: 'Kapsam Dışı Belgesi' },
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
{ key: 'uts', label: 'TİTUBB veya ÜTS belgesi', term: 'TİTUBB veya ÜTS belgesi' },
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
],
}),
tender({
ikn: '2026/1636687',
id: 'ilan-2211682',
city: 'Ankara',
date: '28.09.2026',
time: '14:00',
scenario: 'kit-karsiligi-otoanalizor',
titleTerm: 'Kit karşılığı otoanalizör',
natureTerm: '24 Aylık Kit ve Kit Karşılığı Alınacak Otoanalizör',
deliveryTerm: '15 (Onbeş) takvim günü',
validityDays: 120,
partialOfferTerm: 'işin tamamı için teklif verilecektir',
requirements: [
{ key: 'service-training', label: 'Teknik Servis Eğitim Sertifikası', term: 'Teknik Servis Eğitim Sertifikası' },
{ key: 'brochure', label: 'Broşür', term: 'Broşür' },
{ key: 'user-guide', label: 'Cihaz ve ODS kullanım kılavuzu', term: 'ODS yazılım sistemine ait kullanım klavuzu' },
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
{ key: 'device-id-cards', label: 'Cihaz ve sistem tanıtım kimlik kartı', term: 'tanıtım kimlik kartı' },
{ key: 'production-and-age', label: 'Üretimde olma ve 13 yaş altı kanıtı', term: 'on üç yaşın altında olduğunu belirten belgeler' },
{ key: 'offer-authorization-proof', label: 'Teklif yetkisi kanıtı', term: 'Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge' },
{ key: 'uts-evidence', label: 'ÜTS beyanı ve kanıtlayıcı belgeler', term: 'ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER' },
],
}),
];
+13 -1
View File
@@ -20,7 +20,7 @@ const request=async(path,method='GET',body,auth=token)=>{
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',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',APP_RELEASE:'0123456789abcdef',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),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');
});
@@ -55,6 +55,18 @@ test('readiness endpoint checks persistent data directories',async()=>{
assert.equal(response.data.checks.userData.ok,true);
assert.equal(response.data.checks.sharedData.ok,true);
});
test('version endpoint identifies the running release and frontend asset',async()=>{
const response=await request('/api/version','GET',null,null);
assert.equal(response.status,200);
assert.equal(response.data.ok,true);
assert.equal(response.data.release,'0123456789abcdef');
assert.equal(response.data.version,'1.0.0');
if(response.data.frontendAsset){
assert.match(response.data.frontendAsset.path,/^\/assets\/.+\.js$/);
assert.match(response.data.frontendAsset.sha256,/^[a-f0-9]{64}$/);
assert.ok(response.data.frontendAsset.bytes>0);
}
});
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,[]);
+154
View File
@@ -0,0 +1,154 @@
// Read-only production acceptance probe for five current laboratory tenders.
// It records every D10 gate separately and never turns an upstream blocker into
// a positive result. No authentication, writes, payments, messages or bids.
import { createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { currentLabAcceptanceCases } from './fixtures/d10-current-lab.mjs';
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_FIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-five-tenders.json';
const timeoutMs = 30_000;
function normalize(value) {
return String(value || '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLocaleLowerCase('tr-TR');
}
function includesTerm(text, term) {
return normalize(text).includes(normalize(term));
}
async function probeJson(path) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${base}${path}`, { signal: controller.signal, headers: { 'User-Agent': 'ihaleist-live-five-probe/1.0' } });
const data = await response.json().catch(() => null);
return {
path,
status: response.status,
ok: response.ok && data?.success === true,
data,
error: response.ok && data?.success === true ? null : data?.error || `HTTP ${response.status}`,
};
} catch (error) {
return { path, status: 0, ok: false, data: null, error: error?.message || String(error) };
} finally {
clearTimeout(timer);
}
}
const listProbe = await probeJson('/api/tenders');
const list = listProbe.data || {};
const byIkn = new Map((Array.isArray(list.tenders) ? list.tenders : []).map((tender) => [String(tender.ikn || '').replace(/\s/g, ''), tender]));
const tenders = [];
for (const fixture of currentLabAcceptanceCases) {
const listed = byIkn.get(fixture.ikn) || null;
const detailProbe = await probeJson(`/api/tenders/${encodeURIComponent(fixture.id)}/detail`);
const detail = detailProbe.data || {};
const announcementText = String(detail.aciklama || '');
const requirementChecks = fixture.requirements.map(({ key, label, term }) => ({
key,
label,
sourceTerm: term,
present: includesTerm(announcementText, term),
}));
const announcementChecks = {
listed: Boolean(listed),
detailEndpoint: detailProbe.ok,
ikn: includesTerm(announcementText, fixture.ikn),
title: includesTerm(`${listed?.baslik || ''} ${announcementText}`, fixture.titleTerm),
date: detail.tarih === fixture.date && includesTerm(announcementText, fixture.date),
time: detail.saat === fixture.time && includesTerm(announcementText, fixture.time),
nature: includesTerm(announcementText, fixture.natureTerm),
delivery: includesTerm(announcementText, fixture.deliveryTerm),
validity: includesTerm(announcementText, `geçerlilik süresi, ihale tarihinden itibaren ${fixture.validityDays}`),
partialOffer: includesTerm(announcementText, fixture.partialOfferTerm),
requirements: requirementChecks.every(({ present }) => present),
};
const announcementPassed = Object.values(announcementChecks).every(Boolean);
const documentProbe = await probeJson(`/api/documents/ilan?ikn=${encodeURIComponent(fixture.ikn)}`);
const document = documentProbe.data || {};
const officialDocumentPackagePassed = documentProbe.ok
&& document.ikn === fixture.ikn
&& Number(document.dokumanSayisi) > 0;
const documentBlocker = officialDocumentPackagePassed ? null : {
stage: 'officialDocumentPackage',
status: documentProbe.status,
code: document.code || null,
retryable: document.retryable ?? null,
message: document.error || documentProbe.error,
};
const dependentStatus = officialDocumentPackagePassed ? 'not-run-read-only-scope' : 'blocked-by-official-document-package';
const stages = {
selectionAndAnnouncement: { passed: announcementPassed, status: announcementPassed ? 'passed' : 'failed' },
officialDocumentPackage: { passed: officialDocumentPackagePassed, status: officialDocumentPackagePassed ? 'passed' : 'blocked', blocker: documentBlocker },
productMatch: { passed: false, status: dependentStatus },
companyDocumentEvidence: { passed: false, status: dependentStatus },
priceAndOutputs: { passed: false, status: dependentStatus },
};
tenders.push({
ikn: fixture.ikn,
scenario: fixture.scenario,
id: fixture.id,
title: listed?.baslik || detail.title || null,
institution: listed?.kurum || detail.institution || null,
city: listed?.sehir || detail.city || fixture.city,
tenderDate: detail.tarih || null,
tenderTime: detail.saat || null,
sourceUrl: listed?.kaynakUrl || null,
publicAnnouncement: {
endpointStatus: detailProbe.status,
textChars: announcementText.length,
normalizedTextSha256: announcementText
? createHash('sha256').update(normalize(announcementText)).digest('hex')
: null,
checks: announcementChecks,
requirementChecks,
},
officialDocumentProbe: {
endpointStatus: documentProbe.status,
code: document.code || null,
retryable: document.retryable ?? null,
documentCount: Number(document.dokumanSayisi) || 0,
announcementCount: Number(document.ilanSayisi) || 0,
okasCount: Number(document.okasSayisi) || 0,
error: document.error || documentProbe.error,
},
stages,
positive: Object.values(stages).every(({ passed }) => passed),
});
}
const blockers = tenders.flatMap((tender) => tender.stages.officialDocumentPackage.blocker
? [{ ikn: tender.ikn, ...tender.stages.officialDocumentPackage.blocker }]
: []);
const result = {
checkedAt: new Date().toISOString(),
base,
readOnly: true,
listProbe: { status: listProbe.status, success: listProbe.ok },
listCount: Array.isArray(list.tenders) ? list.tenders.length : 0,
selectedCount: tenders.length,
announcementPassCount: tenders.filter(({ stages }) => stages.selectionAndAnnouncement.passed).length,
officialDocumentPackagePassCount: tenders.filter(({ stages }) => stages.officialDocumentPackage.passed).length,
positiveCount: tenders.filter(({ positive }) => positive).length,
pass: tenders.length === 5 && tenders.every(({ positive }) => positive),
blockers,
tenders,
scope: 'Canlı liste, ilan metni ve resmi doküman uçları salt okunur denetlendi; giriş, veri değişikliği, ödeme, mesaj veya teklif gönderimi yapılmadı.',
};
await mkdir(dirname(evidencePath), { recursive: true });
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify({
base,
checkedAt: result.checkedAt,
selectedCount: result.selectedCount,
announcementPassCount: result.announcementPassCount,
officialDocumentPackagePassCount: result.officialDocumentPackagePassCount,
positiveCount: result.positiveCount,
pass: result.pass,
blockerCodes: [...new Set(blockers.map(({ code }) => code).filter(Boolean))],
}));
if (!result.pass) process.exitCode = 2;
+145
View File
@@ -0,0 +1,145 @@
// D09 read-only production probe. It checks public configuration, source reads
// and unauthenticated guards. It never signs in, sends a message, creates a
// checkout, calls an AI model, changes data or submits a tender.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_INTEGRATIONS_EVIDENCE_PATH
|| 'docs/quality/evidence/qa-live-d09-integrations.json';
const timeoutMs = 45_000;
async function request(path, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${base}${path}`, {
...options,
signal: controller.signal,
headers: {
'User-Agent': 'ihaleist-live-integrations-probe/1.0',
...(options.headers || {}),
},
});
const contentType = response.headers.get('content-type') || '';
const body = await response.text();
let json = null;
if (contentType.includes('json')) {
try { json = JSON.parse(body); } catch { /* malformed JSON stays visible as a failed check */ }
}
return {
path,
method: options.method || 'GET',
status: response.status,
contentType,
json,
textChars: body.length,
error: response.ok ? null : String(json?.error?.message || json?.error || body || `HTTP ${response.status}`).slice(0, 300),
};
} catch (error) {
return { path, method: options.method || 'GET', status: 0, contentType: '', json: null, textChars: 0, error: error?.message || String(error) };
} finally {
clearTimeout(timer);
}
}
const raw = {};
for (const [name, path, options] of [
['google', '/api/auth/google/config'],
['tcmb', '/api/exchange-rates'],
['documents', '/api/documents/config'],
['ai', '/api/ai/config'],
['billing', '/api/billing/plans'],
['telegram', '/api/health/takip'],
['scheduler', '/api/health/scheduler'],
['items', '/api/health/kalem'],
['embedding', '/api/health/uyum'],
['bik', '/api/stats'],
['history', '/api/history/stats'],
['resultsRss', '/api/feeds/sonuc.rss?limit=1'],
['protectedRss', '/api/feeds/tenders.rss'],
['manifest', '/manifest.webmanifest'],
['serviceWorker', '/sw.js'],
['ted', '/api/ted?mod=turkiye&since=20990101&limit=1'],
['kik', '/api/kik/search?limit=1'],
['okas', '/api/okas/ara?q=hemogram'],
['mcpGuard', '/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) }],
]) {
raw[name] = await request(path, options);
}
const guardPaths = {
authSession: ['/api/auth/me'],
documents: ['/api/me/documents'],
documentText: ['/api/documents/text?ikn=2026%2F1634449'],
apiKeys: ['/api/me/apikeys'],
emailConfig: ['/api/notify/email/config'],
plan: ['/api/me/plan'],
subscriptions: ['/api/notify/subscriptions'],
sourceHistory: ['/api/tenders/source-history?ikn=2026%2F1634449'],
companyScan: ['/api/company/scan', { method: 'POST' }],
aiAnalyze: ['/api/ai/analyze', { method: 'POST' }],
checkout: ['/api/billing/checkout', { method: 'POST' }],
emailSend: ['/api/notify/email/test', { method: 'POST' }],
telegramMutation: ['/api/me/telegram', { method: 'POST' }],
notificationRun: ['/api/notify/run', { method: 'POST' }],
};
const guards = {};
for (const [name, [path, options]] of Object.entries(guardPaths)) {
const probe = await request(path, options);
guards[name] = { status: probe.status, passed: [401, 403].includes(probe.status) };
}
function row(id, label, status, evidence, remaining) {
return { id, label, status, evidence, remaining };
}
const googleConfigured = raw.google.status === 200 && Boolean(raw.google.json?.clientId);
const tcmbReady = raw.tcmb.status === 200 && raw.tcmb.json?.success === true
&& Number(raw.tcmb.json?.rates?.USD) > 0 && Number(raw.tcmb.json?.rates?.EUR) > 0;
const aiConfigured = raw.ai.status === 200 && raw.ai.json?.configured === true;
const billingConfigured = raw.billing.status === 200 && raw.billing.json?.configured === true;
const billingWebhookReady = raw.billing.status === 200 && raw.billing.json?.webhookHazir === true;
const telegramConfigured = raw.telegram.status === 200 && raw.telegram.json?.telegram === true;
const kikManualGate = raw.kik.status === 428 && raw.kik.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
const ekapManualGate = raw.okas.status === 428 && raw.okas.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
const integrations = [
row('BIK', 'ilan.gov.tr ihale havuzu', raw.bik.status === 200 && raw.bik.json?.success ? 'passed' : 'blocked', { status: raw.bik.status, source: raw.bik.json?.kaynak || null, loaded: raw.bik.json?.yuklenenIlan ?? null }, 'Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir.'),
row('TCMB', 'TCMB döviz satış kurları', tcmbReady ? 'passed' : 'blocked', { status: raw.tcmb.status, bulletinDate: raw.tcmb.json?.bulletinDate || null, source: raw.tcmb.json?.source || null, cached: raw.tcmb.json?.cached ?? null }, tcmbReady ? 'Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor.' : raw.tcmb.error),
row('GOOGLE', 'Google ile giriş', googleConfigured ? 'configured' : 'blocked', { status: raw.google.status, configured: googleConfigured }, 'Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli.'),
row('EKAP', 'EKAP arama ve resmi doküman', raw.okas.status === 200 ? 'partial' : ekapManualGate ? 'manual-gate' : 'blocked', { browserAvailable: raw.documents.json?.tarayiciVar ?? null, okasStatus: raw.okas.status, okasCode: raw.okas.json?.code || null, d10DocumentCode: 'HUMAN_VERIFICATION_REQUIRED' }, 'Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli.'),
row('KIK', 'KİK kurul kararları', raw.kik.status === 200 && raw.kik.json?.success ? 'passed' : kikManualGate ? 'manual-gate' : 'blocked', { status: raw.kik.status, code: raw.kik.json?.code || null, resultCount: raw.kik.json?.results?.length ?? null, error: raw.kik.error }, raw.kik.status === 200 ? 'Bir kararın ayrıntı bağlantısı ayrıca kullanıcı akışında açılmalı.' : kikManualGate ? 'EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli.' : 'Kaynak erişimi düzeltildikten sonra arama ve karar metni tekrar denenmeli.'),
row('TED', 'TED Avrupa ihaleleri', raw.ted.status === 200 && raw.ted.json?.success ? 'passed' : 'blocked', { status: raw.ted.status, totalCount: raw.ted.json?.totalCount ?? null, timedOut: raw.ted.json?.timedOut ?? null, error: raw.ted.error }, raw.ted.status === 200 ? 'Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı.' : 'TED kaynağı tekrar denenmeli.'),
row('AI', 'AI analiz ve teklif yardımcıları', aiConfigured ? 'configured' : 'blocked', { status: raw.ai.status, configured: aiConfigured, providerCount: Array.isArray(raw.ai.json?.providers) ? raw.ai.json.providers.length : 0 }, 'Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli.'),
row('STRIPE', 'Stripe plan, ödeme ve webhook', billingConfigured && billingWebhookReady ? 'configured' : 'blocked', { status: raw.billing.status, checkoutConfigured: billingConfigured, webhookConfigured: billingWebhookReady, planCount: Array.isArray(raw.billing.json?.plans) ? raw.billing.json.plans.length : 0 }, 'Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli.'),
row('EMAIL', 'E-posta bildirimleri', guards.emailConfig?.passed ? 'guarded' : 'blocked', { unauthenticatedStatus: guards.emailConfig?.status ?? null }, 'Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı.'),
row('TELEGRAM', 'Telegram bağlantısı ve bildirim', telegramConfigured ? 'configured' : 'blocked', { status: raw.telegram.status, configured: telegramConfigured }, 'Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli.'),
row('PWA', 'PWA manifest ve offline worker', raw.manifest.status === 200 && raw.serviceWorker.status === 200 ? 'passed' : 'blocked', { manifestStatus: raw.manifest.status, serviceWorkerStatus: raw.serviceWorker.status }, 'Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir.'),
row('API', 'API anahtarı, RSS ve MCP', raw.protectedRss.status === 401 && raw.mcpGuard.status === 401 && guards.apiKeys?.passed ? 'guarded' : 'blocked', { apiKeyGuard: guards.apiKeys?.status ?? null, rssGuard: raw.protectedRss.status, mcpGuard: raw.mcpGuard.status, publicResultsRssStatus: raw.resultsRss.status }, 'Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli.'),
row('SCHEDULER', 'Zeyilname/sonuç zamanlayıcısı', raw.scheduler.status === 200 && raw.scheduler.json?.success ? 'observable' : 'blocked', { status: raw.scheduler.status, browserAvailable: raw.scheduler.json?.tarayiciVar ?? null, resultScanActive: raw.scheduler.json?.sonucTaramasi?.aktif ?? null, resultScanRunning: raw.scheduler.json?.sonucTaramasi?.calisiyor ?? null, directProcurementActive: raw.scheduler.json?.dogrudanTemin?.aktif ?? null, directProcurementRunning: raw.scheduler.json?.dogrudanTemin?.calisiyor ?? null }, 'Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı.'),
];
const result = {
checkedAt: new Date().toISOString(),
base,
readOnly: true,
scope: 'Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.',
integrations,
guards,
summary: {
total: integrations.length,
passed: integrations.filter(({ status }) => status === 'passed').length,
configured: integrations.filter(({ status }) => status === 'configured').length,
guarded: integrations.filter(({ status }) => status === 'guarded').length,
observable: integrations.filter(({ status }) => status === 'observable').length,
manualGate: integrations.filter(({ status }) => status === 'manual-gate').length,
blocked: integrations.filter(({ status }) => status === 'blocked').length,
protectedGuardsPassed: Object.values(guards).filter(({ passed }) => passed).length,
protectedGuardsTotal: Object.keys(guards).length,
},
};
await mkdir(dirname(evidencePath), { recursive: true });
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify({ checkedAt: result.checkedAt, ...result.summary, statuses: Object.fromEntries(integrations.map(({ id, status }) => [id, status])) }));
+56 -5
View File
@@ -2,6 +2,7 @@
// provider calls are performed. It intentionally fails when a required
// deployment contract is missing.
import { mkdir, writeFile } from 'node:fs/promises';
import crypto from 'node:crypto';
const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json';
@@ -13,8 +14,10 @@ const requiredHeaders = [
];
const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])];
const checks = {};
const expectedRelease = String(process.env.IHALEIST_EXPECTED_RELEASE || '').trim();
const expectedAssetSha256 = String(process.env.IHALEIST_EXPECTED_ASSET_SHA256 || '').trim();
async function probe(path, successField = null) {
async function probe(path, successField = null, captureText = false) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
@@ -24,11 +27,14 @@ async function probe(path, successField = null) {
contentType: response.headers.get('content-type'),
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
};
if (successField) {
if (successField || captureText) {
const raw = await response.text();
if (captureText) result.text = raw;
if (successField) {
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
result.apiSuccess = result.json?.[successField] === true;
}
}
return result;
} catch (error) {
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
@@ -37,18 +43,63 @@ async function probe(path, successField = null) {
}
}
checks.home = await probe('/');
async function probeFrontendAsset(publicPath) {
if (!publicPath) return { error: 'Ana sayfada Vite JavaScript asset yolu bulunamadı.' };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(new URL(publicPath, base), { signal: controller.signal, redirect: 'manual' });
const bytes = Buffer.from(await response.arrayBuffer());
return {
status: response.status,
contentType: response.headers.get('content-type'),
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
path: new URL(publicPath, base).pathname,
bytes: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
};
} catch (error) {
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
} finally {
clearTimeout(timer);
}
}
checks.home = await probe('/', null, true);
const assetMatch = checks.home.text?.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
delete checks.home.text;
checks.health = await probe('/api/health', 'ok');
// /api/health and /api/ready use the same { ok: true } probe contract. The
// readiness payload also includes a separate `ready` diagnostic field.
checks.ready = await probe('/api/ready', 'ok');
const missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready].some(check => check?.headers?.[name] == null));
checks.version = await probe('/api/version', 'ok');
// The EKAP document fallback depends on the bundled browser being available;
// check its public capability endpoint without opening a document or mutating
// any account data.
checks.documents = await probe('/api/documents/config', 'tarayiciVar');
checks.frontendAsset = await probeFrontendAsset(assetMatch?.[1]);
const release = checks.version?.json?.release;
const releaseValid = typeof release === 'string' && /^[a-f0-9]{7,64}$/i.test(release);
const releaseMatches = !expectedRelease || release === expectedRelease;
const reportedAsset = checks.version?.json?.frontendAsset;
const assetMatchesRuntime = Boolean(reportedAsset?.sha256)
&& reportedAsset.sha256 === checks.frontendAsset?.sha256
&& reportedAsset.path === checks.frontendAsset?.path;
const assetMatchesExpected = !expectedAssetSha256 || checks.frontendAsset?.sha256 === expectedAssetSha256;
const publicChecks = [checks.home, checks.health, checks.ready, checks.version, checks.documents, checks.frontendAsset];
const missingHeaders = requiredHeaders.filter(name => publicChecks.some(check => check?.headers?.[name] == null));
const report = {
base,
readOnly: true,
checkedAt: new Date().toISOString(),
expectedRelease: expectedRelease || null,
expectedAssetSha256: expectedAssetSha256 || null,
checks,
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && missingHeaders.length === 0,
releaseValid,
releaseMatches,
assetMatchesRuntime,
assetMatchesExpected,
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.version?.status === 200 && checks.version.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && checks.frontendAsset?.status === 200 && releaseValid && releaseMatches && assetMatchesRuntime && assetMatchesExpected && missingHeaders.length === 0,
missingHeaders,
};
+10
View File
@@ -0,0 +1,10 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
test('Nginx proxies MCP JSON-RPC requests to the authenticated Node endpoint', () => {
const config = readFileSync(new URL('../nginx.conf', import.meta.url), 'utf8');
const block = /location\s*=\s*\/mcp\s*\{([\s\S]*?)\n\s*\}/.exec(config)?.[1] || '';
assert.match(block, /proxy_pass\s+http:\/\/127\.0\.0\.1:3001;/);
assert.match(block, /client_max_body_size\s+1m;/);
});
+5 -1
View File
@@ -1,6 +1,6 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {validateOfferResponse} from '../src/services/offerResponse.js';
import {draftReviewLabel,validateOfferResponse} from '../src/services/offerResponse.js';
import {qualifyOfferDraft} from '../server/offer-draft.mjs';
import {qualifyOfferAudit} from '../server/offer-audit.mjs';
import {qualifyQuestionAnswer} from '../server/document-question.mjs';
@@ -16,3 +16,7 @@ test('nested fields that would crash the view are rejected at the boundary',()=>
['teklif-denetim',{maddeler:[{gereklilik:'Numune',durum:'unexpected'}],eksikler:[],uyum_yuzde:100,belirsizSayisi:0}],
['teklif-taslak',{bolumler:[{baslik:'Kapak',icerik:{text:'invalid'}}]}]]) assert.throws(()=>validateOfferResponse('/api/ai/'+url,{success:true,...data}),/beklenen biçimde/);
});
test('partial draft output carries an explicit non-official offer warning',()=>{
assert.equal(draftReviewLabel({status:'partial'}),'EKSİK TASLAK — TEKLİF OLARAK KULLANILAMAZ');
assert.equal(draftReviewLabel({status:'draft'}),'Taslak — kullanıcı kontrolü gerekli');
});
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
clearOfflineMutations,
enqueueTenderMutation,
listOfflineMutations,
offlineMutationCount,
replayOfflineMutations,
} from '../src/services/offlineMutationQueue.js';
const storage = new Map();
globalThis.localStorage = {
getItem: key => storage.has(key) ? storage.get(key) : null,
setItem: (key, value) => storage.set(key, String(value)),
removeItem: key => storage.delete(key),
};
test('offline tender mutations are account scoped and merged by tender', async () => {
clearOfflineMutations('qa@example.test');
enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 2 });
enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { kanbanStatus: 'teklif_hazirlaniyor' }, snapshot: { ikn: '2026/1' }, version: 2 });
enqueueTenderMutation('other@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 0 });
assert.equal(offlineMutationCount('qa@example.test'), 1);
assert.deepEqual(listOfflineMutations('qa@example.test')[0].patch, { takipte: true, kanbanStatus: 'teklif_hazirlaniyor' });
assert.equal(offlineMutationCount('other@example.test'), 1);
clearOfflineMutations('qa@example.test'); clearOfflineMutations('other@example.test');
});
test('replay removes acknowledged rows and retains the first failed row', async () => {
clearOfflineMutations('replay@example.test');
enqueueTenderMutation('replay@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 0 });
enqueueTenderMutation('replay@example.test', { key: 'b', patch: { takipte: true }, snapshot: {}, version: 0 });
const sent = [];
const first = await replayOfflineMutations('replay@example.test', async item => { sent.push(item.key); });
assert.deepEqual(sent, ['a', 'b']);
assert.equal(first.pending, 0);
enqueueTenderMutation('replay@example.test', { key: 'c', patch: { takipte: true }, snapshot: {}, version: 0 });
enqueueTenderMutation('replay@example.test', { key: 'd', patch: { takipte: true }, snapshot: {}, version: 0 });
const second = await replayOfflineMutations('replay@example.test', async item => { if (item.key === 'c') throw new Error('network'); });
assert.equal(second.sent, 0);
assert.equal(listOfflineMutations('replay@example.test')[0].key, 'c');
assert.equal(listOfflineMutations('replay@example.test')[0].attempts, 1);
clearOfflineMutations('replay@example.test');
});
test('concurrent reconnect signals send each queued mutation once', async () => {
clearOfflineMutations('concurrent@example.test');
enqueueTenderMutation('concurrent@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 0 });
let sends = 0;
let release;
const gate = new Promise(resolve => { release = resolve; });
const send = async () => { sends += 1; await gate; return { version: 1 }; };
const first = replayOfflineMutations('concurrent@example.test', send);
const second = replayOfflineMutations('concurrent@example.test', send);
release();
const [a, b] = await Promise.all([first, second]);
assert.equal(sends, 1);
assert.deepEqual(a, b);
assert.equal(offlineMutationCount('concurrent@example.test'), 0);
});
test('a mutation added during replay is retained with the acknowledged server version', async () => {
clearOfflineMutations('during-replay@example.test');
enqueueTenderMutation('during-replay@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 3 });
const first = await replayOfflineMutations('during-replay@example.test', async item => {
enqueueTenderMutation('during-replay@example.test', { key: 'a', patch: { kanbanStatus: 'teklif_hazirlaniyor' }, snapshot: {}, version: item.version });
return { version: 4 };
});
assert.equal(first.pending, 1);
const retained = listOfflineMutations('during-replay@example.test')[0];
assert.equal(retained.version, 4);
assert.deepEqual(retained.patch, { takipte: true, kanbanStatus: 'teklif_hazirlaniyor' });
const versions = [];
await replayOfflineMutations('during-replay@example.test', async item => { versions.push(item.version); return { version: 5 }; });
assert.deepEqual(versions, [4]);
assert.equal(offlineMutationCount('during-replay@example.test'), 0);
});
test('a full queue rejects a new tender instead of silently discarding an older change', () => {
clearOfflineMutations('full@example.test');
for (let index = 0; index < 100; index += 1) {
enqueueTenderMutation('full@example.test', { key: String(index), patch: { takipte: true }, snapshot: {}, version: 0 });
}
assert.throws(
() => enqueueTenderMutation('full@example.test', { key: 'overflow', patch: { takipte: true }, snapshot: {}, version: 0 }),
/kuyruğu dolu/,
);
assert.equal(offlineMutationCount('full@example.test'), 100);
assert.equal(listOfflineMutations('full@example.test')[0].key, '0');
clearOfflineMutations('full@example.test');
});
+7
View File
@@ -24,6 +24,13 @@ test('document revision and vault failures invalidate a previously verified save
assert.ok(preparationSummary({...tender,bidDraft:draft},{...vault,documents:[{...file,boyut:21}]},now).documentIssues.some(text=>text.includes('değişti')));
assert.ok(preparationSummary({...tender,bidDraft:draft},{...vault,error:'offline'},now).documentIssues.some(text=>text.includes('doğrulanamadı')));
});
test('preparation summary exposes document owners without hiding unassigned work',()=>{
const doc={document:'Yetki belgesi'};
const assigned={manualDocuments:[doc],checks:{documents:{[documentKey(doc)]:{owner:' Uyum ekibi '}}}};
const missing={manualDocuments:[doc],checks:{documents:{}}};
assert.deepEqual(preparationSummary({...tender,bidDraft:assigned},vault,now).documentOwners,[{document:'Yetki belgesi',owner:'Uyum ekibi'}]);
assert.deepEqual(preparationSummary({...tender,bidDraft:missing},vault,now).documentOwners,[{document:'Yetki belgesi',owner:'Atanmadı'}]);
});
test('expired source takes priority over completion or manually moved Kanban status',()=>{
const summary=preparationSummary({...tender,tarih:'01.09.2026',kanbanStatus:'teklif_verildi'},vault,now);
assert.match(summary.next.text,/zamanı geçti/);
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { deployedRelease, frontendAssetInfo } from '../server/release-info.mjs';
test('release metadata rejects missing build markers', () => {
assert.equal(deployedRelease({}), null);
assert.equal(deployedRelease({ APP_RELEASE: 'unknown' }), null);
assert.equal(deployedRelease({ SOURCE_COMMIT: 'abc1234' }), 'abc1234');
assert.equal(deployedRelease({ APP_RELEASE: 'release-a', SOURCE_COMMIT: 'release-b' }), 'release-a');
});
test('frontend asset metadata hashes the script referenced by index.html', () => {
const root = mkdtempSync(join(tmpdir(), 'ihaleist-release-'));
try {
mkdirSync(join(root, 'assets'));
writeFileSync(join(root, 'index.html'), '<script type="module" src="/assets/index-test.js"></script>');
writeFileSync(join(root, 'assets/index-test.js'), 'console.log("release");\n');
assert.deepEqual(frontendAssetInfo([root]), {
path: '/assets/index-test.js',
sha256: 'ed81365d3ce25c2e499a43c7d91c72cbc05c6475a19423f18fc3917c96857e80',
bytes: 24,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { sourceFailure } from '../server/source-error.mjs';
test('EKAP human verification is a manual non-retryable gate', () => {
const result = sourceFailure(new Error('EKAP 428: {"code":"HUMAN_VERIFICATION_REQUIRED"}'));
assert.equal(result.status, 428);
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
assert.equal(result.body.retryable, false);
assert.equal(result.body.manualAction, true);
assert.equal(result.body.manualUrl, 'https://ekap.kik.gov.tr/EKAP/');
assert.match(result.body.error, /kendi tarayıcınızda açıp doğrulamayı tamamlayın/);
});
test('ordinary upstream failures stay retryable', () => {
const result = sourceFailure(new Error('upstream timeout'));
assert.equal(result.status, 502);
assert.deepEqual(result.body, {
success: false,
error: 'upstream timeout',
code: 'SOURCE_UNAVAILABLE',
retryable: true,
});
});
test('KIK HTTP 428 is also exposed as the same manual verification gate', () => {
const result = sourceFailure(new Error('KİK sunucusu isteği reddetti (HTTP 428).'));
assert.equal(result.status, 428);
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
assert.equal(result.body.manualAction, true);
});
+17 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {fetchTenderDetail} from '../src/services/tenderApi.js';
import {fetchTenderDetail,fetchOfficialTenderDetail} from '../src/services/tenderApi.js';
test('detail loading rejects empty source, supports retry and stops on timeout/cancel',async()=>{
const original=globalThis.fetch;
try {
@@ -12,6 +12,8 @@ test('detail loading rejects empty source, supports retry and stops on timeout/c
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/HTTP 503/);
globalThis.fetch=async()=>Response.json({success:false,code:'SOURCE_UNAVAILABLE',retryable:true,error:'kaynak yok'},{status:502});
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/geçici olarak erişilemiyor.*yeniden deneyin/);
globalThis.fetch=async()=>Response.json({success:false,code:'HUMAN_VERIFICATION_REQUIRED',retryable:false,manualAction:true,error:'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.'},{status:428});
await assert.rejects(()=>fetchTenderDetail('ekap-2026/123'),/güvenlik doğrulaması.*kendi tarayıcınızda açın/);
globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{
if(signal.aborted) reject(new DOMException('Cancelled','AbortError'));
else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true});
@@ -36,3 +38,17 @@ test('search-only detail uses IKN endpoint and warns when official announcement
await assert.rejects(fetchTenderDetail('ekap-2026/123'),/İKN/);
} finally {globalThis.fetch=original;}
});
test('official tender detail keeps EKAP date authoritative and validates IKN',async()=>{
const original=globalThis.fetch;
try {
globalThis.fetch=async url=>{
assert.equal(url,'/api/documents/ilan?ikn=2026%2F1573235');
return Response.json({success:true,ikn:'2026/1573235',tarih:'16.09.2026',saat:'10:00',sourceDateTime:'16.09.2026 10:00',metin:'EKAP ilanı'});
};
const detail=await fetchOfficialTenderDetail(' 2026/1573235 ');
assert.equal(detail.tarih,'16.09.2026');
globalThis.fetch=async()=>Response.json({success:true,ikn:'2026/999',tarih:'10.09.2026',saat:'10:00'});
await assert.rejects(()=>fetchOfficialTenderDetail('2026/1573235'),/İKN/);
} finally {globalThis.fetch=original;}
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dusunmeAyari, zaiChat, gecici429, zaiAktifIstek } from '../server/zai.mjs';
import { analyzeSpec, routeFor } from '../server/ai.mjs';
import { tedCevir, harfOnar } from '../server/ted-ceviri.mjs';
const ZAI_URL = 'https://api.z.ai/api/coding/paas/v4/chat/completions';
const ok = (content) => new Response(JSON.stringify({ choices: [{ message: { content } }] }), { status: 200 });
const hata429 = (code) => new Response(JSON.stringify({ error: { code, message: 'x' } }), { status: 429 });
function ortam(vars) {
const eski = {};
for (const [k, v] of Object.entries(vars)) { eski[k] = process.env[k]; if (v === undefined) delete process.env[k]; else process.env[k] = v; }
return () => { for (const [k, v] of Object.entries(eski)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } };
}
const TEMEL = { ZAI_API_KEY: 'synthetic-test-only', ZAI_BASE_URL: ZAI_URL, ZAI_RETRY_BASE_MS: '0', ZAI_MAX_CONCURRENCY: '2', OPENROUTER_API_KEY: undefined, GEMINI_API_KEY: undefined };
test('düşünme ayarı modele göre: glm-5 reasoning_effort low, glm-4 thinking kapalı', () => {
const geri = ortam({ ZAI_REASONING_EFFORT: undefined });
try {
assert.deepEqual(dusunmeAyari('glm-5.3'), { reasoning_effort: 'low' });
assert.deepEqual(dusunmeAyari('glm-5.3-flash'), { reasoning_effort: 'low' });
assert.deepEqual(dusunmeAyari('glm-5.3', null), {});
assert.deepEqual(dusunmeAyari('glm-4.7-flash'), { thinking: { type: 'disabled' } });
} finally { geri(); }
});
test('geçici 429 aynı modelle yeniden denenir, bakiye hatası (1113) hemen fırlatılır', async () => {
const geri = ortam(TEMEL); const orijinal = globalThis.fetch;
try {
const govdeler = [];
globalThis.fetch = async (url, { body }) => { govdeler.push(JSON.parse(body)); return govdeler.length === 1 ? hata429('1302') : ok('{"a":1}'); };
const sonuc = await zaiChat('glm-5.3-flash', [{ role: 'user', content: 'x' }], { retries429: 2 });
assert.equal(sonuc, '{"a":1}');
assert.equal(govdeler.length, 2);
assert.equal(govdeler[1].model, 'glm-5.3-flash');
assert.equal(govdeler[1].reasoning_effort, 'low');
assert.deepEqual(govdeler[1].response_format, { type: 'json_object' });
let cagri = 0;
globalThis.fetch = async () => { cagri++; return hata429('1113'); };
await assert.rejects(zaiChat('glm-5.3', [{ role: 'user', content: 'x' }], { retries429: 3 }), /1113/);
assert.equal(cagri, 1);
assert.equal(gecici429('{"error":{"code":"1302"}}'), true);
} finally { globalThis.fetch = orijinal; geri(); }
});
test('eşzamanlı Z.ai isteği sınırı süreç genelinde 2 ile sınırlı', async () => {
const geri = ortam(TEMEL); const orijinal = globalThis.fetch;
try {
let acik = 0, tepe = 0;
globalThis.fetch = async () => { acik++; tepe = Math.max(tepe, acik); await new Promise((r) => setTimeout(r, 20)); acik--; return ok('{}'); };
await Promise.all([1, 2, 3, 4].map(() => zaiChat('glm-5.3-flash', [{ role: 'user', content: 'x' }])));
assert.equal(tepe, 2);
assert.equal(zaiAktifIstek(), 0);
} finally { globalThis.fetch = orijinal; geri(); }
});
test('analyzeSpec Z.ai anahtarı varken GLM 5.3\'ü doğrudan Z.ai\'den çağırır', async () => {
const geri = ortam(TEMEL); const orijinal = globalThis.fetch;
try {
assert.deepEqual(routeFor('zai'), { yol: 'direct', key: 'synthetic-test-only', model: 'glm-5.3' });
const istekler = [];
globalThis.fetch = async (url, { body }) => { istekler.push({ url, body: JSON.parse(body) }); return ok(JSON.stringify({ summary: 'özet', qualificationCriteria: [], criticalRequirements: [], risksAndPenalties: [], bidAnalysis: {} })); };
const out = await analyzeSpec({ text: 'Sentetik şartname metni', chain: ['zai'] });
assert.equal(istekler.length, 1);
assert.equal(istekler[0].url, ZAI_URL);
assert.equal(istekler[0].body.model, 'glm-5.3');
assert.equal(istekler[0].body.reasoning_effort, 'low');
assert.equal(out.model.provider, 'Z.ai');
assert.equal(out.model.yol, 'direct');
assert.equal(out.result.summary, 'özet');
} finally { globalThis.fetch = orijinal; geri(); }
});
test('TED çevirisi OpenRouter anahtarı olmadan önce Z.ai GLM 5.3 ile çalışır', async () => {
const geri = ortam(TEMEL); const orijinal = globalThis.fetch;
try {
const istekler = [];
globalThis.fetch = async (url, { body }) => {
const b = JSON.parse(body); istekler.push({ url, model: b.model });
const ilanlar = JSON.parse(b.messages[1].content).ilanlar;
return ok(JSON.stringify({ ceviriler: ilanlar.map((i) => ({ id: i.id, baslik: `TR ${i.baslik}`, aciklama: '' })) }));
};
const tenders = [{ id: 't1', baslik: 'Austria – Works' }, { id: 't2', baslik: 'Poland – Supplies' }];
const bekleyen = await tedCevir(tenders, { blockMs: 2000 });
assert.equal(bekleyen, 0);
assert.equal(istekler.length, 1);
assert.equal(istekler[0].url, ZAI_URL);
assert.equal(istekler[0].model, 'glm-5.3');
assert.equal(tenders[0].baslikTr, 'TR Austria – Works');
assert.equal(tenders[1].ceviriKaynagi, 'model');
} finally { globalThis.fetch = orijinal; geri(); }
});
test('harfOnar kelime içi büyük İ/I bozulmasını düzeltir, kısaltmalara dokunmaz', () => {
assert.equal(harfOnar('Romanya – Elektrİklİ otobüsler – Elektrİklİ otobüs ve şarj İstasyonlarİ temİNİ'),
'Romanya – Elektrikli otobüsler – Elektrikli otobüs ve şarj İstasyonlari temini');
assert.equal(harfOnar('Denİz Habİtatlarının ve NATURA 2000 Alanlarının Tespİtİ'), 'Deniz Habitatlarının ve NATURA 2000 Alanlarının Tespiti');
assert.equal(harfOnar('İstanbul MEB İl Müdürlüğü'), 'İstanbul MEB İl Müdürlüğü');
assert.equal(harfOnar(null), '');
});