Share stored document retry flow across tender detail and vault

This commit is contained in:
2026-09-06 09:37:56 +03:00
parent ba1f93bd76
commit 6dc6fadda8
7 changed files with 114 additions and 37 deletions
+6
View File
@@ -307,3 +307,9 @@ Doğrulama: üretim derlemesi geçti; HTTP testi, başarısız çıkarım ve yen
İhale detayındaki yükleme ve yeniden kalem çıkarımı, dosya metni okunamadığında sahiplik kontrollü read-error uç noktasına hata bildiriyor. Sunucu serbest hata metni veya başarı durumu kabul etmiyor; sabit, kullanıcıya yönelik okuma hatasını saklıyor. İstemcinin gördüğü önceki işleme durumu atomik UPDATE koşuluyla karşılaştırılıyor; gecikmiş hata bildirimi daha yeni sonucu ezemiyor (409). Bildirim kaydedilemezse arayüz bunu ayrıca belirtiyor. İhale detayındaki yükleme ve yeniden kalem çıkarımı, dosya metni okunamadığında sahiplik kontrollü read-error uç noktasına hata bildiriyor. Sunucu serbest hata metni veya başarı durumu kabul etmiyor; sabit, kullanıcıya yönelik okuma hatasını saklıyor. İstemcinin gördüğü önceki işleme durumu atomik UPDATE koşuluyla karşılaştırılıyor; gecikmiş hata bildirimi daha yeni sonucu ezemiyor (409). Bildirim kaydedilemezse arayüz bunu ayrıca belirtiyor.
70 test geçti. HTTP kapsamında oturumsuz/başka hesap/firma belgesi reddi, kalıcı hata ve eski durumla tekrar bildirimin 409 olması doğrulandı. Mevcut dosya ve baytları sonraki çıkarım denemelerinde korunuyor. Gerçek PDF ile tarayıcı kabulü ve genel depoya yeniden işleme entegrasyonu açık; bu değişiklik tam D01 kabulü sayılmıyor. 70 test geçti. HTTP kapsamında oturumsuz/başka hesap/firma belgesi reddi, kalıcı hata ve eski durumla tekrar bildirimin 409 olması doğrulandı. Mevcut dosya ve baytları sonraki çıkarım denemelerinde korunuyor. Gerçek PDF ile tarayıcı kabulü ve genel depoya yeniden işleme entegrasyonu açık; bu değişiklik tam D01 kabulü sayılmıyor.
### Genel belge deposunda yeniden işleme
Doküman Deposu'nda İKN bağlantılı, okunabilir ihale belgelerine yeniden kalem çıkarımı eklendi. İhale detayı ve depo artık aynı storedDocument servisiyle mevcut dosyayı okuyor, okuma hatasını kaydediyor ve sahiplik kontrollü kalem aktarımı yapıyor. Özel firma belgeleri bu işleme dahil değil. İşlem sonucu ve liste yenileme hatası ayrı gösteriliyor. Belge okuma sırasında oturum değişirse eski belge metninin yeni oturumla aktarımı veya hata bildirimi durduruluyor.
71 test geçti; üretim derlemesi geçti. Yeni servis testi mevcut dosya ID/İKN/metin aktarımını, okuma hatasının önceki durumla raporlanmasını ve hesap değişikliğinde ikinci isteğin gönderilmemesini doğruluyor. Gerçek tarayıcı PDF kabulü ve tüm depo işlemlerinin hesap değişiminde ekran durumlarını temizlemesi henüz açık.
+4 -34
View File
@@ -1,3 +1,4 @@
import { readableDocument, readStoredDocument, 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';
@@ -88,44 +89,13 @@ export const TenderDocuments = ({ tender }) => {
try { await deleteDocument(id); belgeleriYukle(); } catch (err) { setError(err.message); } try { await deleteDocument(id); belgeleriYukle(); } catch (err) { setError(err.message); }
}; };
// PDF/metin tarayıcıda (pdfjs) okunur; docx/xlsx/zip için tarayıcıda çözücü const okunabilirMi = readableDocument;
// yok, sunucudaki çıkarıcıya sorulur. .doc ve .rar'ın karşılığı yok. const belgeMetni = readStoredDocument;
const tarayicidaOkunur = (ad) => /\.(pdf|txt|md)$/i.test(String(ad || ''));
const sunucudaOkunur = (ad) => /\.(docx|xlsx|zip)$/i.test(String(ad || ''));
const okunabilirMi = (ad) => tarayicidaOkunur(ad) || sunucudaOkunur(ad);
const belgeMetni = async (b) => {
if (sunucudaOkunur(b.ad)) {
const r = await fetch(`/api/me/documents/text?id=${encodeURIComponent(b.id)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
const d = await r.json().catch(() => ({}));
if (!d.success) throw new Error(d.error || 'metin çıkarılamadı');
return d.metin;
}
const r = await fetch(`/api/me/documents/download?id=${encodeURIComponent(b.id)}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!r.ok) throw new Error('belge indirilemedi');
const blob = await r.blob();
// extractText { text, pageCount, usedPages } döndürür — nesneyi metin sanıp
// göndermek analizi sessizce bozuyordu.
const { text } = await extractText(new File([blob], b.ad, { type: b.mime || 'application/pdf' }));
return text;
};
const kalemleriYenidenCikar = async (b) => { const kalemleriYenidenCikar = async (b) => {
setBusy('kalem'); setError(null); setBilgi(null); setBusy('kalem'); setError(null); setBilgi(null);
try { try {
let metin; const result = await retryDocumentExtraction(b);
try { metin = await belgeMetni(b); }
catch (readError) {
try { await reportDocumentReadError(b.id,b.isleme); }
catch { throw new Error(`${readError.message} (hata durumu kaydedilemedi; listeyi yenileyin)`); }
throw readError;
}
const { kalemCikar } = await import('../services/kalem');
const result = await kalemCikar({ ikn, metin, dosya:b.ad, documentId:b.id });
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); }
+24 -1
View File
@@ -1,3 +1,4 @@
import { readableDocument, retryDocumentExtraction } from '../../services/storedDocument';
import { documentProcessingLabel } from '../../services/documentProcessing'; import { documentProcessingLabel } from '../../services/documentProcessing';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
@@ -16,6 +17,7 @@ export const DocumentVault = () => {
const [durum, setDurum] = useState(null); const [durum, setDurum] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [hata, setHata] = useState(null); const [hata, setHata] = useState(null);
const [islemeBilgi, setIslemeBilgi] = useState(null);
const [kategori, setKategori] = useState(KATEGORILER[0]); const [kategori, setKategori] = useState(KATEGORILER[0]);
const dosyaRef = useRef(null); const dosyaRef = useRef(null);
@@ -26,6 +28,19 @@ export const DocumentVault = () => {
}; };
useEffect(yukle, [authUser]); useEffect(yukle, [authUser]);
const yenidenIsle = async (document) => {
setBusy(true); setHata(null); setIslemeBilgi(null);
try {
const result = await retryDocumentExtraction(document);
setIslemeBilgi(`${document.ad}: ${result.sayi || 0} kalem çıkarıldı.`);
} catch (error) { setHata(error.message); }
finally {
try { setDurum(await listDocuments()); }
catch { setHata(previous => [previous, 'Belge listesi yenilenemedi.'].filter(Boolean).join(' ')); }
setBusy(false);
}
};
const dosyaSecildi = async (e) => { const dosyaSecildi = async (e) => {
const f = e.target.files?.[0]; const f = e.target.files?.[0];
if (!f) return; if (!f) return;
@@ -250,6 +265,7 @@ export const DocumentVault = () => {
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{islemeBilgi && <p role="status" className="text-xs text-slate-700">{islemeBilgi}</p>}
{durum.belgeler.map((d) => ( {durum.belgeler.map((d) => (
<div key={d.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs flex items-start justify-between gap-3"> <div key={d.id} className="bg-white rounded-xl border border-slate-200 p-4 shadow-xs flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
@@ -273,7 +289,14 @@ export const DocumentVault = () => {
)} )}
</label> </label>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex flex-wrap items-center gap-1">
{d.tur === 'ihale' && d.ikn && readableDocument(d.ad) && (
<button onClick={() => yenidenIsle(d)} disabled={busy || !d.erisilebilir}
aria-label={`${d.ad} için kalem çıkarımını yeniden dene`}
className="text-[10px] text-blue-700 font-bold disabled:opacity-40">
Kalemleri yeniden çıkar
</button>
)}
<button onClick={() => downloadDocument(d.id, d.ad).catch((e) => setHata(e.message))} <button onClick={() => downloadDocument(d.id, d.ad).catch((e) => setHata(e.message))}
className="text-slate-400 hover:text-teal-600 p-1" title="İndir"> className="text-slate-400 hover:text-teal-600 p-1" title="İndir">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
+1 -1
View File
@@ -2,7 +2,7 @@
* Sunucu tarafı hesap servisleri: plan/kota, kullanıcı verisi, API anahtarları, belgeler. * Sunucu tarafı hesap servisleri: plan/kota, kullanıcı verisi, API anahtarları, belgeler.
* (Önceden bunların hepsi localStorage'daydı veya sahteydi.) * (Önceden bunların hepsi localStorage'daydı veya sahteydi.)
*/ */
import { getToken } from './auth'; import { getToken } from './auth.js';
const auth = () => ({ Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' }); const auth = () => ({ Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' });
+1 -1
View File
@@ -5,7 +5,7 @@
* korunuyor). Bir kullanıcı dokümanı yükleyince kalemler havuza yazılıyor ve * korunuyor). Bir kullanıcı dokümanı yükleyince kalemler havuza yazılıyor ve
* o ihale herkesin kartında kalemleriyle görünüyor. * o ihale herkesin kartında kalemleriyle görünüyor.
*/ */
import { getToken } from './auth'; import { getToken } from './auth.js';
/** Bir ihalenin tüm kalemleri. */ /** Bir ihalenin tüm kalemleri. */
export async function kalemleriGetir(ikn) { export async function kalemleriGetir(ikn) {
+40
View File
@@ -0,0 +1,40 @@
import { getToken } from './auth.js';
import { reportDocumentReadError } from './account.js';
export const readableDocument = (name) => /\.(pdf|txt|md|docx|xlsx|zip)$/i.test(String(name || ''));
export async function readStoredDocument(document) {
if (!readableDocument(document.ad)) throw new Error('Bu dosya biçiminden metin çıkarımı desteklenmiyor.');
const office = /\.(docx|xlsx|zip)$/i.test(document.ad);
const response = await fetch(`/api/me/documents/${office ? 'text' : 'download'}?id=${encodeURIComponent(document.id)}`, {
headers: {Authorization:`Bearer ${getToken()}`},
});
if (office) {
const result = await response.json().catch(() => ({}));
if (!response.ok || !result.success) throw new Error(result.error || 'Belge metni alınamadı.');
return result.metin;
}
if (!response.ok) throw new Error('Belge indirilemedi.');
const blob = await response.blob();
const { extractText } = await import('./pdfText');
return (await extractText(new File([blob],document.ad,{type:document.mime || 'application/octet-stream'}))).text;
}
export async function retryDocumentExtraction(document) {
if (document.tur !== 'ihale' || !document.ikn) throw new Error('İhaleye bağlı ihale dokümanı gerekli.');
const token = getToken();
const checkSession = () => { if (!token || token !== getToken()) throw new Error('Oturum değişti; belgeyi yeniden açın.'); };
checkSession();
let text;
try { text = await readStoredDocument(document); }
catch (error) {
checkSession();
try { await reportDocumentReadError(document.id,document.isleme); }
catch { throw new Error(`${error.message} (hata durumu kaydedilemedi; listeyi yenileyin)`); }
throw error;
}
checkSession();
const { kalemCikar } = await import('./kalem.js');
checkSession();
return kalemCikar({ikn:document.ikn,metin:text,dosya:document.ad,documentId:document.id});
}
+38
View File
@@ -0,0 +1,38 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {retryDocumentExtraction} from '../src/services/storedDocument.js';
test('stored document retry uses owned file ID and stops before a changed session can publish',async()=>{
const originalFetch=globalThis.fetch;
const storage=Object.getOwnPropertyDescriptor(globalThis,'localStorage');
let token='account-a';
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:()=>token}});
const doc={id:'owned-doc',ad:'cetvel.docx',ikn:'2026/123',tur:'ihale',isleme:{status:'awaiting_text'}};
const calls=[];
try {
globalThis.fetch=async(url,options)=>{
calls.push({url,options});
if(url.includes('/text?')) return Response.json({success:true,metin:'Source table'});
return Response.json({success:true,sayi:2});
};
assert.equal((await retryDocumentExtraction(doc)).sayi,2);
const payload=JSON.parse(calls[1].options.body);
assert.equal(payload.documentId,doc.id);assert.equal(payload.ikn,doc.ikn);assert.equal(payload.metin,'Source table');
calls.length=0;
globalThis.fetch=async(url)=>{calls.push(url);token='account-b';return Response.json({success:true,metin:'old account document'});};
await assert.rejects(retryDocumentExtraction(doc),/Oturum değişti/);
assert.equal(calls.length,1);
token='account-a';calls.length=0;
globalThis.fetch=async(url,options)=>{
calls.push({url,options});
return url.includes('/text?') ? Response.json({success:false,error:'Okunamadı'},{status:422}) : Response.json({success:true});
};
await assert.rejects(retryDocumentExtraction(doc),/Okunamadı/);
assert.equal(calls[1].url,'/api/me/documents/read-error');
assert.deepEqual(JSON.parse(calls[1].options.body),{id:doc.id,expectedProcessing:doc.isleme});
assert.equal(calls.length,2);
} finally {
globalThis.fetch=originalFetch;
if(storage) Object.defineProperty(globalThis,'localStorage',storage);else delete globalThis.localStorage;
}
});