fix: guide EKAP human verification

This commit is contained in:
2026-09-15 17:41:51 +03:00
parent 242e7d8c2e
commit e5e1b12185
10 changed files with 372 additions and 56 deletions
+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);
}
}
});
+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' },
],
}),
];
+127 -40
View File
@@ -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;
+24
View File
@@ -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,
});
});
+2
View File
@@ -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});