fix: enforce source-backed offer type in bid preparation
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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ı.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { verifyAnalysisCitations } from './analysis-citations.mjs';
|
import { verifyAnalysisCitations } from './analysis-citations.mjs';
|
||||||
import { validateAnalysisShape } from './analysis-schema.mjs';
|
import { validateAnalysisShape } from './analysis-schema.mjs';
|
||||||
import {createHash} from 'node:crypto';
|
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}={}) {
|
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.');
|
if(!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('Analiz nesne biçiminde değil.');
|
||||||
const clean={...result};delete clean._outputRepaired;
|
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)<expectedItems)issues.push(`Kaynakta ${expectedItems} kalem var; analiz listesi daha kısa.`);
|
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 citations=verifyAnalysisCitations(clean,source.slice(0,limit));
|
||||||
const bidValidity=extractBidValidity(source.slice(0,limit));
|
const bidValidity=extractBidValidity(source.slice(0,limit));
|
||||||
|
const bidType=extractBidType(source.slice(0,limit));
|
||||||
|
if(bidType.status==='conflict')issues.push('Kaynakta farklı teklif türleri var; karma kapsam veya sürüm farkı kontrol edilmeli.');
|
||||||
if(bidValidity.status==='conflict')issues.push('Kaynakta farklı teklif geçerlilik süreleri var; ilgili maddeler karşılaştırılmalı.');
|
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');
|
const version=createHash('sha256').update(source).digest('hex');
|
||||||
return {...clean,procurementTerms:{bidValidity},sourceEvidence:citations.evidence,documentVersion:version,sourceReview:{status:issues.length?'partial':'review_required',
|
return {...clean,procurementTerms:{bidValidity,bidType},sourceEvidence:citations.evidence,documentVersion:version,sourceReview:{status:issues.length?'partial':'review_required',
|
||||||
fileName,sourceCharacters:source.length,analyzedCharacters:Math.min(source.length,limit),
|
fileName,sourceCharacters:source.length,analyzedCharacters:Math.min(source.length,limit),
|
||||||
outputRepaired:repaired,sections,issues,unverifiedFindings:citations.missing}};
|
outputRepaired:repaired,sections,issues,unverifiedFindings:citations.missing}};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bidValidityForForm } from '../services/bidTerms';
|
import { bidValidityForForm, bidTypeForForm } from '../services/bidTerms';
|
||||||
import { createBidWorkbook, downloadBidWorkbook } from '../services/bidWorkbook';
|
import { createBidWorkbook, downloadBidWorkbook } from '../services/bidWorkbook';
|
||||||
import { getToken } from '../services/auth';
|
import { getToken } from '../services/auth';
|
||||||
import { listDocuments, downloadDocument } from '../services/account';
|
import { listDocuments, downloadDocument } from '../services/account';
|
||||||
@@ -71,8 +71,9 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const [teklifTuru, setTeklifTuru] = useState(storedDraft.teklifTuru || 'birim');
|
|
||||||
const effectiveResults = results || storedDraft.analysisSource || {};
|
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 kaynakGecerlilik = bidValidityForForm(tender?.aciklama, effectiveResults.procurementTerms?.bidValidity);
|
||||||
const [gecerlilikGun, setGecerlilikGun] = useState(storedDraft.gecerlilikGun || kaynakGecerlilik.days || '');
|
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 eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
|
||||||
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
|
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 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 kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,deadlineDate:tenderDateKey(tender)});
|
||||||
const analizEksik = effectiveResults.sourceReview?.status === 'partial';
|
const analizEksik = effectiveResults.sourceReview?.status === 'partial';
|
||||||
@@ -234,6 +235,11 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
</label>
|
</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>}
|
{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>}
|
</fieldset>}
|
||||||
|
{kaynakTeklifTuru.evidence.length > 0 && <div className="text-xs border rounded-lg p-3 space-y-1">
|
||||||
|
<p className="font-bold">Teklif türünün kaynağı</p>
|
||||||
|
{kaynakTeklifTuru.status === 'conflict' && <p role="alert" className="text-red-700">Kaynaklarda farklı teklif türleri var. Güncel resmi dokümanı ve karma kapsamı kontrol edin.</p>}
|
||||||
|
{[...new Set(kaynakTeklifTuru.evidence.map(e=>e.quote))].map(quote=><blockquote key={quote}>{quote}</blockquote>)}
|
||||||
|
</div>}
|
||||||
{kaynakGecerlilik.evidence.length > 0 && <div className="text-xs border rounded-lg p-3 space-y-1">
|
{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>
|
<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>}
|
{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>}
|
||||||
@@ -289,6 +295,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
onChange={(e) => setTeklifTuru(e.target.value)}
|
onChange={(e) => setTeklifTuru(e.target.value)}
|
||||||
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300 bg-white"
|
className="w-full text-xs px-3 py-2 rounded-lg border border-slate-300 bg-white"
|
||||||
>
|
>
|
||||||
|
<option value="">Resmi dokümana göre seçin</option>
|
||||||
<option value="birim">Birim fiyat</option>
|
<option value="birim">Birim fiyat</option>
|
||||||
<option value="goturu">Anahtar teslim götürü bedel</option>
|
<option value="goturu">Anahtar teslim götürü bedel</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -428,7 +435,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
|
|
||||||
{(kalemEksik || turCeliskisi || sureEksik) && <p role="alert" className="text-xs bg-amber-50 p-3 text-amber-900">
|
{(kalemEksik || turCeliskisi || sureEksik) && <p role="alert" className="text-xs bg-amber-50 p-3 text-amber-900">
|
||||||
{kalemEksik && 'Seçilen her kalemde ad, miktar, birim ve fiyat gerekli. '}
|
{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. '}
|
{sureEksik && 'Teklif geçerlilik süresini kaynak dokümana göre doğrulayın. '}
|
||||||
</p>}
|
</p>}
|
||||||
{/* Belgeler */}
|
{/* Belgeler */}
|
||||||
|
|||||||
@@ -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))];
|
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};
|
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:[])]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
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',()=>{
|
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 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);
|
const terms=extractBidValidity(source);
|
||||||
|
|||||||
Reference in New Issue
Block a user