diff --git a/src/components/TenderList.jsx b/src/components/TenderList.jsx
index 61501de..d0848a8 100644
--- a/src/components/TenderList.jsx
+++ b/src/components/TenderList.jsx
@@ -70,6 +70,7 @@ export const TenderList = () => {
return true;
}).sort((a, b) => {
+ if (sortBy === 'uyum') return (b.aiMatchScore ?? -1) - (a.aiMatchScore ?? -1) || b.id.localeCompare(a.id);
if (sortBy === 'newest') return b.id.localeCompare(a.id);
if (sortBy === 'urgent') return a.tarih.localeCompare(b.tarih);
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
@@ -162,6 +163,7 @@ export const TenderList = () => {
onChange={(e) => setSortBy(e.target.value)}
className="bg-slate-50 border border-slate-300 rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-700 focus:ring-2 focus:ring-blue-500"
>
+
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index e9e0f54..c6a78d7 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -1,5 +1,7 @@
-import React, { createContext, useContext, useState, useEffect } from 'react';
+import React, {createContext, useContext, useState, useEffect, useMemo } from 'react';
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
+import { havuzuPuanla } from '../services/uyum';
+import { getToken } from '../services/auth';
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
import { getAllUserData, setUserData, getPlan } from '../services/account';
@@ -406,36 +408,63 @@ export const AppProvider = ({ children }) => {
}
};
- const [userProfile, setUserProfile] = useState({
- name: 'Hikmet Tanrıverdi',
- username: 'tanriverdi',
- company: 'Tanrıverdi Medikal & Sağlık Teknolojileri A.Ş.',
- website: 'https://www.tanriverdimedikal.com.tr',
- trialDaysLeft: 7,
- role: 'Yönetici Tedarikçi',
- scrapedCapabilities: {
- scrapedAt: null,
- extractedSectors: [],
- extractedProducts: [],
- extractedCertifications: [],
- matchScoreBoost: 0
- }
+ const BOS_YETKINLIK = {
+ scrapedAt: null, extractedSectors: [], extractedProducts: [], extractedCertifications: [],
+ ihaleKonulari: [], matchScoreBoost: 0,
+ };
+ const [userProfile, setUserProfile] = useState(() => {
+ // Yetkinlikler uyum skorunun girdisi — sayfa yenilenince sıfırlanmasın
+ let kayitli = null;
+ try { kayitli = JSON.parse(localStorage.getItem('ihaleist_firma_profil') || 'null'); } catch { /* yoksay */ }
+ return {
+ name: 'Hikmet Tanrıverdi',
+ username: 'tanriverdi',
+ company: kayitli?.company || 'Tanrıverdi Medikal & Sağlık Teknolojileri A.Ş.',
+ website: kayitli?.website || 'https://www.tanriverdimedikal.com.tr',
+ trialDaysLeft: 7,
+ role: 'Yönetici Tedarikçi',
+ scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) },
+ };
});
+ useEffect(() => {
+ const veri = { company: userProfile.company, website: userProfile.website, scrapedCapabilities: userProfile.scrapedCapabilities };
+ yerelKaydet('ihaleist_firma_profil', veri);
+ if (veriSenkron) sunucuyaYaz('firma_profil', veri);
+ }, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, veriSenkron]);
- const scanCompanyWebsite = (url) => {
- return new Promise((resolve) => {
- setTimeout(() => {
- const newCapabilities = parseWebsiteCapabilitiesDynamically(url);
+ // Uyum skoru: havuz ya da profil değişince tüm ihaleler yeniden puanlanır.
+ // Sözlüksel, dış servis yok — 8 bin ihalede anında.
+ const tendersSkorlu = useMemo(() => havuzuPuanla(tenders, userProfile), [tenders, userProfile.scrapedCapabilities]);
- setUserProfile(prev => ({
- ...prev,
- website: url,
- scrapedCapabilities: newCapabilities
- }));
-
- resolve(newCapabilities);
- }, 1500);
- });
+ // Önce gerçek tarama (/api/company/scan: site metni + model), başarısızsa
+ // alan adından tahmin eden eski yol. Sonuç uyum skorunun girdisi olur.
+ const scanCompanyWebsite = async (url) => {
+ let yeni = null;
+ try {
+ const r = await fetch('/api/company/scan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
+ body: JSON.stringify({ url }),
+ });
+ const d = await r.json();
+ if (d.success && d.sonuc) {
+ const so = d.sonuc;
+ yeni = {
+ scrapedAt: new Date().toLocaleString('tr-TR'),
+ extractedSectors: so.sektorler || [],
+ extractedProducts: so.urunler || [],
+ extractedCertifications: so.sertifikalar || [],
+ ihaleKonulari: so.ihaleKonulari || [],
+ ozet: so.ozet || null,
+ tarananSayfa: d.tarananSayfa,
+ matchScoreBoost: 0,
+ gercekTarama: true,
+ };
+ }
+ } catch { /* aşağıda yedek yol */ }
+ if (!yeni) yeni = { ...parseWebsiteCapabilitiesDynamically(url), ihaleKonulari: [], gercekTarama: false };
+ setUserProfile((prev) => ({ ...prev, website: url, scrapedCapabilities: yeni }));
+ return yeni;
};
const updateCompanyCapabilities = (updatedCapabilities) => {
@@ -539,7 +568,7 @@ export const AppProvider = ({ children }) => {
{
switch (id) {
+ case 'uyumlu': return (t.aiMatchScore ?? 0) >= 60; // profil terimleri ihalede geçiyor
case 'favori_kurumlar': return favoriteKurumlar.includes((t.kurum || '').trim());
case 'dogrudan_temin': return t.alimTuru === 'Doğrudan Temin';
case 'ihaleler': return t.alimTuru !== 'Doğrudan Temin' && t.durum !== 'Sonuçlandı';
diff --git a/src/services/uyum.js b/src/services/uyum.js
new file mode 100644
index 0000000..ae9c225
--- /dev/null
+++ b/src/services/uyum.js
@@ -0,0 +1,86 @@
+/**
+ * Şirket–ihale uyum skoru (0-100) + nedenler.
+ *
+ * NEDEN SÖZLÜKSEL: karttaki %Uyumlu rozeti sahteydi (sabit 98/95/91/99).
+ * Profil terimleri (ürünler, sektörler, sertifikalar, ihale konuları) ile
+ * ihalenin başlığı, teklif cetveli kalemleri, OKAS/CPV etiketi ve sektörü
+ * karşılaştırılır. Dış servis yok: 8 bin ihalede anında, deterministik ve
+ * her skorun nedeni gösterilebilir ("‘eldiven’ teklif cetvelinde geçiyor").
+ * (Fikir: kaanvp/kobi-ihale-eslestirme'nin eşik + TF-IDF açıklama deseni.)
+ */
+
+const ASCII = { ı: 'i', İ: 'i', ş: 's', Ş: 's', ğ: 'g', Ğ: 'g', ç: 'c', Ç: 'c', ö: 'o', Ö: 'o', ü: 'u', Ü: 'u' };
+export const norm = (s) =>
+ String(s || '').replace(/[ıİşŞğĞçÇöÖüÜ]/g, (c) => ASCII[c]).toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
+
+// Tek başına anlam taşımayan kelimeler eşleşme sayılmasın
+const DUR = new Set(['ve', 'ile', 'icin', 'alimi', 'alim', 'hizmet', 'hizmeti', 'mal', 'malzeme', 'malzemesi',
+ 'isi', 'is', 'adet', 'muhtelif', 'cesitli', 'genel', 'sistem', 'sistemi', 'urun', 'urunleri', 'san', 'tic', 'ltd', 'sti', 'as']);
+
+/** Profilden aranabilir terimleri çıkarır: [{terim, kaynak, agirlik}] */
+export function profilTerimleri(userProfile) {
+ const c = userProfile?.scrapedCapabilities || {};
+ const ekle = (liste, kaynak, agirlik) => (liste || []).map((t) => ({ terim: norm(t), ham: String(t).trim(), kaynak, agirlik }));
+ const hepsi = [
+ ...ekle(c.extractedProducts, 'ürün', 1.0),
+ ...ekle(c.ihaleKonulari, 'ihale konusu', 1.0),
+ ...ekle(c.extractedSectors, 'sektör', 0.6),
+ ...ekle(c.extractedCertifications, 'sertifika', 0.3),
+ ].filter((t) => t.terim.length >= 3 && !DUR.has(t.terim));
+ // Aynı terim iki kaynaktan gelirse ağırlığı yükseği kalsın
+ const m = new Map();
+ for (const t of hepsi) if (!m.has(t.terim) || m.get(t.terim).agirlik < t.agirlik) m.set(t.terim, t);
+ return [...m.values()];
+}
+
+// Terim metinde geçiyor mu — çok kelimeli terimde tüm anlamlı kelimeler aranır
+function gecerMi(terim, metin) {
+ if (!terim || !metin) return false;
+ if (metin.includes(terim)) return true;
+ const kelimeler = terim.split(' ').filter((k) => k.length >= 4 && !DUR.has(k));
+ return kelimeler.length >= 2 && kelimeler.every((k) => metin.includes(k));
+}
+
+/**
+ * Tek ihale için skor. Profilde terim yoksa null döner — kartta sahte yüzde
+ * göstermek yerine rozet çıkmaz.
+ */
+export function uyumSkoru(tender, terimler) {
+ if (!terimler?.length) return { skor: null, nedenler: [] };
+ const alanlar = [
+ { ad: 'başlık', metin: norm(tender.baslik), agirlik: 1.0 },
+ { ad: 'teklif cetveli', metin: norm([...(tender.kalemler || []), ...(tender.items || []).map((i) => i.ad)].join(' | ')), agirlik: 1.1 },
+ { ad: 'OKAS/CPV', metin: norm(tender.cpvLabel), agirlik: 0.7 },
+ { ad: 'sektör', metin: norm(tender.sektor), agirlik: 0.5 },
+ { ad: 'ilan metni', metin: norm(String(tender.aciklama || '').slice(0, 3000)), agirlik: 0.6 },
+ ];
+ let puan = 0;
+ const nedenler = [];
+ for (const t of terimler) {
+ let enIyi = null;
+ for (const a of alanlar) {
+ if (!a.metin || !gecerMi(t.terim, a.metin)) continue;
+ const katki = t.agirlik * a.agirlik;
+ if (!enIyi || katki > enIyi.katki) enIyi = { alan: a.ad, katki };
+ }
+ if (enIyi) {
+ puan += enIyi.katki;
+ nedenler.push({ terim: t.ham, kaynak: t.kaynak, alan: enIyi.alan, katki: enIyi.katki });
+ }
+ }
+ if (!nedenler.length) return { skor: 0, nedenler: [] };
+ // Doygunluk: 1 güçlü eşleşme ≈ 55, 2 ≈ 75, 3+ ≈ 90; sektör-tek eşleşme ~35
+ const skor = Math.round(100 * (1 - Math.exp(-puan * 0.8)));
+ nedenler.sort((a, b) => b.katki - a.katki);
+ return { skor: Math.max(0, Math.min(100, skor)), nedenler: nedenler.slice(0, 5) };
+}
+
+/** Havuzun tamamını puanlar; alan adları kartın ve detayın okuduğu adlar. */
+export function havuzuPuanla(tenders, userProfile) {
+ const terimler = profilTerimleri(userProfile);
+ if (!terimler.length) return tenders.map((t) => (t.aiMatchScore == null && !t.uyumNedenleri ? t : { ...t, aiMatchScore: null, uyumNedenleri: [] }));
+ return tenders.map((t) => {
+ const { skor, nedenler } = uyumSkoru(t, terimler);
+ return { ...t, aiMatchScore: skor, uyumNedenleri: nedenler };
+ });
+}