Analyze server-resolved document reviews with revision checks
This commit is contained in:
@@ -365,3 +365,11 @@ Bu yalnız sunucu altyapısıdır. Metin düzenleme arayüzü, bu kaydın analiz
|
|||||||
İhale dokümanı “Metni incele” paneline DocumentTextReview eklendi. Kaynakla karşılaştırılmış düzeltme metni, sayfa/düzeltme açıklaması ve tüm sayfaları karşılaştırma beyanı alınır. Metin veya not değişince beyan sıfırlanır. Başarısız kayıt taslağı korur; sürüm uyuşmazlığında kullanıcıya taslağını kopyalayıp güncel kaydı yükleme seçeneği sunulur. Son kayıt sürümü/zamanı ve kaynak değişikliği açık gösterilir. Kayıt başarısı sunucu yanıtıyla gösterilir; bileşen kapanması/oturum değişiminden sonraki yanıtlar uygulanmaz.
|
İhale dokümanı “Metni incele” paneline DocumentTextReview eklendi. Kaynakla karşılaştırılmış düzeltme metni, sayfa/düzeltme açıklaması ve tüm sayfaları karşılaştırma beyanı alınır. Metin veya not değişince beyan sıfırlanır. Başarısız kayıt taslağı korur; sürüm uyuşmazlığında kullanıcıya taslağını kopyalayıp güncel kaydı yükleme seçeneği sunulur. Son kayıt sürümü/zamanı ve kaynak değişikliği açık gösterilir. Kayıt başarısı sunucu yanıtıyla gösterilir; bileşen kapanması/oturum değişiminden sonraki yanıtlar uygulanmaz.
|
||||||
|
|
||||||
79 test ve üretim derlemesi geçti. Formun gerçek tarayıcı kabulü ve kaydedilen incelemenin analizde sunucu tarafından çözülmesi henüz tamamlanmadı. Form, kayıt işleminin tek başına hazır teklif üretmediğini açıkça belirtiyor; mevcut OCR engeli devam ediyor.
|
79 test ve üretim derlemesi geçti. Formun gerçek tarayıcı kabulü ve kaydedilen incelemenin analizde sunucu tarafından çözülmesi henüz tamamlanmadı. Form, kayıt işleminin tek başına hazır teklif üretmediğini açıkça belirtiyor; mevcut OCR engeli devam ediyor.
|
||||||
|
|
||||||
|
### Kaydedilmiş inceleme üzerinden analiz
|
||||||
|
|
||||||
|
İnceleme formunda kaydedilmiş metin/not değişmemişse “Kaydedilmiş incelemeyi analiz et” sunuluyor. İstemci belge ID ve inceleme sürümünü gönderir; sunucu kaynak dosya özeti, sahiplik, İKN ve son inceleme sürümünü kontrol ederek metni DB'den alır. İstemcinin gönderdiği alternatif metin veya OCR onay bayrağı bu yolda kullanılmaz. AI işi bittikten sonra sürüm tekrar doğrulanır; arada değişiklik varsa sonuç reddedilir. Sonuçta sunucu kaynaklı inceleyen hesap, zaman, sürüm, dosya/metin özeti ve not tutulur. Bu yol OCR uyarısını kullanıcı incelemesiyle açıklayabilir; diğer analiz kusurları ve hazırlık kontrolleri aynen uygulanır.
|
||||||
|
|
||||||
|
Hazırlık formu inceleme kaynağını gösterir. İnceleme sürümü kaynak snapshot'ına katıldı; aynı kalemlerle yeni inceleme sonucu geldiğinde eski kaynak kontrolü geçersizleşir.
|
||||||
|
|
||||||
|
79 mevcut test ve ek snapshot testi geçti (80 ayrı test); üretim derlemesi geçti. Kaynak çözümleme testleri başka hesap/İKN, eski revizyon ve değişmiş dosyayı reddediyor. Gerçek AI sağlayıcısıyla tarayıcı kabulü ve çıktı anında sunucudan incelemenin hâlâ güncel olduğunun doğrulanması henüz açık. İnceleme kullanıcı beyanıdır, resmi doğruluk garantisi değildir.
|
||||||
|
|||||||
@@ -41,3 +41,21 @@ export function saveDocumentReview(email,id,input) {
|
|||||||
} catch(error) {try {db.exec('ROLLBACK');} catch {} throw error;}
|
} catch(error) {try {db.exec('ROLLBACK');} catch {} throw error;}
|
||||||
finally {db.close();}
|
finally {db.close();}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a requested review to server-owned text; caller text/approval is never used. */
|
||||||
|
export function reviewedAnalysisSource(email,reference,tenderKey) {
|
||||||
|
if(!reference || typeof reference.documentId !== 'string' || !Number.isInteger(reference.revision))
|
||||||
|
throw fail(400,'Geçerli belge ve inceleme sürümü gerekli.');
|
||||||
|
const db=openDb();
|
||||||
|
try {
|
||||||
|
const fileHash=source(db,email,reference.documentId);
|
||||||
|
const doc=db.prepare('SELECT ad,ikn FROM belgeler WHERE id=? AND email=?').get(reference.documentId,email);
|
||||||
|
if(!doc.ikn || doc.ikn!==tenderKey) throw fail(409,'İnceleme bu ihaleye bağlı değil.');
|
||||||
|
const review=latest(db,email,reference.documentId);
|
||||||
|
if(!review || review.revision!==reference.revision || review.file_hash!==fileHash)
|
||||||
|
throw fail(409,'İnceleme veya kaynak dosya değişti; güncel incelemeyi açın.');
|
||||||
|
return {text:review.corrected_text,fileName:doc.ad,
|
||||||
|
humanReview:{documentId:reference.documentId,revision:review.revision,fileHash,textHash:review.text_hash,
|
||||||
|
reviewedBy:email,reviewedAt:review.reviewed_at,note:review.review_note}};
|
||||||
|
} finally {db.close();}
|
||||||
|
}
|
||||||
|
|||||||
+13
-6
@@ -1,4 +1,4 @@
|
|||||||
import {documentReview,saveDocumentReview} from './document-review.mjs';
|
import {documentReview,saveDocumentReview,reviewedAnalysisSource} from './document-review.mjs';
|
||||||
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
|
import { documentMetadataPatch, decodeDocumentUpload } from './document-metadata.mjs';
|
||||||
import { deadlineInfo } from '../src/services/tenderCalendar.js';
|
import { deadlineInfo } from '../src/services/tenderCalendar.js';
|
||||||
import { exchangeRates } from './exchange-rates.mjs';
|
import { exchangeRates } from './exchange-rates.mjs';
|
||||||
@@ -1300,20 +1300,27 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const body = await readBody(req, 2 * 1024 * 1024);
|
const body = await readBody(req, 2 * 1024 * 1024);
|
||||||
if (!body.text || String(body.text).trim().length < 40) {
|
const reviewed=body.reviewedSource ? reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn) : null;
|
||||||
|
const sourceText=reviewed ? reviewed.text : body.text;
|
||||||
|
if (!sourceText || String(sourceText).trim().length < 40) {
|
||||||
return json(res, 400, { success: false, error: 'Analiz için yeterli metin yok (en az 40 karakter).' });
|
return json(res, 400, { success: false, error: 'Analiz için yeterli metin yok (en az 40 karakter).' });
|
||||||
}
|
}
|
||||||
const out = await analyzeSpec({
|
const out = await analyzeSpec({
|
||||||
text: body.text,
|
text: sourceText,
|
||||||
fileName: body.fileName,
|
fileName: reviewed?.fileName || body.fileName,
|
||||||
extraction: body.extraction,
|
extraction: reviewed ? {fileName:reviewed.fileName,complete:true,ocr:false} : body.extraction,
|
||||||
tender: body.tender,
|
tender: body.tender,
|
||||||
chain: body.chain,
|
chain: body.chain,
|
||||||
mode: body.mode === 'derin' ? 'derin' : undefined,
|
mode: body.mode === 'derin' ? 'derin' : undefined,
|
||||||
});
|
});
|
||||||
|
if (reviewed) {
|
||||||
|
const latest=reviewedAnalysisSource(user.email,body.reviewedSource,body.tender?.ikn);
|
||||||
|
if(latest.humanReview.textHash!==reviewed.humanReview.textHash) throw Object.assign(new Error('İnceleme analiz sırasında değişti.'),{status:409});
|
||||||
|
out.result.sourceReview={...out.result.sourceReview,humanReview:reviewed.humanReview};
|
||||||
|
}
|
||||||
return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() });
|
return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return json(res, 502, { success: false, error: e.message, attempts: e.attempts || null });
|
return json(res, e.status || 502, { success: false, error: e.message, attempts: e.attempts || null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
{kaynakGecerlilik.status === 'conflict' && <p role="alert" className="text-red-700">Kaynaklarda farklı süreler var. Güncel resmi dokümanı belirleyip analizi yenileyin; çelişki çözülmeden çıktı alınamaz.</p>}
|
{kaynakGecerlilik.status === 'conflict' && <p role="alert" className="text-red-700">Kaynaklarda farklı süreler var. Güncel resmi dokümanı belirleyip analizi yenileyin; çelişki çözülmeden çıktı alınamaz.</p>}
|
||||||
{[...new Set(kaynakGecerlilik.evidence.map(e=>e.quote))].map(quote=><blockquote key={quote}>{quote}</blockquote>)}
|
{[...new Set(kaynakGecerlilik.evidence.map(e=>e.quote))].map(quote=><blockquote key={quote}>{quote}</blockquote>)}
|
||||||
</div>}
|
</div>}
|
||||||
|
{effectiveResults.sourceReview?.humanReview && <p className="text-xs text-slate-600">
|
||||||
|
Metin incelemesi: sürüm {effectiveResults.sourceReview.humanReview.revision} · {effectiveResults.sourceReview.humanReview.reviewedBy} · {effectiveResults.sourceReview.humanReview.reviewedAt}. Kullanıcı karşılaştırmasıdır; resmi onay değildir.
|
||||||
|
</p>}
|
||||||
{analizEksik && <p role="alert" className="text-xs text-red-700">Analiz kısmi: kaynak, OCR veya kalem bilgisi kontrol gerektiriyor. {(effectiveResults.sourceReview.issues || []).join(' ')} Tam ve doğrulanabilir kaynakla analizi tamamlayın.</p>}
|
{analizEksik && <p role="alert" className="text-xs text-red-700">Analiz kısmi: kaynak, OCR veya kalem bilgisi kontrol gerektiriyor. {(effectiveResults.sourceReview.issues || []).join(' ')} Tam ve doğrulanabilir kaynakla analizi tamamlayın.</p>}
|
||||||
{/* Sizden beklenen bilgiler */}
|
{/* Sizden beklenen bilgiler */}
|
||||||
{eksikler.length > 0 && (
|
{eksikler.length > 0 && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React,{useEffect,useRef,useState} from 'react';
|
|||||||
import {getDocumentTextReview,saveDocumentTextReview} from '../services/account';
|
import {getDocumentTextReview,saveDocumentTextReview} from '../services/account';
|
||||||
import {getToken} from '../services/auth';
|
import {getToken} from '../services/auth';
|
||||||
|
|
||||||
export function DocumentTextReview({documentId,extractedText}) {
|
export function DocumentTextReview({documentId,extractedText,onAnalyze}) {
|
||||||
const [source,setSource]=useState(null);
|
const [source,setSource]=useState(null);
|
||||||
const [text,setText]=useState(extractedText || '');
|
const [text,setText]=useState(extractedText || '');
|
||||||
const [note,setNote]=useState('');
|
const [note,setNote]=useState('');
|
||||||
@@ -10,6 +10,7 @@ export function DocumentTextReview({documentId,extractedText}) {
|
|||||||
const [busy,setBusy]=useState(false);
|
const [busy,setBusy]=useState(false);
|
||||||
const [error,setError]=useState(null);
|
const [error,setError]=useState(null);
|
||||||
const [saved,setSaved]=useState(null);
|
const [saved,setSaved]=useState(null);
|
||||||
|
const [analyzing,setAnalyzing]=useState(false);
|
||||||
const live=useRef(true);
|
const live=useRef(true);
|
||||||
const sequence=useRef(0);
|
const sequence=useRef(0);
|
||||||
const load=async()=>{
|
const load=async()=>{
|
||||||
@@ -38,27 +39,39 @@ export function DocumentTextReview({documentId,extractedText}) {
|
|||||||
} catch(e) {if(live.current && token===getToken())setError(e.message);}
|
} catch(e) {if(live.current && token===getToken())setError(e.message);}
|
||||||
finally {if(live.current && token===getToken())setBusy(false);}
|
finally {if(live.current && token===getToken())setBusy(false);}
|
||||||
};
|
};
|
||||||
|
const analyze=async()=>{
|
||||||
|
if(!source?.review || source.stale || busy || analyzing)return;
|
||||||
|
const token=getToken();setAnalyzing(true);setError(null);
|
||||||
|
try {await onAnalyze({documentId,revision:source.review.revision});}
|
||||||
|
catch(e) {if(live.current && token===getToken())setError(e.message);}
|
||||||
|
finally {if(live.current && token===getToken())setAnalyzing(false);}
|
||||||
|
};
|
||||||
return <section aria-label="Belge metni incelemesi" className="border-t pt-3 space-y-2">
|
return <section aria-label="Belge metni incelemesi" className="border-t pt-3 space-y-2">
|
||||||
<h5 className="font-bold text-sm">Metni karşılaştır ve düzelt</h5>
|
<h5 className="font-bold text-sm">Metni karşılaştır ve düzelt</h5>
|
||||||
<p className="text-xs text-slate-600">Belgenin tüm sayfalarını açıp metin, miktar ve birimleri karşılaştırın. Bu kayıt kullanıcı incelemesidir; resmî onay veya e-imza değildir. Kaydetmek tek başına teklif dosyasını hazır yapmaz.</p>
|
<p className="text-xs text-slate-600">Belgenin tüm sayfalarını açıp metin, miktar ve birimleri karşılaştırın. Bu kayıt kullanıcı incelemesidir; resmî onay veya e-imza değildir. Kaydetmek tek başına teklif dosyasını hazır yapmaz.</p>
|
||||||
{source?.stale && <p role="alert" className="text-xs text-amber-800">Kaynak dosya değişmiş. Önceki inceleme geçerli değil; tüm sayfaları yeniden karşılaştırın.</p>}
|
{source?.stale && <p role="alert" className="text-xs text-amber-800">Kaynak dosya değişmiş. Önceki inceleme geçerli değil; tüm sayfaları yeniden karşılaştırın.</p>}
|
||||||
{source?.review && <p className="text-xs text-slate-500">Son kayıt: sürüm {source.review.revision} · {new Date(source.review.reviewed_at).toLocaleString('tr-TR')}</p>}
|
{source?.review && <p className="text-xs text-slate-500">Son kayıt: sürüm {source.review.revision} · {new Date(source.review.reviewed_at).toLocaleString('tr-TR')}</p>}
|
||||||
<label className="block text-xs font-semibold">Kontrol edilen metin
|
<label className="block text-xs font-semibold">Kontrol edilen metin
|
||||||
<textarea value={text} onChange={e=>{setText(e.target.value);setCompared(false);setSaved(null);}} disabled={busy}
|
<textarea value={text} onChange={e=>{setText(e.target.value);setCompared(false);setSaved(null);}} disabled={busy || analyzing}
|
||||||
className="block w-full h-64 border rounded p-2 font-mono font-normal mt-1" />
|
className="block w-full h-64 border rounded p-2 font-mono font-normal mt-1" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs font-semibold">Karşılaştırılan sayfalar ve düzeltmeler
|
<label className="block text-xs font-semibold">Karşılaştırılan sayfalar ve düzeltmeler
|
||||||
<textarea value={note} onChange={e=>{setNote(e.target.value);setCompared(false);setSaved(null);}} disabled={busy}
|
<textarea value={note} onChange={e=>{setNote(e.target.value);setCompared(false);setSaved(null);}} disabled={busy || analyzing}
|
||||||
placeholder="Örnek: Sayfa 1–3 karşılaştırıldı; 2. sayfadaki miktar 100 olarak düzeltildi."
|
placeholder="Örnek: Sayfa 1–3 karşılaştırıldı; 2. sayfadaki miktar 100 olarak düzeltildi."
|
||||||
className="block w-full border rounded p-2 font-normal mt-1" />
|
className="block w-full border rounded p-2 font-normal mt-1" />
|
||||||
</label>
|
</label>
|
||||||
<label className="flex gap-2 text-xs"><input type="checkbox" checked={compared} onChange={e=>setCompared(e.target.checked)} disabled={busy}/>
|
<label className="flex gap-2 text-xs"><input type="checkbox" checked={compared} onChange={e=>setCompared(e.target.checked)} disabled={busy || analyzing}/>
|
||||||
Bu metni kaynak dosyanın tüm sayfalarıyla karşılaştırdım.</label>
|
Bu metni kaynak dosyanın tüm sayfalarıyla karşılaştırdım.</label>
|
||||||
<button onClick={save} disabled={busy || !source || !compared || text.trim().length<40 || note.trim().length<20}
|
<button onClick={save} disabled={busy || analyzing || !source || !compared || text.trim().length<40 || note.trim().length<20}
|
||||||
className="text-xs bg-blue-700 text-white px-3 py-2 rounded disabled:opacity-40">{busy ? 'İşleniyor…' : 'İncelemeyi kaydet'}</button>
|
className="text-xs bg-blue-700 text-white px-3 py-2 rounded disabled:opacity-40">{busy ? 'İşleniyor…' : 'İncelemeyi kaydet'}</button>
|
||||||
|
{onAnalyze && source?.review && !source.stale && <button onClick={analyze}
|
||||||
|
disabled={busy || analyzing || text.trim()!==source.review.corrected_text || note.trim()!==source.review.review_note}
|
||||||
|
className="ml-2 text-xs bg-amber-500 px-3 py-2 rounded disabled:opacity-40">
|
||||||
|
{analyzing ? 'İncelenmiş metin analiz ediliyor…' : 'Kaydedilmiş incelemeyi analiz et'}
|
||||||
|
</button>}
|
||||||
{error && <div role="alert" className="text-xs text-red-700">{error}
|
{error && <div role="alert" className="text-xs text-red-700">{error}
|
||||||
<p>Metin taslağınız bu ekranda korunuyor. Sürüm değiştiyse taslağı kopyalayıp güncel kaydı yükleyin.</p>
|
<p>Metin taslağınız bu ekranda korunuyor. Sürüm değiştiyse taslağı kopyalayıp güncel kaydı yükleyin.</p>
|
||||||
<button onClick={load} disabled={busy} className="underline">Güncel kaydı yükle (taslağı değiştirir)</button>
|
<button onClick={load} disabled={busy || analyzing} className="underline">Güncel kaydı yükle (taslağı değiştirir)</button>
|
||||||
</div>}
|
</div>}
|
||||||
{saved && <p role="status" className="text-xs text-green-800">İnceleme sürüm {saved.revision} sunucuda kaydedildi. Analiz ve teklif kontrolleri ayrıca tamamlanmalıdır.</p>}
|
{saved && <p role="status" className="text-xs text-green-800">İnceleme sürüm {saved.revision} sunucuda kaydedildi. Analiz ve teklif kontrolleri ayrıca tamamlanmalıdır.</p>}
|
||||||
</section>;
|
</section>;
|
||||||
|
|||||||
@@ -420,7 +420,10 @@ export const TenderDocuments = ({ tender }) => {
|
|||||||
Metni resmi dokümanla karşılaştırın; okunmuş olması doğruluğunun onaylandığı anlamına gelmez.
|
Metni resmi dokümanla karşılaştırın; okunmuş olması doğruluğunun onaylandığı anlamına gelmez.
|
||||||
</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} />}
|
{metinOnizleme.type === 'ihale' && <DocumentTextReview key={metinOnizleme.documentId} documentId={metinOnizleme.documentId} extractedText={metinOnizleme.text} onAnalyze={async(reviewedSource)=>{
|
||||||
|
await analyzeSpecPdfWithGemini({reviewedSource,tender,fileName:metinOnizleme.name});
|
||||||
|
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>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
|
|||||||
@@ -428,13 +428,13 @@ export const AppProvider = ({ children }) => {
|
|||||||
// Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
|
// Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde
|
||||||
// seçilen model zinciri (fallback) ile.
|
// seçilen model zinciri (fallback) ile.
|
||||||
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
|
// mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir
|
||||||
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction } = {}) => {
|
const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode, extraction, reviewedSource } = {}) => {
|
||||||
const session = getToken();
|
const session = getToken();
|
||||||
setAiAnalyzing(true);
|
setAiAnalyzing(true);
|
||||||
setAiAnalysisResults(null);
|
setAiAnalysisResults(null);
|
||||||
try {
|
try {
|
||||||
const { analyzeSpecText } = await import('../services/ai');
|
const { analyzeSpecText } = await import('../services/ai');
|
||||||
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction });
|
const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain, mode, extraction, reviewedSource });
|
||||||
const result = {
|
const result = {
|
||||||
...data.result,
|
...data.result,
|
||||||
tenderKey: tender?.ikn || tender?.id || null,
|
tenderKey: tender?.ikn || tender?.id || null,
|
||||||
|
|||||||
+2
-2
@@ -13,11 +13,11 @@ export async function getAiConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeSpecText({ text, fileName, tender, chain, mode, extraction }) {
|
export async function analyzeSpecText({ text, fileName, tender, chain, mode, extraction, reviewedSource }) {
|
||||||
const res = await fetch('/api/ai/analyze', {
|
const res = await fetch('/api/ai/analyze', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||||
body: JSON.stringify({ text, fileName, tender, chain, mode, extraction }),
|
body: JSON.stringify({ text, fileName, tender, chain, mode, extraction, reviewedSource }),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function bidSourceSnapshot(tender, results, rows = sourceRows(tender, res
|
|||||||
deadline: [tender?.tarih || '', tender?.saat || ''],
|
deadline: [tender?.tarih || '', tender?.saat || ''],
|
||||||
documentVersion: results?.documentVersion ?? tender?.documentVersion ?? null,
|
documentVersion: results?.documentVersion ?? tender?.documentVersion ?? null,
|
||||||
procurementTerms: results?.procurementTerms || null,
|
procurementTerms: results?.procurementTerms || null,
|
||||||
|
humanReview: results?.sourceReview?.humanReview || null,
|
||||||
analyzedItems: results?.itemsAndSpecs || [],
|
analyzedItems: results?.itemsAndSpecs || [],
|
||||||
rows: rows.map(r => r.priceKey).sort(),
|
rows: rows.map(r => r.priceKey).sort(),
|
||||||
documents: results?.documentChecklist || [],
|
documents: results?.documentChecklist || [],
|
||||||
|
|||||||
@@ -37,3 +37,10 @@ test('changed quantity, requirements and announcement invalidate reviewed source
|
|||||||
test('legacy positional prices are not silently assigned to potentially changed source rows',()=>{
|
test('legacy positional prices are not silently assigned to potentially changed source rows',()=>{
|
||||||
assert.deepEqual(restoreBidPrices({fiyatlar:{0:'15',1:'900'}}),{});
|
assert.deepEqual(restoreBidPrices({fiyatlar:{0:'15',1:'900'}}),{});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('review revision changes invalidate the preparation source even with identical extracted rows',()=>{
|
||||||
|
const tender={ikn:'2026/1',items:[]};
|
||||||
|
const first={sourceReview:{humanReview:{documentId:'doc',revision:1,textHash:'same'}}};
|
||||||
|
const second={sourceReview:{humanReview:{documentId:'doc',revision:2,textHash:'same'}}};
|
||||||
|
assert.notEqual(bidSourceSnapshot(tender,first),bidSourceSnapshot(tender,second));
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,15 +7,24 @@ test('document review becomes stale when actual source bytes change; deletion re
|
|||||||
const dir=fs.mkdtempSync(path.join(tmpdir(),'ihale-review-'));
|
const dir=fs.mkdtempSync(path.join(tmpdir(),'ihale-review-'));
|
||||||
process.env.IHALEIST_USER_DATA_DIR=dir;
|
process.env.IHALEIST_USER_DATA_DIR=dir;
|
||||||
const store=await import('../server/user-store.mjs');
|
const store=await import('../server/user-store.mjs');
|
||||||
const {documentReview,saveDocumentReview}=await import('../server/document-review.mjs');
|
const {documentReview,saveDocumentReview,reviewedAnalysisSource}=await import('../server/document-review.mjs');
|
||||||
try {
|
try {
|
||||||
const id=store.saveDocumentFile('owner',{ad:'qa.txt',tur:'ihale',ikn:'2026/1'},Buffer.from('Original source'));
|
const id=store.saveDocumentFile('owner',{ad:'qa.txt',tur:'ihale',ikn:'2026/1'},Buffer.from('Original source'));
|
||||||
const source=documentReview('owner',id);
|
const source=documentReview('owner',id);
|
||||||
const input={fileHash:source.fileHash,expectedRevision:0,correctedText:'A complete synthetic corrected document text for the test.',note:'All pages compared in this isolated synthetic test.',allPagesCompared:true};
|
const input={fileHash:source.fileHash,expectedRevision:0,correctedText:'A complete synthetic corrected document text for the test.',note:'All pages compared in this isolated synthetic test.',allPagesCompared:true};
|
||||||
saveDocumentReview('owner',id,input);
|
saveDocumentReview('owner',id,input);
|
||||||
|
const resolved=reviewedAnalysisSource('owner',{documentId:id,revision:1},'2026/1');
|
||||||
|
assert.equal(resolved.text,input.correctedText);
|
||||||
|
assert.equal(resolved.humanReview.reviewedBy,'owner');
|
||||||
|
assert.throws(()=>reviewedAnalysisSource('other',{documentId:id,revision:1},'2026/1'),e=>e.status===404);
|
||||||
|
assert.throws(()=>reviewedAnalysisSource('owner',{documentId:id,revision:1},'2026/2'),e=>e.status===409);
|
||||||
|
saveDocumentReview('owner',id,{...input,expectedRevision:1,correctedText:input.correctedText+' Revised.'});
|
||||||
|
assert.throws(()=>reviewedAnalysisSource('owner',{documentId:id,revision:1},'2026/1'),e=>e.status===409);
|
||||||
|
|
||||||
const record=store.getDocument('owner',id);
|
const record=store.getDocument('owner',id);
|
||||||
fs.writeFileSync(path.join(store.BELGE_DIR,record.dosya),'Changed source bytes');
|
fs.writeFileSync(path.join(store.BELGE_DIR,record.dosya),'Changed source bytes');
|
||||||
assert.equal(documentReview('owner',id).stale,true);
|
assert.equal(documentReview('owner',id).stale,true);
|
||||||
|
assert.throws(()=>reviewedAnalysisSource('owner',{documentId:id,revision:2},'2026/1'),e=>e.status===409);
|
||||||
assert.throws(()=>saveDocumentReview('owner',id,{...input,expectedRevision:1}),e=>e.status===409);
|
assert.throws(()=>saveDocumentReview('owner',id,{...input,expectedRevision:1}),e=>e.status===409);
|
||||||
store.deleteDocument('owner',id);
|
store.deleteDocument('owner',id);
|
||||||
const db=store.openDb();
|
const db=store.openDb();
|
||||||
|
|||||||
Reference in New Issue
Block a user