From a751147d45c9d77a26a41595ca84f965104b6333 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 09:24:38 +0300 Subject: [PATCH] fix: carry source-backed bid validity into preparation drafts --- docs/quality/uygulama-durumu.md | 8 ++++++++ server/analysis-quality.mjs | 5 ++++- src/components/BidPackageBuilder.jsx | 17 ++++++++++++----- src/services/bidSource.js | 1 + src/services/bidTerms.js | 19 +++++++++++++++++++ tests/bid-terms.test.mjs | 22 ++++++++++++++++++++++ 6 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/services/bidTerms.js create mode 100644 tests/bid-terms.test.mjs diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index ff38435..fa90a30 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -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. diff --git a/server/analysis-quality.mjs b/server/analysis-quality.mjs index 898d769..1cbb00b 100644 --- a/server/analysis-quality.mjs +++ b/server/analysis-quality.mjs @@ -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) { }); 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 }) => { {sourceOptions.needsSelection &&

Kaynak seçilmeden çıktı hazırlanamaz. Kaynak listeleri değiştiğinde seçim yeniden yapılmalıdır.

} } + {kaynakGecerlilik.evidence.length > 0 &&
+

Teklif geçerlilik süresinin kaynağı

+ {kaynakGecerlilik.status === 'conflict' &&

Kaynaklarda farklı süreler var. Güncel resmi dokümanı belirleyip analizi yenileyin; çelişki çözülmeden çıktı alınamaz.

} + {[...new Set(kaynakGecerlilik.evidence.map(e=>e.quote))].map(quote=>
{quote}
)} +
} {analizEksik &&

Analiz kısmi: kaynak veya kalem listesi eksik. Tam dokümanı ve resmi cetveli kontrol ederek analizi tamamlayın.

} {/* Sizden beklenen bilgiler */} {eksikler.length > 0 && ( diff --git a/src/services/bidSource.js b/src/services/bidSource.js index bcc1dfd..ea81a54 100644 --- a/src/services/bidSource.js +++ b/src/services/bidSource.js @@ -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 || [], diff --git a/src/services/bidTerms.js b/src/services/bidTerms.js new file mode 100644 index 0000000..3d4295f --- /dev/null +++ b/src/services/bidTerms.js @@ -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}; +} diff --git a/tests/bid-terms.test.mjs b/tests/bid-terms.test.mjs new file mode 100644 index 0000000..689bcde --- /dev/null +++ b/tests/bid-terms.test.mjs @@ -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); +});