From bc71984847f79de2de67e275d741bfb7052d6470 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 09:25:51 +0300 Subject: [PATCH] fix: enforce source-backed offer type in bid preparation --- docs/quality/uygulama-durumu.md | 7 +++++++ server/analysis-quality.mjs | 6 ++++-- src/components/BidPackageBuilder.jsx | 15 +++++++++++---- src/services/bidTerms.js | 16 ++++++++++++++++ tests/bid-terms.test.mjs | 14 +++++++++++++- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index fa90a30..94490fa 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -256,3 +256,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - 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. + +### D06 — Kaynaklı teklif türü aktarımı + +- Açık teklif türü etiketi veya teklif verileceğini belirten desteklenen maddelerden birim/götürü türü ve alıntı konumu çıkarılıyor; procurementTerms içinde analiz/taslak kaydına taşınıyor. +- Kaynak türü bilinmeyen yeni form otomatik birim fiyat seçmiyor. Kullanıcı resmi dokümanla seçim yapıyor. Kaynakla ters seçim iki yönde de engelleniyor; ilan/analiz farklı türler içeriyorsa çözülmeden çıktı alınamıyor. +- Sadece “birim fiyat” geçen örnek tablo veya “götürü bedel teklif verilemez” ifadesi pozitif tür tespiti sayılmıyor. Kalem bulunamaması türü değiştirmiyor. +- Kanıt: açık birim/götürü maddesi, Türkçe büyük harf, negatif hüküm, eksik liste ve iki tür çelişkisi testleriyle 69/69 test ve build geçti. Kaynak metni biçimleri sınırlıdır; karma teklif türüne ayrı çıktı üretimi ve tüm mevzuat/sözleşme türleri bu adımda tamamlanmadı. diff --git a/server/analysis-quality.mjs b/server/analysis-quality.mjs index 1cbb00b..37ae714 100644 --- a/server/analysis-quality.mjs +++ b/server/analysis-quality.mjs @@ -1,7 +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'; +import {extractBidValidity,extractBidType} 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; @@ -17,9 +17,11 @@ export function qualifyAnalysis(result,{text='',limit=60000,fileName='',expected if(expectedItems>0 && (clean.itemsAndSpecs?.length || 0) { }; }); - const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || 'birim'); const effectiveResults = results || storedDraft.analysisSource || {}; + const kaynakTeklifTuru = bidTypeForForm(tender?.aciklama,effectiveResults.procurementTerms?.bidType); + const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || kaynakTeklifTuru.type || ''); const kaynakGecerlilik = bidValidityForForm(tender?.aciklama, effectiveResults.procurementTerms?.bidValidity); const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik.days || ''); @@ -124,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 turCeliskisi = !['birim','goturu'].includes(teklifTuru) || kaynakTeklifTuru.status === 'conflict' || (kaynakTeklifTuru.type && teklifTuru !== kaynakTeklifTuru.type); 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'; @@ -234,6 +235,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.

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

Teklif türünün kaynağı

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

Kaynaklarda farklı teklif türleri var. Güncel resmi dokümanı ve karma kapsamı kontrol edin.

} + {[...new Set(kaynakTeklifTuru.evidence.map(e=>e.quote))].map(quote=>
{quote}
)} +
} {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.

} @@ -289,6 +295,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { onChange={(e) => setTeklifTuru(e.target.value)} className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300 bg-white" > + @@ -428,7 +435,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { {(kalemEksik || turCeliskisi || sureEksik) &&

{kalemEksik && 'Seçilen her kalemde ad, miktar, birim ve fiyat gerekli. '} - {turCeliskisi && 'İlan birim fiyat teklif belirtiyor; götürü bedel seçilemez. '} + {turCeliskisi && 'Teklif türünü resmi dokümana göre seçin; kaynakla farklılık varsa giderin. '} {sureEksik && 'Teklif geçerlilik süresini kaynak dokümana göre doğrulayın. '}

} {/* Belgeler */} diff --git a/src/services/bidTerms.js b/src/services/bidTerms.js index 3d4295f..96188f0 100644 --- a/src/services/bidTerms.js +++ b/src/services/bidTerms.js @@ -17,3 +17,19 @@ export function bidValidityForForm(announcement, analyzed) { 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}; } + +export function extractBidType(text) { + const source=String(text || ''),lower=source.toLocaleLowerCase('tr-TR'),evidence=[]; + for(const [type,label] of [['birim','birim\\s+fiyat'],['goturu','(?:anahtar\\s+teslimi?\\s+)?götürü\\s+bedel']]) { + const pattern=new RegExp(`(?:teklif\\s+türü\\s*[::-]\\s*${label}|${label}\\s+(?:üzerinden\\s+|şeklinde\\s+)?teklif\\s+(?:verilecektir|vereceklerdir|sunulacaktır|alınacaktır))`,'gu'); + for(const match of lower.matchAll(pattern)) evidence.push({type,quote:source.slice(match.index,match.index+match[0].length),start:match.index,end:match.index+match[0].length}); + } + return summarizeBidType(evidence); +} +function summarizeBidType(evidence) { + const types=[...new Set(evidence.map(e=>e.type).filter(t=>['birim','goturu'].includes(t)))]; + return {status:types.length>1?'conflict':types.length?'found':'not_found',type:types.length===1?types[0]:null,evidence}; +} +export function bidTypeForForm(announcement,analyzed) { + return summarizeBidType([...extractBidType(announcement).evidence,...(Array.isArray(analyzed?.evidence)?analyzed.evidence:[])]); +} diff --git a/tests/bid-terms.test.mjs b/tests/bid-terms.test.mjs index 689bcde..028795a 100644 --- a/tests/bid-terms.test.mjs +++ b/tests/bid-terms.test.mjs @@ -1,7 +1,19 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {extractBidValidity,bidValidityForForm} from '../src/services/bidTerms.js'; +import {extractBidValidity,bidValidityForForm,extractBidType,bidTypeForForm} from '../src/services/bidTerms.js'; import {qualifyAnalysis} from '../server/analysis-quality.mjs'; +test('offer type comes from explicit clauses and conflicting types remain unresolved',()=>{ + const unit='İstekliler birim fiyat teklif vereceklerdir.'; + const lump='TEKLİF TÜRÜ: ANAHTAR TESLİMİ GÖTÜRÜ BEDEL'; + assert.equal(extractBidType(unit).type,'birim'); + assert.equal(extractBidType(lump).type,'goturu'); + assert.equal(extractBidType('Birim fiyat tablosu örneği. Götürü bedel teklif verilemez.').status,'not_found'); + assert.equal(extractBidType('Kalem listesi bulunamadı.').type,null); + assert.equal(bidTypeForForm(unit,extractBidType(lump)).status,'conflict'); + const result=qualifyAnalysis({},{text:lump}); + assert.equal(result.procurementTerms.bidType.type,'goturu'); + assert.equal(qualifyAnalysis({},{text:unit+' '+lump}).sourceReview.status,'partial'); +}); 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);