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
+8
View File
@@ -555,3 +555,11 @@ Yeniden çıkarımda kullanıcı iptali gerçek okuma hatası gibi kaydedilmez.
Test: gerçek yardımcı fonksiyon üzerinden iptal edilen DOCX okumasında yalnız bir metin isteği, sıfır hata kaydı/kalem yayını; hesap değişimi sonrasında gelen özel Office metninin reddi. Diğer belge ekranları ve çalışan Office sunucu çıkarımının iptali bu turda tamamlanmadı; tarayıcıdan depo kapanış kabulü açık. Test: gerçek yardımcı fonksiyon üzerinden iptal edilen DOCX okumasında yalnız bir metin isteği, sıfır hata kaydı/kalem yayını; hesap değişimi sonrasında gelen özel Office metninin reddi. Diğer belge ekranları ve çalışan Office sunucu çıkarımının iptali bu turda tamamlanmadı; tarayıcıdan depo kapanış kabulü açık.
Sonuç: Tüm 124 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Plan henüz tamamlanmadı; canlı yayın yapılmadı. Sonuç: Tüm 124 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Plan henüz tamamlanmadı; canlı yayın yapılmadı.
### D01 / D06 — İhale belge panelinin işlem yaşam süresi
TenderDocuments hesabı ve İKN'yi anahtar alan ayrı oturum bileşenine taşındı. Bileşen kapanırken PDF/metin okuma sinyali iptal edilir; panel durum güncellemeleri başlangıç oturumu ve etkin görünümle sınırlandırılır. Önizleme, yeniden çıkarım, tek belge analizi ve birleşik evrak okuma aynı sinyali kullanır. Toplu okumada iptal “atlanmış belge” olarak yutulmaz, kalan belgeler ve yeni AI başlatma durur.
PDF yüklemesinden sonra hesap/ihale değişirse çıkarım ve ortak kalem yayınına geçilmez. İptal, okuma hatası kaydı oluşturmaz. AI penceresi açılmadan ve incelenmiş metin analizinden sonra etkin oturum yeniden kontrol edilir. İlan metni edinimi de iptal sinyalini alır. Önceden başlamış AppContext genel AI çağrısının tam iptali ayrı iştir; bu değişiklik başlamış her sunucu işlemini durdurduğu iddiasını taşımaz.
Doğrulama: mevcut 124 test ve üretim derlemesi başarılı. Bu tur yeni birim test eklenmedi; önceki okuyucu/oturum testlerinin kapsadığı yardımcıların panel bağlantıları tamamlandı. İhale değişimi sırasında gerçek OCR ve toplu okuma tarayıcı kabulü açık. Büyük paket uyarıları sürüyor. Canlı yayın yapılmadı.
+54 -19
View File
@@ -1,5 +1,5 @@
import { DocumentTextReview } from './DocumentTextReview'; 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 { documentProcessingLabel } from '../services/documentProcessing';
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../context/AppContext'; import { useApp } from '../context/AppContext';
@@ -14,29 +14,49 @@ import { extractText } from '../services/pdfText';
* - İlan metnini (ilan + OKAS kalemleri) çeker ve doğrudan AI analizine verir * - İ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 { analyzeSpecPdfWithGemini, setIsAiSpecAnalyzerOpen } = useApp();
const [busy, setBusy] = useState(null); const readLifetime = useRef(new AbortController());
const [error, setError] = useState(null); const sessionToken = useRef(getToken());
const [bilgi, setBilgi] = useState(null); const current = () => !readLifetime.current.signal.aborted && Boolean(sessionToken.current) && sessionToken.current === getToken();
const [ilerleme, setIlerleme] = useState(''); const assertCurrent = () => {
const [metinOnizleme, setMetinOnizleme] = useState(null); 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ı // 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). // 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 // Eski kayıtlarda İKN boşluklu gelebiliyor (" 2026/1622878"); EKAP tespiti
// ve sunucu çağrıları boşluksuz değerle yapılır. // ve sunucu çağrıları boşluksuz değerle yapılır.
const ikn = String(tender?.ikn || '').replace(/[\s\u00a0]+/g, '') || undefined; const ikn = String(tender?.ikn || '').replace(/[\s\u00a0]+/g, '') || undefined;
const [belgeler, setBelgeler] = useState([]); const [belgeler, setBelgeler] = useOwnedState([]);
const [yukleniyor, setYukleniyor] = useState(false); const [yukleniyor, setYukleniyor] = useOwnedState(false);
const [listeHata, setListeHata] = useState(null); const [listeHata, setListeHata] = useOwnedState(null);
const [listeBekliyor, setListeBekliyor] = useState(false); const [listeBekliyor, setListeBekliyor] = useOwnedState(false);
const listeIstegi = useRef(0); const listeIstegi = useRef(0);
const onizlemeIstegi = useRef(0); const onizlemeIstegi = useRef(0);
const dosyaRef = useRef(null); const dosyaRef = useRef(null);
const belgeleriYukle = useCallback(async () => { const belgeleriYukle = useCallback(async () => {
if (!ikn) return; if (!ikn || !current()) return;
const request = ++listeIstegi.current; const request = ++listeIstegi.current;
setListeBekliyor(true); setListeHata(null); setListeBekliyor(true); setListeHata(null);
try { try {
@@ -64,19 +84,24 @@ export const TenderDocuments = ({ tender }) => {
try { try {
// Sunucu docx/xlsx/zip için teklif cetveli kalemlerini yüklemede // 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. // 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'}); const cevap = await uploadDocument(f, 'İhale Dokümanı', ikn, {documentType:'ihale'});
assertCurrent();
let kalem = cevap?.kalem?.sayi || null; let kalem = cevap?.kalem?.sayi || null;
let kalemHata = cevap.processing?.status === 'error' ? cevap.processing.error : null; let kalemHata = cevap.processing?.status === 'error' ? cevap.processing.error : null;
if (!kalem && ikn && /\.(pdf|txt|md)$/i.test(f.name)) { if (!kalem && ikn && /\.(pdf|txt|md)$/i.test(f.name)) {
try { try {
let text; let text;
try { ({text} = await extractText(f)); } try { ({text} = await extractText(f,{signal:readLifetime.current.signal})); }
catch (readError) { catch (readError) {
assertCurrent();
if(readError.name === 'AbortError')throw readError;
try { await reportDocumentReadError(cevap.id,cevap.belgeler.find(b=>b.id===cevap.id)?.isleme); } 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)`); } catch { throw new Error(`${readError.message} (hata durumu kaydedilemedi; listeyi yenileyin)`); }
throw readError; throw readError;
} }
const { kalemCikar } = await import('../services/kalem'); const { kalemCikar } = await import('../services/kalem');
assertCurrent();
const d = await kalemCikar({ ikn, metin: text, dosya: f.name, documentId:cevap.id }); const d = await kalemCikar({ ikn, metin: text, dosya: f.name, documentId:cevap.id });
kalem = d.sayi || null; kalem = d.sayi || null;
} catch (error) { kalemHata = error.message; } } catch (error) { kalemHata = error.message; }
@@ -93,14 +118,13 @@ export const TenderDocuments = ({ tender }) => {
}; };
const okunabilirMi = readableDocument; const okunabilirMi = readableDocument;
const belgeMetni = readStoredDocument;
const metniIncele = async (b) => { const metniIncele = async (b) => {
setBusy('metin'); setError(null); setMetinOnizleme(null); setBusy('metin'); setError(null); setMetinOnizleme(null);
const token = getToken(); const token = getToken();
const request = ++onizlemeIstegi.current; const request = ++onizlemeIstegi.current;
try { try {
const result = await inspectStoredDocument(b); const result = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
if (token !== getToken() || request !== onizlemeIstegi.current) return; if (token !== getToken() || request !== onizlemeIstegi.current) return;
setMetinOnizleme({...result,name:b.ad,documentId:b.id,type:b.tur}); setMetinOnizleme({...result,name:b.ad,documentId:b.id,type:b.tur});
} catch (e) { if (token === getToken() && request === onizlemeIstegi.current) setError(e.message); } } catch (e) { if (token === getToken() && request === onizlemeIstegi.current) setError(e.message); }
@@ -110,7 +134,7 @@ export const TenderDocuments = ({ tender }) => {
const kalemleriYenidenCikar = async (b) => { const kalemleriYenidenCikar = async (b) => {
setBusy('kalem'); setError(null); setBilgi(null); setBusy('kalem'); setError(null); setBilgi(null);
try { 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ı.` }); setBilgi({ isleme: `${b.ad}: ${result.sayi || 0} kalem çıkarıldı.` });
} catch (e) { setError(`Kalem çıkarımı tamamlanamadı: ${e.message}. Dosya kaydı korunmuştur.`); } } catch (e) { setError(`Kalem çıkarımı tamamlanamadı: ${e.message}. Dosya kaydı korunmuştur.`); }
finally { await belgeleriYukle(); setBusy(null); } finally { await belgeleriYukle(); setBusy(null); }
@@ -121,9 +145,10 @@ export const TenderDocuments = ({ tender }) => {
setBusy('belge'); setError(null); setBusy('belge'); setError(null);
try { try {
if (!okunabilirMi(b.ad)) throw new Error('Bu dosya türünden metin çıkarılamıyor (PDF, DOCX, XLSX, ZIP ve TXT destekleniyor).'); 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; const metin = reading.text;
if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).'); if (!metin || metin.length < 100) throw new Error('Belgeden analiz için yeterli metin çıkarılamadı (taranmış PDF olabilir).');
assertCurrent();
setIsAiSpecAnalyzerOpen(true); 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 }); 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) { } catch (e) {
@@ -161,10 +186,13 @@ export const TenderDocuments = ({ tender }) => {
}; };
const ilanMetniGetir = async () => { const ilanMetniGetir = async () => {
assertCurrent();
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, { 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(); const d = await r.json();
assertCurrent();
if (!d.success) throw new Error(d.error); if (!d.success) throw new Error(d.error);
return d; return d;
}; };
@@ -175,6 +203,7 @@ export const TenderDocuments = ({ tender }) => {
const d = await ilanMetniGetir(); const d = await ilanMetniGetir();
if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.'); 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 }); setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi });
assertCurrent();
setIsAiSpecAnalyzerOpen(true); setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({ text: d.metin, fileName: `${d.ikn} — EKAP ilan metni`, tender }); await analyzeSpecPdfWithGemini({ text: d.metin, fileName: `${d.ikn} — EKAP ilan metni`, tender });
} catch (e) { } catch (e) {
@@ -200,21 +229,24 @@ export const TenderDocuments = ({ tender }) => {
parcalar.push(`===== EKAP İLAN METNİ (${d.ikn}) =====\n${d.metin}`); parcalar.push(`===== EKAP İLAN METNİ (${d.ikn}) =====\n${d.metin}`);
} }
} catch { } catch {
assertCurrent();
atlanan.push('EKAP ilan metni (alınamadı)'); atlanan.push('EKAP ilan metni (alınamadı)');
extraction.push({fileName:'EKAP ilan metni',complete:false}); extraction.push({fileName:'EKAP ilan metni',complete:false});
} }
} }
for (const b of belgeler) { 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; } if (!okunabilirMi(b.ad)) { atlanan.push(`${b.ad} (metin çıkarılamıyor)`); extraction.push({fileName:b.ad,complete:false}); continue; }
setIlerleme(`${b.ad} okunuyor…`); setIlerleme(`${b.ad} okunuyor…`);
try { try {
const reading = await inspectStoredDocument(b); const reading = await inspectStoredDocument(b,{signal:readLifetime.current.signal});
const t = reading.text; const t = reading.text;
extraction.push({ocr:reading.ocr,pageCount:reading.pageCount,usedPages:reading.usedPages,complete:reading.complete,fileName:b.ad}); 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}`); 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}); } else { atlanan.push(`${b.ad} (yeterli metin yok — taranmış olabilir)`); extraction.push({fileName:b.ad,complete:false}); }
} catch (e) { } catch (e) {
assertCurrent();
atlanan.push(`${b.ad} (${e.message})`); atlanan.push(`${b.ad} (${e.message})`);
extraction.push({fileName:b.ad,complete:false}); extraction.push({fileName:b.ad,complete:false});
} }
@@ -232,6 +264,7 @@ export const TenderDocuments = ({ tender }) => {
} }
setBilgi({ derin: { parcaSayisi, karakter: metin.length, atlanan } }); setBilgi({ derin: { parcaSayisi, karakter: metin.length, atlanan } });
setIlerleme(''); setIlerleme('');
assertCurrent();
setIsAiSpecAnalyzerOpen(true); setIsAiSpecAnalyzerOpen(true);
await analyzeSpecPdfWithGemini({ await analyzeSpecPdfWithGemini({
text: metin, text: metin,
@@ -421,7 +454,9 @@ export const TenderDocuments = ({ tender }) => {
</p> </p>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words text-xs">{metinOnizleme.text || 'Okunabilir metin bulunamadı.'}</pre> <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)=>{ {metinOnizleme.type === 'ihale' && <DocumentTextReview key={metinOnizleme.documentId} documentId={metinOnizleme.documentId} extractedText={metinOnizleme.text} onAnalyze={async(reviewedSource)=>{
assertCurrent();
await analyzeSpecPdfWithGemini({reviewedSource,tender,fileName:metinOnizleme.name}); await analyzeSpecPdfWithGemini({reviewedSource,tender,fileName:metinOnizleme.name});
assertCurrent();
setIsAiSpecAnalyzerOpen(true); setIsAiSpecAnalyzerOpen(true);
}} />} }} />}
<button onClick={()=>setMetinOnizleme(null)} className="text-xs underline">Metin önizlemesini kapat</button> <button onClick={()=>setMetinOnizleme(null)} className="text-xs underline">Metin önizlemesini kapat</button>