diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md
index b094e28..e3ec22b 100644
--- a/docs/quality/uygulama-durumu.md
+++ b/docs/quality/uygulama-durumu.md
@@ -1,5 +1,15 @@
# Düzeltme planı — uygulama durumu
+## 15 Eylül 2026 üretim ve D10 güncellemesi
+
+- 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 düzeltildi. 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.
+
6 Eylül 2026. Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani.
Planın tamamı henüz bitmedi. Canlı yayın yapılmadı.
diff --git a/server/index.js b/server/index.js
index 1d0d4eb..b95653d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -66,6 +66,7 @@ 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.
@@ -663,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 = '';
@@ -842,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)
@@ -854,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
@@ -867,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') {
diff --git a/server/source-error.mjs b/server/source-error.mjs
new file mode 100644
index 0000000..ea7dd19
--- /dev/null
+++ b/server/source-error.mjs
@@ -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\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,
+ },
+ };
+}
diff --git a/src/components/TenderDocuments.jsx b/src/components/TenderDocuments.jsx
index a2a180b..77d8918 100644
--- a/src/components/TenderDocuments.jsx
+++ b/src/components/TenderDocuments.jsx
@@ -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 }) => {
{ekapUrl && (
-
+
- {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: >}
EKAP doküman sayfasını 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.>}
)}
diff --git a/src/services/tenderApi.js b/src/services/tenderApi.js
index f663eed..4e764c9 100644
--- a/src/services/tenderApi.js
+++ b/src/services/tenderApi.js
@@ -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ı');
@@ -69,7 +79,8 @@ export async function fetchOfficialTenderDetail(ikn, {signal, timeoutMs = 30000}
const res = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(cleanIkn)}`, { signal: controller.signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
- if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmî ihale kaynağı geçici olarak erişilemiyor.');
+ 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.');
diff --git a/tests/d10-current-lab-fixture.test.mjs b/tests/d10-current-lab-fixture.test.mjs
new file mode 100644
index 0000000..aee99b1
--- /dev/null
+++ b/tests/d10-current-lab-fixture.test.mjs
@@ -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);
+ }
+ }
+});
diff --git a/tests/fixtures/d10-current-lab.mjs b/tests/fixtures/d10-current-lab.mjs
new file mode 100644
index 0000000..3f13bf3
--- /dev/null
+++ b/tests/fixtures/d10-current-lab.mjs
@@ -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' },
+ ],
+ }),
+];
diff --git a/tests/live-five-probe.mjs b/tests/live-five-probe.mjs
index 909e66b..9fc0e03 100644
--- a/tests/live-five-probe.mjs
+++ b/tests/live-five-probe.mjs
@@ -1,67 +1,154 @@
-// Read-only production acceptance probe for the five health/laboratory tenders.
-// No authentication, writes, payments, messages, or tender submissions.
-import { writeFile } from 'node:fs/promises';
-import { healthAcceptanceCases } from './fixtures/health-acceptance.mjs';
+// 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;
-async function getJson(path) {
+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);
- if (!response.ok || !data || data.success !== true) throw new Error(`${path}: HTTP ${response.status}`);
- return data;
- } finally { clearTimeout(timer); }
+ 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 list = await getJson('/api/tenders?limit=100');
+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 healthAcceptanceCases) {
- const ikn = `2026/${fixture.ikn}`;
- const listed = byIkn.get(ikn);
- if (!listed) throw new Error(`${ikn}: live listede bulunamadı`);
- const detail = await getJson(`/api/tenders/${encodeURIComponent(listed.id)}/detail`);
- const document = await getJson(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`);
- const officialDate = document.sourceDateTime || `${document.tarih || detail.tarih || ''} ${document.saat || detail.saat || ''}`.trim();
- if (!officialDate || !document.ikn || document.ikn !== ikn) throw new Error(`${ikn}: resmi tarih/İKN doğrulanamadı`);
+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,
+ ikn: fixture.ikn,
scenario: fixture.scenario,
- id: listed.id,
- title: listed.baslik,
- institution: listed.kurum,
- city: listed.sehir,
- sector: listed.sektor,
- procedure: listed.usul,
- announcementDate: listed.ilanTarihi,
- tenderDate: document.tarih || detail.tarih || '',
- tenderTime: document.saat || detail.saat || '',
- sourceDateTime: officialDate,
- officialDateSource: 'documents/ilan sourceDateTime',
- announcementDateConflict: ikn === '2026/1573235' && listed.tarih !== document.tarih
- ? `ilan listesi ${listed.tarih}; resmi detay ${document.tarih}` : null,
- detailStatus: detail.success === true,
- documentProbeStatus: 200,
- documentCount: Number(document.dokumanSayisi) || 0,
- announcementCount: Number(document.ilanSayisi) || 0,
- okasCount: Number(document.okasSayisi) || 0,
- documentEndpointSuccess: document.success === true,
- sourceUrl: listed.kaynakUrl,
+ 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: 'Live metadata/detail/document endpoint probes only; no authentication, writes, payments, messages, or submissions.',
+ 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, tenders: tenders.map(({ ikn, tenderDate, tenderTime, documentCount }) => ({ ikn, tenderDate, tenderTime, documentCount })) }));
+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;
diff --git a/tests/source-error.test.mjs b/tests/source-error.test.mjs
new file mode 100644
index 0000000..e56fb00
--- /dev/null
+++ b/tests/source-error.test.mjs
@@ -0,0 +1,24 @@
+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,
+ });
+});
diff --git a/tests/tender-detail-fetch.test.mjs b/tests/tender-detail-fetch.test.mjs
index 4695a2c..15b7859 100644
--- a/tests/tender-detail-fetch.test.mjs
+++ b/tests/tender-detail-fetch.test.mjs
@@ -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});