From a268b15ad732b46ce613faccb36945edc0439f91 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 20:42:21 +0300 Subject: [PATCH] reconcile official tender dates on detail open --- docs/quality/uygulama-durumu.md | 6 ++++++ src/context/AppContext.jsx | 24 +++++++++++++++++++--- src/services/tenderApi.js | 32 ++++++++++++++++++++++++++++++ tests/tender-detail-fetch.test.mjs | 16 ++++++++++++++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index b849707..380b14e 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -2372,3 +2372,9 @@ Production API’deki beş sağlık/laboratuvar ihalesi (`2026/1582991`, `2026/1 ### 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ı. diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 2434f9f..a38b6a3 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -8,7 +8,7 @@ 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'; @@ -321,9 +321,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 => diff --git a/src/services/tenderApi.js b/src/services/tenderApi.js index 910bb9c..f663eed 100644 --- a/src/services/tenderApi.js +++ b/src/services/tenderApi.js @@ -52,6 +52,38 @@ 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) { + if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmî ihale kaynağı geçici olarak erişilemiyor.'); + 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']; diff --git a/tests/tender-detail-fetch.test.mjs b/tests/tender-detail-fetch.test.mjs index 736cca4..4695a2c 100644 --- a/tests/tender-detail-fetch.test.mjs +++ b/tests/tender-detail-fetch.test.mjs @@ -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 { @@ -36,3 +36,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;} +});