From a34b758430a3ed896239f419c7fb611c1a328f4c Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 09:22:05 +0300 Subject: [PATCH] fix: normalize Turkish search and isolate query item matches --- docs/quality/uygulama-durumu.md | 6 ++++++ src/components/TenderList.jsx | 13 ++----------- src/context/AppContext.jsx | 10 ++++++---- src/services/tenderSearch.js | 15 +++++++++++++++ tests/tender-search.test.mjs | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 src/services/tenderSearch.js create mode 100644 tests/tender-search.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 19f69d1..639472f 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -235,3 +235,9 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - ISO/ofsetli zamanlar Türkiye gününe dönüştürülüyor; Türkçe kaynaklardaki ofsetsiz saatler İstanbul kabul ediliyor. Geçersiz takvim günü/saat reddediliyor; varsa ayrı ilan tarihi kullanılıyor. Süre metni veya keyfi kayıt kimliği yayın zamanı olarak yorumlanmıyor. - Bugün eklenenler hesabında bilinen yayın tarihi, eskimiş isYeni bayrağına üstün geliyor. Uyum puanı eşitliğinde de kimlik yerine yayın sırası kullanılıyor; sayısal id nedeniyle localeCompare hatası ortadan kalktı. - Kanıt: yayın zamanı regresyonuyla 63/63 test ve build geçti. UTC/Türkiye gün sınırı, ofsetsiz saat, farklı türde kimlik, geçersiz tarih ve eski isYeni bayrağı doğrulandı. Kaynakların gerçek saat dilimi sözleşmelerinin tek tek teyidi ve gerçek veriyle görsel kabul hâlâ açık. + +### D05 — Türkçe arama ve sorguya bağlı kalem sonuçları + +- Başlık/kurum/İKN/kalem araması ortak normalizasyon kullanıyor: Türkçe büyük-küçük harf, birleşik Unicode karakterleri, noktasız ı ve fazla boşluklar aynı şekilde ele alınıyor. Metin ve nesne biçimindeki kalem özetleri destekleniyor. +- Tam kalem havuzu eşleşmeleri artık üretildikleri sorgu ile saklanıyor. Kullanıcı yeni sorguya geçtiği render anında önceki sorgunun eşleşme haritası kullanılmıyor; debounce/ağ beklerken eski sonuçlar yeni aramaya karışmıyor. Mevcut geç yanıt iptal kontrolü korunuyor. +- Kanıt: Türkçe alan eşleşmeleri ve hemogram → mikroskop sorgu geçişi regresyonlarıyla 65/65 test ve build geçti. Gerçek arama sağlayıcısının tüm eşanlamlı/tıbbi ürün kapsamı ve tarayıcıda geciktirilmiş ağ kabulü henüz doğrulanmadı. diff --git a/src/components/TenderList.jsx b/src/components/TenderList.jsx index 1f6f803..e38ad20 100644 --- a/src/components/TenderList.jsx +++ b/src/components/TenderList.jsx @@ -1,3 +1,4 @@ +import { matchesTenderSearch } from '../services/tenderSearch'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { useApp } from '../context/AppContext'; import { TenderCard } from './TenderCard'; @@ -43,17 +44,7 @@ export const TenderList = () => { const filteredTenders = useMemo(() => { return tenders.filter(tender => { // Search Query - if (searchQuery) { - const q = searchQuery.toLowerCase(); - const matchTitle = (tender.baslik || '').toLowerCase().includes(q); - const matchKurum = (tender.kurum || '').toLowerCase().includes(q); - const matchIkn = (tender.ikn || '').toLowerCase().includes(q); - const matchItems = tender.items && tender.items.some(i => (i.ad || '').toLowerCase().includes(q)); - // Kartta taşınan ilk kalemler + sunucudaki tam kalem araması - const matchKalem = tender.kalemler && tender.kalemler.some(a => (a || '').toLowerCase().includes(q)); - const matchHavuz = kalemEslesen && tender.ikn && kalemEslesen.has(tender.ikn); - if (!matchTitle && !matchKurum && !matchIkn && !matchItems && !matchKalem && !matchHavuz) return false; - } + if (!matchesTenderSearch(tender,searchQuery,kalemEslesen)) return false; // Geçmiş ihaleler: varsayılan olarak GİZLİ. Kullanıcı "geçmişi de göster" // derse (onlyValidOnly=false) hepsi gelir. Tarihi okunamayan kayıt gizlenmez. diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 9fa20cb..4da7367 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,3 +1,4 @@ +import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch'; import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react'; import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService'; import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum'; @@ -54,19 +55,20 @@ export const AppProvider = ({ children }) => { // Kalem havuzunda arama: firmanın kendi ürününü ("eldiven", "ortopedik vida") // hangi ihalelerin teklif cetvelinde geçtiğini bulması için. Kartta yalnızca // ilk birkaç kalem taşınıyor, tam arama sunucuda yapılıyor. - const [kalemEslesen, setKalemEslesen] = useState(null); // Map(ikn → eşleşen kalemler) + const [kalemSonucu, setKalemSonucu] = useState(null); + const kalemEslesen = matchingItemResults(kalemSonucu, searchQuery); useEffect(() => { const q = searchQuery.trim(); - if (q.length < 2) { setKalemEslesen(null); return; } + if (q.length < 2) { setKalemSonucu(null); return; } let iptal = false; const zaman = setTimeout(() => { import('../services/kalem') .then(({ kalemAra }) => kalemAra(q)) .then((sonuclar) => { if (iptal) return; - setKalemEslesen(sonuclar.length ? new Map(sonuclar.map((s) => [s.ikn, s.eslesen])) : null); + setKalemSonucu({query:normalizeTenderSearch(q),matches:sonuclar.length ? new Map(sonuclar.map((s) => [s.ikn, s.eslesen])) : null}); }) - .catch(() => { if (!iptal) setKalemEslesen(null); }); + .catch(() => { if (!iptal) setKalemSonucu(null); }); }, 350); return () => { iptal = true; clearTimeout(zaman); }; }, [searchQuery]); diff --git a/src/services/tenderSearch.js b/src/services/tenderSearch.js new file mode 100644 index 0000000..da8021b --- /dev/null +++ b/src/services/tenderSearch.js @@ -0,0 +1,15 @@ +export const normalizeTenderSearch = value => String(value ?? '').normalize('NFKD') + .toLocaleLowerCase('tr-TR').replace(/ı/g,'i').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,' ').trim(); + +export function matchingItemResults(result, query) { + return result?.query === normalizeTenderSearch(query) ? result.matches : null; +} + +export function matchesTenderSearch(tender, query, itemMatches) { + const q=normalizeTenderSearch(query); + if (!q) return true; + const values=[tender.baslik,tender.kurum,tender.ikn, + ...(tender.items || []).map(item=>item.ad), + ...(tender.kalemler || []).map(item=>typeof item === 'string' ? item : item.ad)]; + return values.some(v=>normalizeTenderSearch(v).includes(q)) || Boolean(itemMatches?.has(tender.ikn)); +} diff --git a/tests/tender-search.test.mjs b/tests/tender-search.test.mjs new file mode 100644 index 0000000..6d55355 --- /dev/null +++ b/tests/tender-search.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {normalizeTenderSearch,matchesTenderSearch,matchingItemResults} from '../src/services/tenderSearch.js'; +test('Turkish search treats case, decomposed characters and spacing consistently across fields',()=>{ + for(const value of ['IĞDIR','Iğdır','igdir',' IĞDIR ']) assert.equal(normalizeTenderSearch(value),'igdir'); + for(const field of ['baslik','kurum']) assert.equal(matchesTenderSearch({[field]:'IĞDIR İL SAĞLIK'},'ığdır'),true); + assert.equal(matchesTenderSearch({items:[{ad:'STERİL TEST KİTİ'}]},'steril test'),true); + assert.equal(matchesTenderSearch({kalemler:['İmmünoloji']},'immunoloji'),true); + assert.equal(matchesTenderSearch({kalemler:[{ad:'Ölçüm'}]},'olcum'),true); + assert.equal(matchesTenderSearch({},' '),true); + assert.equal(matchesTenderSearch({baslik:'Mikroskop'},'hemogram'),false); +}); +test('old item matches are unavailable immediately when a new query is entered',()=>{ + const result={query:normalizeTenderSearch('hemogram'),matches:new Map([['2026/1',[{ad:'Hemogram'}]]])}; + assert.equal(matchingItemResults(result,'HEMOGRAM'),result.matches); + assert.equal(matchingItemResults(result,'mikroskop'),null); + assert.equal(matchesTenderSearch({ikn:'2026/1',baslik:'Genel sarf'},'mikroskop',matchingItemResults(result,'mikroskop')),false); + assert.equal(matchingItemResults(result,''),null); +});