fix: carry source-backed bid validity into preparation drafts

This commit is contained in:
2026-09-06 09:24:38 +03:00
parent e07be66e7f
commit a751147d45
6 changed files with 66 additions and 6 deletions
+8
View File
@@ -248,3 +248,11 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont
- Detay isteğine 30 saniye zaman aşımı ve iptal sinyali eklendi; pencere kapatıldığında/ihale değiştiğinde istek durduruluyor ve geç yanıt önceki kayda uygulanmıyor.
- Kaynak detayının kullanıcıya özel görev/not/taslak alanlarını ezmemesi için yanıt publicTender süzgecinden geçiriliyor.
- Kanıt: boş başarılı yanıt, sonraki başarılı deneme, HTTP 503, zaman aşımı ve iptal regresyonuyla 66/66 test ve build geçti. Gerçek kaynakta hata→yeniden deneme tarayıcı kabulü ve tüm kaynak türlerinin detay akışları ayrıca açık.
### D06 — Teklif geçerlilik süresinin kaynaklı aktarımı
- Analiz sonucunda procurementTerms.bidValidity alanı sunucuda tüketilen kaynak metinden üretiliyor: gün sayısı, bulunan/bulunamayan/çelişkili durumu, birebir alıntı ve karakter aralığı. Teslimat/garanti günleri teklif geçerliliği olarak seçilmiyor.
- Form bu yapılandırılmış değeri ve ilan maddelerini birlikte kullanıyor; sonuç taslakla saklanıyor ve kaynak sürümü kontrolüne katılıyor. Bulunan kaynak alıntıları kullanıcıya gösteriliyor. Birden fazla farklı süre varsa ilkini sessizce seçmek yerine çıktı engelleniyor.
- Süre alanında pozitif tam gün isteniyor. Mevcut kullanıcı düzenlemesi yeni analizle sessizce ezilmiyor; kaynak koşulundan kısa süre hazır kabul edilmiyor.
- Kanıt: büyük harfli Türkçe madde, parantezli yazıyla sayı, 120 günün analiz/JSON saklama/form kaynağına aktarımı, tekrar aynı süre ve 120/60 çelişkisi testleriyle 68/68 test ve build geçti.
- Sınır: Açıkça etiketlenmiş sayısal Türkçe maddeler destekleniyor; salt yazıyla ifade, OCR bozulması ve farklı madde biçimleri manuel kaynak kontrolü gerektirebilir. Bu, tüm ihale koşullarının ortak veri modeline taşındığı veya hukuki çelişki denetiminin tamamlandığı anlamına gelmez.
+4 -1
View File
@@ -1,6 +1,7 @@
import { verifyAnalysisCitations } from './analysis-citations.mjs';
import { validateAnalysisShape } from './analysis-schema.mjs';
import {createHash} from 'node:crypto';
import {extractBidValidity} from '../src/services/bidTerms.js';
export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expectedItems=0,repaired=false}={}) {
if(!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Analiz nesne biçiminde değil.');
const clean={...result};delete clean._outputRepaired;
@@ -15,8 +16,10 @@ export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expected
if(repaired)issues.push('Analiz yanıtı kesildi; yalnız kurtarılabilen alanlar gösteriliyor.');
if(expectedItems>0 && (clean.itemsAndSpecs?.length || 0)<expectedItems)issues.push(`Kaynakta ${expectedItems} kalem var; analiz listesi daha kısa.`);
const citations=verifyAnalysisCitations(clean,source.slice(0,limit));
const bidValidity=extractBidValidity(source.slice(0,limit));
if(bidValidity.status==='conflict')issues.push('Kaynakta farklı teklif geçerlilik süreleri var; ilgili maddeler karşılaştırılmalı.');
const version=createHash('sha256').update(source).digest('hex');
return {...clean,sourceEvidence:citations.evidence,documentVersion:version,sourceReview:{status:issues.length?'partial':'review_required',
return {...clean,procurementTerms:{bidValidity},sourceEvidence:citations.evidence,documentVersion:version,sourceReview:{status:issues.length?'partial':'review_required',
fileName,sourceCharacters:source.length,analyzedCharacters:Math.min(source.length,limit),
outputRepaired:repaired,sections,issues,unverifiedFindings:citations.missing}};
}
+12 -5
View File
@@ -1,3 +1,4 @@
import { bidValidityForForm } from '../services/bidTerms';
import { createBidWorkbook, downloadBidWorkbook } from '../services/bidWorkbook';
import { getToken } from '../services/auth';
import { listDocuments, downloadDocument } from '../services/account';
@@ -71,12 +72,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
});
const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || 'birim');
const kaynakGecerlilik = /(?:geçerlilik[^.]{0,100}?|ihale tarihinden itibaren\s*)(\d+)\s*(?:takvim\s*)?gün/i.exec(tender?.aciklama || '');
const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik?.[1] || '');
const effectiveResults = results || storedDraft.analysisSource || {};
const kaynakGecerlilik = bidValidityForForm(tender?.aciklama, effectiveResults.procurementTerms?.bidValidity);
const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik.days || '');
const [kisim, setKisim] = useState(storedDraft.kisim || '');
const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || '');
const effectiveResults = results || storedDraft.analysisSource || {};
const [itemSource, setItemSource] = useState(storedDraft.itemSource || {});
const sourceOptions = bidSourceOptions(tender, effectiveResults, itemSource);
const [manualDocuments, setManualDocuments] = useState(storedDraft.manualDocuments || []);
@@ -112,7 +114,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
useEffect(()=>{mounted.current=true;return ()=>{mounted.current=false;printWindow.current?.close();};},[]);
const saveDraft = async () => {
const submitted = latestDraft.current;
const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null,sourceReview:effectiveResults.sourceReview || null}}});
const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null,procurementTerms:effectiveResults.procurementTerms || null,sourceReview:effectiveResults.sourceReview || null}}});
if (!mounted.current) return;
if (saved) { setDirty(latestDraft.current !== submitted); setHata(null); }
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
@@ -123,7 +125,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || '');
const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1]));
const sureEksik = !Number.isInteger(Number(gecerlilikGun)) || !(Number(gecerlilikGun) > 0) || kaynakGecerlilik.status === 'conflict' || (kaynakGecerlilik.days && Number(gecerlilikGun) < kaynakGecerlilik.days);
const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,deadlineDate:tenderDateKey(tender)});
const analizEksik = effectiveResults.sourceReview?.status === 'partial';
const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection;
@@ -232,6 +234,11 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
</label>
{sourceOptions.needsSelection && <p role="alert" className="text-xs text-red-700">Kaynak seçilmeden çıktı hazırlanamaz. Kaynak listeleri değiştiğinde seçim yeniden yapılmalıdır.</p>}
</fieldset>}
{kaynakGecerlilik.evidence.length > 0 && <div className="text-xs border rounded-lg p-3 space-y-1">
<p className="font-bold">Teklif geçerlilik süresinin kaynağı</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>)}
</div>}
{analizEksik && <p role="alert" className="text-xs text-red-700">Analiz kısmi: kaynak veya kalem listesi eksik. Tam dokümanı ve resmi cetveli kontrol ederek analizi tamamlayın.</p>}
{/* Sizden beklenen bilgiler */}
{eksikler.length > 0 && (
+1
View File
@@ -32,6 +32,7 @@ export function bidSourceSnapshot(tender, results, rows = sourceRows(tender, res
announcement: tender?.aciklama || '',
deadline: [tender?.tarih || '', tender?.saat || ''],
documentVersion: results?.documentVersion ?? tender?.documentVersion ?? null,
procurementTerms: results?.procurementTerms || null,
analyzedItems: results?.itemsAndSpecs || [],
rows: rows.map(r => r.priceKey).sort(),
documents: results?.documentChecklist || [],
+19
View File
@@ -0,0 +1,19 @@
// Extract only explicitly labelled bid validity clauses; delivery/warranty days are unrelated.
export function extractBidValidity(text) {
const source=String(text || '');
const pattern=/teklif(?:lerin|in)?\s+geçerlilik\s+süresi[^\n.\d]{0,100}(\d{1,4})(?:\s*\([^)]{0,80}\))?\s*(?:takvim\s*)?gün/giu;
const evidence=[];
for(const match of source.toLocaleLowerCase('tr-TR').matchAll(pattern)) {
const days=Number(match[1]);
if(days>0) evidence.push({days,quote:source.slice(match.index,match.index+match[0].length),start:match.index,end:match.index+match[0].length});
}
const values=[...new Set(evidence.map(e=>e.days))];
return {status:values.length>1?'conflict':values.length?'found':'not_found',days:values.length===1?values[0]:null,evidence};
}
export function bidValidityForForm(announcement, analyzed) {
const local=extractBidValidity(announcement);
const evidence=[...local.evidence,...(Array.isArray(analyzed?.evidence)?analyzed.evidence:[])];
const values=[...new Set(evidence.map(e=>e.days).filter(d=>Number.isInteger(d)&&d>0))];
return {status:values.length>1?'conflict':values.length?'found':'not_found',days:values.length===1?values[0]:null,evidence};
}
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {extractBidValidity,bidValidityForForm} from '../src/services/bidTerms.js';
import {qualifyAnalysis} from '../server/analysis-quality.mjs';
test('bid validity is structured from source with exact clause evidence',()=>{
const source='Teslimat 15 gün. TEKLİFLERİN GEÇERLİLİK SÜRESİ ihale tarihinden itibaren 120 (yüzyirmi) takvim günüdür.';
const terms=extractBidValidity(source);
assert.equal(terms.days,120);
assert.equal(source.slice(terms.evidence[0].start,terms.evidence[0].end),terms.evidence[0].quote);
assert.equal(extractBidValidity('Garanti süresi 60 gün. Belge geçerlilik süresi 90 gün.').status,'not_found');
const result=qualifyAnalysis({},{text:source});
assert.equal(result.procurementTerms.bidValidity.days,120);
assert.equal(bidValidityForForm('',JSON.parse(JSON.stringify(result.procurementTerms.bidValidity))).days,120);
});
test('conflicting clauses remain unresolved instead of choosing the first duration',()=>{
const a='Teklif geçerlilik süresi 120 takvim günü.';
const b='Tekliflerin geçerlilik süresi 60 gündür.';
assert.equal(extractBidValidity(a+' '+b).status,'conflict');
assert.equal(bidValidityForForm(a,extractBidValidity(b)).status,'conflict');
assert.equal(qualifyAnalysis({},{text:a+' '+b}).sourceReview.status,'partial');
assert.equal(extractBidValidity(a+' '+a).days,120);
});