fix: guide EKAP human verification
This commit is contained in:
+127
-40
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user