reconcile official tender dates on detail open
This commit is contained in:
@@ -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
|
### 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ı.
|
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ı.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React, {createContext, useContext, useState, useEffect, useMemo, useRef }
|
|||||||
import { scanCompanyProposal } from '../services/companyProfile';
|
import { scanCompanyProposal } from '../services/companyProfile';
|
||||||
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
|
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
|
||||||
import { getToken } from '../services/auth';
|
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 { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
||||||
import { getUserDataSnapshot, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
|
import { getUserDataSnapshot, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account';
|
||||||
import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tenderSourceChanges } from '../services/tenderState';
|
import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tenderSourceChanges } from '../services/tenderState';
|
||||||
@@ -321,9 +321,27 @@ export const AppProvider = ({ children }) => {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setDetailLoad({id:t.id,status:'loading'});
|
setDetailLoad({id:t.id,status:'loading'});
|
||||||
fetchTenderDetail(t.id, {signal:controller.signal})
|
fetchTenderDetail(t.id, {signal:controller.signal})
|
||||||
.then(detail => {
|
.then(async detail => {
|
||||||
if (cancelled) return;
|
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});
|
source.sourceChanges = tenderSourceChanges(savedTenderStates.current[tenderStateKey(t)]?.snapshot, {...t,...source});
|
||||||
setDetailLoad({id:t.id,status:'loaded'});
|
setDetailLoad({id:t.id,status:'loaded'});
|
||||||
setActiveTenderDetail(prev =>
|
setActiveTenderDetail(prev =>
|
||||||
|
|||||||
@@ -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.
|
// 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'];
|
const USER_FIELDS = ['kanbanStatus', 'takipte', 'incelendi', 'atananPersonel', 'gorev', 'notes', 'checklist', 'bidDraft'];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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()=>{
|
test('detail loading rejects empty source, supports retry and stops on timeout/cancel',async()=>{
|
||||||
const original=globalThis.fetch;
|
const original=globalThis.fetch;
|
||||||
try {
|
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/);
|
await assert.rejects(fetchTenderDetail('ekap-2026/123'),/İKN/);
|
||||||
} finally {globalThis.fetch=original;}
|
} 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;}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user