Scope tender document workflows to the active account and tender

This commit is contained in:
2026-09-06 10:26:49 +03:00
parent 2164494c32
commit 7e1e5fe0a1
2 changed files with 63 additions and 20 deletions
+55 -20
View File
@@ -1,5 +1,5 @@
import { DocumentTextReview } from './DocumentTextReview';
import { readableDocument, readStoredDocument, inspectStoredDocument, retryDocumentExtraction } from '../services/storedDocument';
import { readableDocument, inspectStoredDocument, retryDocumentExtraction } from '../services/storedDocument';
import { documentProcessingLabel } from '../services/documentProcessing';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../context/AppContext';
@@ -13,30 +13,50 @@ import { extractText } from '../services/pdfText';
* - EKAP doküman bağlantısını üretip kullanıcının tarayıcısında açar
* - İlan metnini (ilan + OKAS kalemleri) çeker ve doğrudan AI analizine verir
*/
export const TenderDocuments = ({ tender }) => {
export const TenderDocuments = ({tender}) => {
const {authUser} = useApp();
return <TenderDocumentSession key={`${authUser?.email || ''}:${tender.ikn || tender.id}`} tender={tender} />;
};
const TenderDocumentSession = ({ tender }) => {
const { analyzeSpecPdfWithGemini, setIsAiSpecAnalyzerOpen } = useApp();
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const [bilgi, setBilgi] = useState(null);
const [ilerleme, setIlerleme] = useState('');
const [metinOnizleme, setMetinOnizleme] = useState(null);
const readLifetime = useRef(new AbortController());
const sessionToken = useRef(getToken());
const current = () => !readLifetime.current.signal.aborted && Boolean(sessionToken.current) && sessionToken.current === getToken();
const assertCurrent = () => {
readLifetime.current.signal.throwIfAborted();
if (!current()) throw new Error('Oturum değişti; belgeyi yeniden açın.');
};
useEffect(() => {
readLifetime.current = new AbortController();
return () => readLifetime.current.abort();
}, []);
const useOwnedState = initial => {
const [value,setValue] = useState(initial);
const setOwned = useCallback(next => {if(current())setValue(next);},[]);
return [value,setOwned];
};
const [busy, setBusy] = useOwnedState(null);
const [error, setError] = useOwnedState(null);
const [bilgi, setBilgi] = useOwnedState(null);
const [ilerleme, setIlerleme] = useOwnedState('');
const [metinOnizleme, setMetinOnizleme] = useOwnedState(null);
// Son üretilen EKAP doküman adresi — yeni sekme engellenirse kullanıcı
// gerçek bir bağlantıya tıklayarak açabilsin (bağlantı tıklaması engellenmez).
const [ekapUrl, setEkapUrl] = useState(null);
const [ekapUrl, setEkapUrl] = useOwnedState(null);
// Eski kayıtlarda İKN boşluklu gelebiliyor (" 2026/1622878"); EKAP tespiti
// ve sunucu çağrıları boşluksuz değerle yapılır.
const ikn = String(tender?.ikn || '').replace(/[\s\u00a0]+/g, '') || undefined;
const [belgeler, setBelgeler] = useState([]);
const [yukleniyor, setYukleniyor] = useState(false);
const [listeHata, setListeHata] = useState(null);
const [listeBekliyor, setListeBekliyor] = useState(false);
const [belgeler, setBelgeler] = useOwnedState([]);
const [yukleniyor, setYukleniyor] = useOwnedState(false);
const [listeHata, setListeHata] = useOwnedState(null);
const [listeBekliyor, setListeBekliyor] = useOwnedState(false);
const listeIstegi = useRef(0);
const onizlemeIstegi = useRef(0);
const dosyaRef = useRef(null);
const belgeleriYukle = useCallback(async () => {
if (!ikn) return;
if (!ikn || !current()) return;
const request = ++listeIstegi.current;
setListeBekliyor(true); setListeHata(null);
try {
@@ -64,19 +84,24 @@ export const TenderDocuments = ({ tender }) => {
try {
// Sunucu docx/xlsx/zip için teklif cetveli kalemlerini yüklemede
// otomatik çıkarır. PDF tarayıcıda çözüldüğü için metni biz göndeririz.
assertCurrent();
const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, {documentType:'ihale'});
assertCurrent();
let kalem = cevap?.kalem?.sayi || null;
let kalemHata = cevap.processing?.status === 'error' ? cevap.processing.error : null;
if (!kalem && ikn && /\.(pdf|txt|md)$/i.test(f.name)) {
try {
let text;
try { ({text} = await extractText(f)); }
try { ({text} = await extractText(f,{signal:readLifetime.current.signal})); }
catch (readError) {
assertCurrent();
if(readError.name === 'AbortError')throw readError;
try { await reportDocumentReadError(cevap.id,cevap.belgeler.find(b=>b.id===cevap.id)?.isleme); }
catch { throw new Error(`${readError.message} (hata durumu kaydedilemedi; listeyi yenileyin)`); }
throw readError;
}
const { kalemCikar } = await import('../services/kalem');
assertCurrent();
const d = await kalemCikar({ ikn, metin: text, dosya: f.name, documentId:cevap.id });
kalem = d.sayi || null;
} catch (error) { kalemHata = error.message; }
@@ -93,14 +118,13 @@ export const TenderDocuments = ({ tender }) => {
};
const okunabilirMi = readableDocument;
const belgeMetni = readStoredDocument;
const metniIncele = async (b) => {
setBusy('metin'); setError(null); setMetinOnizleme(null);
const token = getToken();
const request = ++onizlemeIstegi.current;
try {
const result = await inspectStoredDocument(b);
const result = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
if (token !== getToken() || request !== onizlemeIstegi.current) return;
setMetinOnizleme({...result,name:b.ad,documentId:b.id,type:b.tur});
} catch (e) { if (token === getToken() && request === onizlemeIstegi.current) setError(e.message); }
@@ -110,7 +134,7 @@ export const TenderDocuments = ({ tender }) => {
const kalemleriYenidenCikar = async (b) => {
setBusy('kalem'); setError(null); setBilgi(null);
try {
const result = await retryDocumentExtraction(b);
const result = await retryDocumentExtraction(b,{signal:readLifetime.current.signal});
setBilgi({ isleme: `${b.ad}: ${result.sayi || 0} kalem çıkarıldı.` });
} catch (e) { setError(`Kalem çıkarımı tamamlanamadı: ${e.message}. Dosya kaydı korunmuştur.`); }
finally { await belgeleriYukle(); setBusy(null); }
@@ -121,9 +145,10 @@ export const TenderDocuments = ({ tender }) => {
setBusy('belge'); setError(null);
try {
if (!okunabilirMi(b.ad)) throw new Error('Bu dosya türünden metin çıkarılamıyor (PDF, DOCX, XLSX, ZIP ve TXT destekleniyor).');
const reading = await inspectStoredDocument(b);
const reading = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
const metin = reading.text;
if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).');
assertCurrent();
setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({ text: metin, fileName: b.ad, extraction:{ocr:reading.ocr,pageCount:reading.pageCount,usedPages:reading.usedPages,complete:reading.complete,fileName:b.ad}, tender, mode: mod });
} catch (e) {
@@ -161,10 +186,13 @@ export const TenderDocuments = ({ tender }) => {
};
const ilanMetniGetir = async () => {
assertCurrent();
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
signal:readLifetime.current.signal,
headers: { Authorization: `Bearer ${sessionToken.current}` },
});
const d = await r.json();
assertCurrent();
if (!d.success) throw new Error(d.error);
return d;
};
@@ -175,6 +203,7 @@ export const TenderDocuments = ({ tender }) => {
const d = await ilanMetniGetir();
if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.');
setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi });
assertCurrent();
setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({ text: d.metin, fileName: `${d.ikn} — EKAP ilan metni`, tender });
} catch (e) {
@@ -200,21 +229,24 @@ export const TenderDocuments = ({ tender }) => {
parcalar.push(`===== EKAP İLAN METNİ (${d.ikn}) =====\n${d.metin}`);
}
} catch {
assertCurrent();
atlanan.push('EKAP ilan metni (alınamadı)');
extraction.push({fileName:'EKAP ilan metni',complete:false});
}
}
for (const b of belgeler) {
assertCurrent();
if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); extraction.push({fileName:b.ad,complete:false}); continue; }
setIlerleme(`${b.ad} okunuyor…`);
try {
const reading = await inspectStoredDocument(b);
const reading = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
const t = reading.text;
extraction.push({ocr:reading.ocr,pageCount:reading.pageCount,usedPages:reading.usedPages,complete:reading.complete,fileName:b.ad});
if (t && t.length >= 200) parcalar.push(`===== BELGE: ${b.ad} =====\n${t}`);
else { atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`); extraction.push({fileName:b.ad,complete:false}); }
} catch (e) {
assertCurrent();
atlanan.push(`${b.ad} (${e.message})`);
extraction.push({fileName:b.ad,complete:false});
}
@@ -232,6 +264,7 @@ export const TenderDocuments = ({ tender }) => {
}
setBilgi({ derin: { parcaSayisi, karakter: metin.length, atlanan } });
setIlerleme('');
assertCurrent();
setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({
text: metin,
@@ -421,7 +454,9 @@ export const TenderDocuments = ({ tender }) => {
</p>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words text-xs">{metinOnizleme.text || 'Okunabilir metin bulunamadı.'}</pre>
{metinOnizleme.type === 'ihale' && <DocumentTextReview key={metinOnizleme.documentId} documentId={metinOnizleme.documentId} extractedText={metinOnizleme.text} onAnalyze={async(reviewedSource)=>{
assertCurrent();
await analyzeSpecPdfWithGemini({reviewedSource,tender,fileName:metinOnizleme.name});
assertCurrent();
setIsAiSpecAnalyzerOpen(true);
}} />}
<button onClick={()=>setMetinOnizleme(null)} className="text-xs underline">Metin önizlemesini kapat</button>