From f7a04dadf523906a4c2d4b069611be584444097a Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 13:31:55 +0300 Subject: [PATCH] Review company scan suggestions before saving profile --- docs/quality/qa-company-profile.json | 18 ++ docs/quality/uygulama-durumu.md | 8 + src/components/CompanyScannerModal.jsx | 382 +++++-------------------- src/context/AppContext.jsx | 64 +---- src/services/companyProfile.js | 15 + tests/company-profile.test.mjs | 13 + 6 files changed, 137 insertions(+), 363 deletions(-) create mode 100644 docs/quality/qa-company-profile.json create mode 100644 src/services/companyProfile.js create mode 100644 tests/company-profile.test.mjs diff --git a/docs/quality/qa-company-profile.json b/docs/quality/qa-company-profile.json new file mode 100644 index 0000000..e1a41ca --- /dev/null +++ b/docs/quality/qa-company-profile.json @@ -0,0 +1,18 @@ +{ + "date": "2026-09-06", + "environment": "localhost:5182 synthetic local user; external AI disabled", + "checks": { + "manualWithoutScan": true, + "discardUnsaved": true, + "saveAcknowledged": true, + "reloadPreserved": true, + "scanFailureVisible": true, + "failurePreservesProduct": true + }, + "limits": [ + "Manual product add, discard, save, reload and failed scan tested", + "Successful scan proposal mapping tested with stub; no real website AI scan", + "Structured brand/model/UTS/service-capacity portfolio remains open", + "Concurrent profile edits from multiple devices not tested" + ] +} diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index b091445..bfe719d 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -889,3 +889,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Kullanım kılavuzuna grup açıklaması eklendi. Kanban yardımı yeni durum seçimi, kaydetme, hazırlık özeti ve desteklenmeyen Slack/Teams gönderimine göre güncellendi. - Yerel normal hesapta dört grup, hazırlık panosunun açılması, normal kullanıcıda AI ayarının gizli olması ve yan panel kısayoluyla grubun değişmesi dahil 6 kontrol geçti (qa-navigation-groups.json). Derleme geçti. Önceki 200 otomatik test tekrar çalıştırılmadı; sunucu/hizmet hesabı değişmedi. - Yeni menünün tam mobil/yalnız-klavye kabulü ve yardım metinlerinin kalan ekranlardaki iddiaları ayrıca incelenmeli. Bu değişiklik gizlenen yönetici araçlarının sunucu yetkilendirmesi yerine geçmez. + +### D07 — Firma taramasını düzenlenebilir öneriye dönüştürme — 6 Eylül 2026 +- İncelemede gerçek tarama hatasının alan adından ürün tahminiyle gizlendiği, zamanlayıcının sahte yüzde/başarı ürettiği ve profilin inceleme öncesinde değiştirildiği görüldü. Bu fallback ve otomatik uygulama kaldırıldı. +- Site taraması ortak zaman aşımı/oturum kontrollü istekle yalnız öneri döndürür; bozuk listeler hata olur. Düzenleme ekranında sektör, ürün/hizmet, sertifika beyanı ve ihale konuları elle eklenebilir/kaldırılabilir. Tarama olmadan boş profilden başlanabilir. +- Profil ve uyum eşiği yalnız Profili kaydet ile sunucu yanıtı beklendikten sonra uygulama durumuna aktarılır. Eski arka plan profil autosave kaldırıldı. Tarama/yerel düzenleme/kapama kendiliğinden profil yazmaz. Hata mevcut ürünleri korur; modal kapanınca tarama istemcisi iptal edilir, hesap değişiminde yerel taslak ayrılır. +- Sahte ilerleme yüzdeleri, konfeti, doğrulanmış sektör/ÜTS iddiaları kaldırıldı. Modal başlığı/alanlar/kapama etiketlendi; odak geri dönüşü, Escape ve Tab sınırı eklendi. +- 202 otomatik test ve derleme geçti. Yerel normal hesapta taramasız ekleme, kaydetmeden kapatma, kayıt onayı, yenileme sonrası kalıcılık, yapılandırılmamış tarama hatası ve mevcut ürünü koruma dahil 6 tarayıcı kontrolü geçti (qa-company-profile.json). Başarılı tarama öneri dönüşümü stub testte doğrulandı; gerçek AI/site taraması yapılmadı. +- Marka/model, yapılandırılmış ÜTS durumu ve servis kapasitesi portföyü henüz eklenmedi. Çok cihazlı eşzamanlı profil düzenleme ve başarılı gerçek tarama kabulü açık. Profil kaydı için mevcut hesap veri deposu kullanılır; bu tur yeni sunucu şeması yoktur. diff --git a/src/components/CompanyScannerModal.jsx b/src/components/CompanyScannerModal.jsx index 5dc5f9a..3c5dec3 100644 --- a/src/components/CompanyScannerModal.jsx +++ b/src/components/CompanyScannerModal.jsx @@ -1,313 +1,69 @@ -import React, { useState } from 'react'; -import { useApp } from '../context/AppContext'; -import { Globe, Bot, Sparkles, CheckCircle2, ShieldCheck, Tag, X, Loader2, Database, ArrowRight } from 'lucide-react'; -import confetti from 'canvas-confetti'; - -export const CompanyScannerModal = () => { - const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities, uyumEsigi, setUyumEsigi, gommeDurum } = useApp(); - - const [urlInput, setUrlInput] = useState(userProfile.website || ''); - const [isScanning, setIsScanning] = useState(false); - const [scanStep, setScanStep] = useState(0); - const [scanSuccess, setScanSuccess] = useState(false); - - const [newProductInput, setNewProductInput] = useState(''); - - if (!isCompanyScannerOpen) return null; - - const handleStartScan = async (e) => { - e.preventDefault(); - if (!urlInput.trim()) return; - - setIsScanning(true); - setScanSuccess(false); - setScanStep(1); - - setTimeout(() => setScanStep(2), 600); - setTimeout(() => setScanStep(3), 1200); - - const capabilities = await scanCompanyWebsite(urlInput); - setIsScanning(false); - setScanSuccess(true); - confetti({ particleCount: 70, spread: 60 }); - }; - - const caps = userProfile.scrapedCapabilities; - - const handleRemoveSector = (indexToRemove) => { - if (!caps?.extractedSectors) return; - const updated = caps.extractedSectors.filter((_, idx) => idx !== indexToRemove); - updateCompanyCapabilities({ extractedSectors: updated }); - }; - - const handleRemoveProduct = (indexToRemove) => { - if (!caps?.extractedProducts) return; - const updated = caps.extractedProducts.filter((_, idx) => idx !== indexToRemove); - updateCompanyCapabilities({ extractedProducts: updated }); - }; - - const handleRemoveCert = (indexToRemove) => { - if (!caps?.extractedCertifications) return; - const updated = caps.extractedCertifications.filter((_, idx) => idx !== indexToRemove); - updateCompanyCapabilities({ extractedCertifications: updated }); - }; - - const handleAddCustomProduct = (e) => { - e.preventDefault(); - if (!newProductInput.trim() || !caps) return; - const updated = [...(caps.extractedProducts || []), newProductInput.trim()]; - updateCompanyCapabilities({ extractedProducts: updated }); - setNewProductInput(''); - }; - - return ( -
-
- - {/* Header */} -
-
-
- -
-
-

- Firma Web Sitesi Tarama & Detaylı Ürün Analiz Motoru -

-

Ürün ve faaliyet alanı önerilerini sitenizden çıkarır; profilinize eklemeden önce doğrulayın.

-
-
- -
- - {/* Modal Body */} -
- - {/* Strict Sector Protection Banner */} -
-
- - Sektör Filtresi Aktif: Sadece web sitenizdeki kesinleşmiş faaliyet alanları ve detaylı ürün kataloğu işlenir. -
- - Açıklanabilir ürün ve sektör eşleşmesi - -
- - {/* URL Form */} -
- -
-
- - setUrlInput(e.target.value)} - placeholder="https://www.firmaniz.com.tr" - className="w-full bg-white border border-slate-300 rounded-lg pl-9 pr-4 py-2.5 text-xs text-slate-900 font-semibold focus:ring-2 focus:ring-blue-500" - /> -
- -
-
- - {/* Scanning Live Indicator */} - {isScanning && ( -
-
- - - Derin Sayfa Metin Analizi ve Ürün Çıkarımı Sürüyor... - - %{(scanStep * 33).toFixed(0)} -
- -
-
-
- -
-

= 1 ? 'text-blue-900 font-bold' : 'text-slate-400'}> - 1. 🌐 {urlInput} alan adına bağlanıldı, harici ve alakasız sektör tanımları elendi. -

-

= 2 ? 'text-blue-900 font-bold' : 'text-slate-400'}> - 2. 📄 Web sitesindeki "Hakkımızda", "Ürünler" ve "Katalog" alt sayfalarındaki ürün isimleri ayıklandı. -

-

= 3 ? 'text-blue-900 font-bold' : 'text-slate-400'}> - 3. 💾 Firmanın gerçek ürün yelpazesi ve ISO/ÜTS kalite belgeleri İhaleist veritabanına işlendi. -

-
-
- )} - - {scanSuccess && ( -
- - Web sitenizdeki ürünler ve doğrudan doğrulanmış sektörler veritabanına işlendi! Alakasız sektörler elendi. -
- )} - - {/* Current Extracted Capabilities Database View */} - {caps && ( -
-
-

- Veritabanındaki Doğrulanmış Firma Ürün & Sektör Profili -

- Son Tarama: {caps.scrapedAt} -
- -
- - {/* Sectors */} -
- - Doğrulanmış Sektörler ({caps.extractedSectors?.length || 0}) - -
- {caps.extractedSectors?.map((s, i) => ( -
- {s} - -
- ))} -
-
- - {/* Products */} -
- - - Detaylı Ürün & Hizmet Kataloğu ({caps.extractedProducts?.length || 0}) - - Sayfadan Ayıklanan Detaylı Kalemler - - - {/* Add Custom Item Form */} -
- setNewProductInput(e.target.value)} - placeholder="+ Yeni Ürün / İhale Kalemi Ekle..." - className="flex-1 bg-white border border-slate-300 rounded-lg px-3 py-1.5 text-xs text-slate-900 font-semibold focus:ring-2 focus:ring-emerald-500" - /> - -
- -
- {caps.extractedProducts?.map((p, i) => ( -
-
- 📦 - {p} -
- -
- ))} -
-
- -
- - {/* Certifications */} -
- - Web Sitesinde Tespit Edilen Sertifika ve Kayıtlar - -
- {caps.extractedCertifications?.map((c, i) => ( - - 📜 {c} - - - ))} -
-
- -
- )} - -
- - {/* Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren. - (Önceki "%98+ doğruluk" cümlesi ölçülmemiş bir iddiaydı, kaldırıldı.) */} -
-
- Uyum eşiği — "Uyumlu" filtresi bu skordan itibaren gösterir - %{uyumEsigi} -
- setUyumEsigi(e.target.value)} - className="w-full accent-blue-600 cursor-pointer" - /> -

- Skor = profil terimlerinin ihalede geçmesi (başlık, teklif cetveli, OKAS) + anlamsal yakınlık bonusu. - {gommeDurum === 'hazir' && <> Anlamsal bonus etkin.} - {gommeDurum === 'bekliyor' && <> Anlamsal bonus hesaplanıyor…} - {(gommeDurum === 'kapali' || gommeDurum === 'hata') && <> Anlamsal bonus şu an kapalı; yalnızca terim eşleşmesi.} -

-
- - {/* Footer */} -
-

- Terim ekledikçe skor keskinleşir; kalem havuzu doldukça teklif cetveli eşleşmeleri devreye girer. -

- -
- -
-
- ); -}; +import React,{useState,useEffect,useRef,useId} from 'react'; +import {useApp} from '../context/AppContext'; +import {getToken} from '../services/auth'; +import {X,Loader2} from 'lucide-react'; +const fields=[['extractedSectors','Sektörler'],['extractedProducts','Ürün ve hizmetler'],['extractedCertifications','Sertifika ve kayıt beyanları'],['ihaleKonulari','İhale konuları']]; +export function CompanyScannerModal(){ + const {isCompanyScannerOpen,authUser}=useApp(); + return isCompanyScannerOpen?:null; +} +function ProfileEditor(){ + const {setIsCompanyScannerOpen,userProfile,scanCompanyWebsite,updateCompanyCapabilities,uyumEsigi}=useApp(); + const [url,setUrl]=useState(userProfile.website || ''),[caps,setCaps]=useState(userProfile.scrapedCapabilities || {}); + const [threshold,setThreshold]=useState(uyumEsigi),[busy,setBusy]=useState(''),[error,setError]=useState(null),[message,setMessage]=useState(''); + const [entries,setEntries]=useState({}); + const alive=useRef(true),controller=useRef(null),locked=useRef(false),root=useRef(null),titleId=useId(); + useEffect(()=>{ + alive.current=true;const opener=document.activeElement;root.current?.querySelector('button')?.focus(); + return()=>{alive.current=false;controller.current?.abort();if(opener?.isConnected)opener.focus();}; + },[]); + const run=async(kind,operation)=>{ + if(locked.current)return;locked.current=true;setBusy(kind);setError(null);setMessage(''); + try{await operation();}catch(e){if(alive.current)setError(e.message);}finally{locked.current=false;if(alive.current)setBusy('');} + }; + const scan=e=>{e.preventDefault();void run('scan',async()=>{ + controller.current=new AbortController(); + const proposal=await scanCompanyWebsite(url,{signal:controller.current.signal}); + if(alive.current){setCaps(proposal);setMessage('Tarama önerileri alındı. Alanları kontrol edip düzenleyin; henüz profilinize kaydedilmedi.');} + });}; + const save=()=>run('save',async()=>{ + await updateCompanyCapabilities(caps,{website:url,threshold}); + if(alive.current)setMessage('Firma profili sunucuya kaydedildi. Eşleşmeler kayıtlı profilinizle güncellenir.'); + }); + const edit=(key,values)=>{setCaps(previous=>({...previous,[key]:values}));setMessage('Kaydedilmemiş değişiklikler var.');}; + const keyDown=e=>{ + if(e.key==='Escape'){e.preventDefault();e.stopPropagation();setIsCompanyScannerOpen(false);} + if(e.key!=='Tab')return; + const controls=[...root.current.querySelectorAll('button,input,select,textarea,a[href]')].filter(el=>!el.disabled&&el.getClientRects().length); + const first=controls[0],last=controls.at(-1); + if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();} + else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();} + }; + return
+
+

Firma ürün ve faaliyet profili

+

Ürünlerinizi elle ekleyebilir veya web sitenizden öneri alabilirsiniz. Tarama, ürün uygunluğunu ya da ÜTS/sertifika geçerliliğini doğrulamaz. Kaydetmeden kapatırsanız bu ekrandaki değişiklikler uygulanmaz.

+
+ + +
+ {busy&&

{busy==='scan'?'Site taranıyor; sonuç bekleniyor…':'Profil kaydediliyor…'}

} + {error&&

{error}

} + {message&&

{message}

} + {caps.ozet&&

Tarama özeti: {caps.ozet}

} +
+ {fields.map(([key,label])=>
+

{label}

+
{e.preventDefault();const value=(entries[key] || '').trim();if(!value)return;edit(key,[...new Set([...(caps[key] || []),value])]);setEntries(previous=>({...previous,[key]:''}));}} className="flex gap-2"> + setEntries(previous=>({...previous,[key]:e.target.value}))} maxLength={300} className="min-w-0 w-full border rounded p-2 text-sm"/> + +
+
    {(caps[key] || []).map((value,index)=>
  • {value}
  • )}
+
)} +
+ +

Uyum skoru terim eşleşmesi ve varsa anlamsal yakınlık girdisidir; başvuru yeterliliği değildir.

+ +
+
; +} diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 21d3f3d..625b445 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -2,7 +2,7 @@ import {mergeServerNotifications,acknowledgeNotifications} from '../services/not import { searchTender } from '../services/searchTender'; import { normalizeTenderSearch, matchingItemResults } from '../services/tenderSearch'; import React, {createContext, useContext, useState, useEffect, useMemo, useRef } from 'react'; -import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService'; +import { scanCompanyProposal } from '../services/companyProfile'; import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum'; import { getToken } from '../services/auth'; import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi'; @@ -479,14 +479,6 @@ export const AppProvider = ({ children }) => { }; const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser())); - useEffect(() => { - const veri = { - company: userProfile.company, website: userProfile.website, - scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi, - }; - if (veriSenkron) ilkYazmaDegil('firma_profil', veri); - }, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, userProfile.uyumEsigi, veriSenkron]); - const setUyumEsigi = (v) => setUserProfile((p) => ({ ...p, uyumEsigi: Math.max(0, Math.min(100, Number(v) || 0)) })); // Anlamsal bonus: profil metni sunucuda gömülür, havuzla kosinüs alınır, // z-skor → 0..20. Profil terimi yoksa ya da oturum yoksa istenmez. Havuz @@ -522,47 +514,20 @@ export const AppProvider = ({ children }) => { [tenders, userProfile.scrapedCapabilities, gommeBonus], ); - // Ö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) => { - const session = getToken(); - let yeni = null; + const scanCompanyWebsite = scanCompanyProposal; + const profileSaveLock=useRef(false); + const updateCompanyCapabilities = async (capabilities,{website=userProfile.website,threshold=userProfile.uyumEsigi}={}) => { + if(!authUser||!veriSenkron)throw new Error('Hesap verileri yüklenmeden profil kaydedilemez.'); + if(profileSaveLock.current)throw new Error('Önceki profil kaydı sürüyor.'); + const session=getToken(); + profileSaveLock.current=true; 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 }; - if(getToken() !== session) throw new Error('Oturum değişti; firma taraması bu hesaba aktarılmadı.'); - setUserProfile((prev) => ({ ...prev, website: url, scrapedCapabilities: yeni })); - return yeni; - }; - - const updateCompanyCapabilities = (updatedCapabilities) => { - setUserProfile(prev => ({ - ...prev, - scrapedCapabilities: { - ...prev.scrapedCapabilities, - ...updatedCapabilities - } - })); + const profile={company:userProfile.company,website,scrapedCapabilities:capabilities,uyumEsigi:Math.max(0,Math.min(100,Number(threshold)||0))}; + await setUserData('firma_profil',profile); + if(getToken()!==session)throw new Error('Oturum değişti; profil bu hesaba aktarılmadı.'); + setUserProfile(previous=>({...previous,...profile})); + return profile; + }finally{profileSaveLock.current=false;} }; const [notifications, setNotifications] = useState([]); @@ -741,7 +706,6 @@ export const AppProvider = ({ children }) => { setActiveTab, tenders: tendersSkorlu, uyumEsigi: userProfile.uyumEsigi, - setUyumEsigi, gommeDurum, setTenders, isLiveMode, diff --git a/src/services/companyProfile.js b/src/services/companyProfile.js new file mode 100644 index 0000000..b6df96e --- /dev/null +++ b/src/services/companyProfile.js @@ -0,0 +1,15 @@ +import {accountRequest,requireObject} from './accountRequest.js'; +export function companyScanProposal(data) { + const result=requireObject(data,'sonuc'); + const array=key=>{ + if(!Array.isArray(result[key])||result[key].some(value=>typeof value!=='string'||!value.trim()))throw new Error('Tarama önerileri eksik veya geçersiz. Profil değiştirilmedi.'); + return [...new Set(result[key].map(value=>value.trim()))]; + }; + return {scrapedAt:new Date().toLocaleString('tr-TR'),extractedSectors:array('sektorler'),extractedProducts:array('urunler'), + extractedCertifications:array('sertifikalar'),ihaleKonulari:array('ihaleKonulari'),ozet:typeof result.ozet==='string'?result.ozet:null, + tarananSayfa:data.tarananSayfa,gercekTarama:true,matchScoreBoost:0}; +} +export async function scanCompanyProposal(url,{signal,request=accountRequest}={}) { + const data=await request('/api/company/scan',{method:'POST',timeoutMs:120000,signal,body:JSON.stringify({url})}); + return companyScanProposal(data); +} diff --git a/tests/company-profile.test.mjs b/tests/company-profile.test.mjs new file mode 100644 index 0000000..0788ce8 --- /dev/null +++ b/tests/company-profile.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {scanCompanyProposal,companyScanProposal} from '../src/services/companyProfile.js'; +test('failed company scan throws instead of fabricating domain-derived products',async()=>{ + await assert.rejects(scanCompanyProposal('https://lab-medical.example',{request:async()=>{throw new Error('Provider unavailable');}}),/Provider unavailable/); + for(const sonuc of [{},{urunler:'kit'},{sektorler:[],urunler:[{}],sertifikalar:[],ihaleKonulari:[]}])assert.throws(()=>companyScanProposal({sonuc})); +}); +test('successful scan is returned as editable source suggestions with no profile write request',async()=>{ + const calls=[];const result=await scanCompanyProposal('https://example.test',{request:async(url,options)=>{calls.push({url,options});return {sonuc:{sektorler:[' Sağlık ','Sağlık'],urunler:['Kit'],sertifikalar:[],ihaleKonulari:['Laboratuvar']},tarananSayfa:2};}}); + assert.equal(calls.length,1);assert.equal(calls[0].url,'/api/company/scan'); + assert.deepEqual(result.extractedSectors,['Sağlık']);assert.deepEqual(result.extractedProducts,['Kit']); + assert.equal(result.gercekTarama,true); +});