Restrict preparation document links to their tender scope

This commit is contained in:
2026-09-06 10:17:14 +03:00
parent 91451b1134
commit 2b9e1953e4
5 changed files with 51 additions and 12 deletions
+12
View File
@@ -491,3 +491,15 @@ Ortak havuz istemcisi HTTP hata/bozuk yanıtı boş liste gibi sunmaz. Panel hat
Gerçek tarayıcı kabulü: localhost:5175 QA Giresun detayında “İhale kaydında aşağıda gösterilen 1 kalem var” mesajı ve “Malzeme / Kalem Listesi (1 Kalem)” tablosu birlikte doğrulandı. Kanıt: `qa-items-source-message.json`. Bu sentetik veriyle kaynak gösterimi testidir; resmi cetvel doğruluğu veya tüm kaynak uyuşmazlıklarının çözümü değildir. Ağ hatası/iptal/zaman aşımı ise otomatik testlerde sınandı.
Tüm 106 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın tamamı ve canlı yayın açık.
### D01 / D07 — Hazırlık belgelerinde ihale kapsamı
Hazırlık belge seçicisi bütün depo kayıtlarını sunuyor; son kontrolde dosya erişimi, sürümü ve son kullanımı denetlenirken İKN/tür kapsamı kontrol edilmiyordu. Böylece aynı hesaba ait başka ihalenin dokümanı mevcut hazırlığa bağlanabiliyordu.
Yeni kapsam denetimi firma belgelerini yeniden kullanılabilir tutar; `ihale` türündeki dosyalar yalnız mevcut İKN ile eşleşince kullanılabilir. Türü bilinmeyen veya ihale bağlantısı olmayan kayıtlar açık hata verir. Seçici uygun dosyaları listeler; önceden bağlanmış uygunsuz dosyayı görünür bir “kullanılamıyor” seçeneğiyle korur, sessizce başka dosya seçmez. Genel hazır olma kontrolü ve çıktı öncesi güncel envanter kontrolü aynı İKN bağlamını kullanır.
Belge kontrol sürümüne tür ve İKN eklendi. Bu alanların değişmesi yeniden inceleme gerektirir; eski sürüm biçimiyle kaydedilmiş bağlantılar da güvenli biçimde yeniden seçim/kontrol ister. Bu, belgelerin gerçek yeterlik içeriğini otomatik doğrulamaz.
Testler: başka ihale belgesi reddi, aynı ihale kabulü, firma belgesinin yeniden kullanımı, tüm hazırlık kontrolüne kapsamın yansıması, tür/bağ değişiminde inceleme geçersizliği. Tarayıcıda yeni seçici kapsamı kabulü halen açık.
Sonuç: Tüm 108 test ve üretim derlemesi geçti. Büyük paket uyarıları sürüyor. Planın tümü bitmedi; canlı yayın yapılmadı.
+3 -3
View File
@@ -127,7 +127,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
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 kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn});
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;
@@ -157,7 +157,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
if (reviewedText) await validateDocumentTextReview(reviewedText,tender.ikn);
if (!mounted.current || session !== getToken() || submitted !== latestDraft.current)
throw new Error('Form, kaynak veya oturum değişti. Güncel bilgilerle yeniden çıktı oluşturun.');
const currentVault = {loading:false,error:null,documents:currentFiles.belgeler || [],deadlineDate:tenderDateKey(tender)};
const currentVault = {loading:false,error:null,documents:currentFiles.belgeler || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn};
setVault(currentVault);
const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault);
if (fileErrors.length) throw new Error(fileErrors.join(' '));
@@ -415,7 +415,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
)}
<PreparationChecks rows={lotSelection.rows} documents={documents} manualDocuments={manualDocuments}
setManualDocuments={value => {setManualDocuments(value);setDirty(true);}} checks={checks} setChecks={setChecks} vault={vault} refreshVault={()=>setVaultRefresh(n=>n+1)} downloadFile={(file)=>downloadDocument(file.id,file.ad).catch(e=>setHata(e.message))}/>
setManualDocuments={value => {setManualDocuments(value);setDirty(true);}} checks={checks} setChecks={setChecks} vault={{...vault,tenderIkn:tender.ikn}} refreshVault={()=>setVaultRefresh(n=>n+1)} downloadFile={(file)=>downloadDocument(file.id,file.ad).catch(e=>setHata(e.message))}/>
{kontrolEksikleri.length > 0 && <details className="bg-amber-50 border border-amber-300 rounded-xl p-3">
<summary className="text-xs font-bold cursor-pointer">{kontrolEksikleri.length} belge/uygunluk kontrolü tamamlanmalı</summary>
<ul className="text-xs list-disc pl-5 mt-2 space-y-1">{kontrolEksikleri.map((message,i)=><li key={i}>{message}</li>)}</ul>
+6 -5
View File
@@ -1,11 +1,12 @@
import React, {useState} from 'react';
import {HEALTH_CHECKS, documentKey, documentRevision} from '../services/preparationChecks';
import {HEALTH_CHECKS, documentKey, documentRevision, documentScopeProblem} from '../services/preparationChecks';
const fieldClass = 'w-full border border-slate-300 rounded-lg p-2 text-xs bg-white';
const Status = ({value, onChange, optional = true}) => <select aria-label="Kontrol durumu" className={fieldClass} value={value || 'pending'} onChange={e => onChange(e.target.value)}>
<option value="pending">Kontrol gerekli</option><option value="verified">Doğrulandı</option>
{optional && <option value="notApplicable">Uygulanmıyor — gerekçe gerekli</option>}
</select>;
export function PreparationChecks({rows, documents, manualDocuments, setManualDocuments, checks, setChecks, vault, refreshVault, downloadFile}) {
const eligibleFiles = vault.documents.filter(file => !documentScopeProblem(file,vault.tenderIkn));
const [name,setName] = useState('');
const [clause,setClause] = useState('');
const updateDocument = (key, patch) => setChecks(old => ({...old,documents:{...old.documents,[key]:{...old.documents?.[key],...patch}}}));
@@ -14,7 +15,7 @@ export function PreparationChecks({rows, documents, manualDocuments, setManualDo
<h5 className="font-extrabold text-sm">Belge ve ürün uygunluğu</h5>
<p className="text-xs text-slate-600">Her kontrol için belge adı/sürümü ve kaynak maddeyi yazın. ÜTS, numune ve servis koşulları her ihalede zorunlu değildir; uygulanmıyorsa gerekçesini belirtin. Bu kayıtlar kullanıcı kontrolüdür; resmi uygunluk onayı değildir.</p>
<div className="space-y-2">
<p className="text-xs">Dosyaları Doküman Deposu'na yükleyin, ardından burada ilgili belge gereksinimine bağlayın.</p>
<p className="text-xs">Firma belgelerini veya bu İKN için yüklenmiş ihale dokümanlarını ilgili gereksinime bağlayın. Başka ihale dokümanları seçilemez.</p>
<button type="button" onClick={refreshVault} disabled={vault.loading} className="text-xs font-bold text-blue-700">Belge deposunu yenile</button>
{vault.loading && <p role="status" className="text-xs">Belge deposu yükleniyor…</p>}
{vault.error && <p role="alert" className="text-xs text-red-700">Belge deposu yüklenemedi: {vault.error}</p>}
@@ -27,12 +28,12 @@ export function PreparationChecks({rows, documents, manualDocuments, setManualDo
onClick={() => setManualDocuments(manualDocuments.filter(item => documentKey(item) !== key))}>Elle eklenen gereksinimi kaldır</button>}
<label className="block text-xs">Bağlı dosya
<select className={fieldClass} value={c.documentId || ''} disabled={vault.loading || Boolean(vault.error)} onChange={e=>{
const file=vault.documents.find(f=>f.id===e.target.value);
const file=eligibleFiles.find(f=>f.id===e.target.value);
updateDocument(key,{documentId:file?.id || null,documentRevision:file ? documentRevision(file) : null,status:'pending'});
}}>
<option value="">Dosya seçin</option>
{c.documentId && !vault.documents.some(f=>f.id===c.documentId) && <option value={c.documentId}>Önceki dosya bulunamadı</option>}
{vault.documents.map(file=><option key={file.id} value={file.id}>{file.ad} · {file.son_kullanma || 'Süre bilgisi yok'}{file.erisilebilir === false ? ' · erişilemiyor' : ''}</option>)}
{c.documentId && !eligibleFiles.some(f=>f.id===c.documentId) && <option value={c.documentId}>Önceki dosya bulunamadı veya bu ihale için kullanılamıyor</option>}
{eligibleFiles.map(file=><option key={file.id} value={file.id}>{file.ad} · {file.son_kullanma || 'Süre bilgisi yok'}{file.erisilebilir === false ? ' · erişilemiyor' : ''}</option>)}
</select>
</label>
{vault.documents.some(f=>f.id===c.documentId) && <button type="button" className="text-xs text-blue-700" onClick={()=>downloadFile(vault.documents.find(f=>f.id===c.documentId))}>Bağlı dosyayı indir</button>}
+12 -3
View File
@@ -5,10 +5,19 @@ export const HEALTH_CHECKS = [
['delivery', 'Teslimat ve süre'],
];
export const documentKey = d => JSON.stringify([d.document, d.mandatory !== false, d.note || '']);
export const documentRevision = file => JSON.stringify([file.id,file.olusturma,file.boyut,file.son_kullanma || null]);
export function documentLinkProblem(check, files, deadlineDate) {
export const documentRevision = file => JSON.stringify([file.id,file.olusturma,file.boyut,file.son_kullanma || null,file.tur || null,file.ikn || null]);
export function documentScopeProblem(file, tenderIkn) {
if (file.tur === 'firma') return null;
if (file.tur !== 'ihale') return 'Belge türü doğrulanamadı. Doküman Deposu kaydını kontrol edin.';
if (!tenderIkn || !file.ikn || String(file.ikn).trim() !== String(tenderIkn).trim())
return 'Bu ihale dokümanı mevcut ihaleye bağlı değil. Doğru İKN için yüklenmiş dosyayı seçin.';
return null;
}
export function documentLinkProblem(check, files, deadlineDate, tenderIkn) {
const file = files.find(f=>f.id===check?.documentId);
if (!file || file.erisilebilir === false) return 'Bağlı dosya bulunamadı veya erişilemiyor.';
const scopeProblem = documentScopeProblem(file,tenderIkn);
if (scopeProblem) return scopeProblem;
if (check.documentRevision !== documentRevision(file)) return 'Belge bilgisi değişti; dosyayı yeniden seçip kontrol edin.';
if (file.son_kullanma && ((!/^\d{4}-\d{2}-\d{2}$/.test(file.son_kullanma) || Number.isNaN(Date.parse(file.son_kullanma+'T00:00:00Z')) || new Date(file.son_kullanma+'T00:00:00Z').toISOString().slice(0,10) !== file.son_kullanma) ||
!deadlineDate || file.son_kullanma < deadlineDate)) return 'Belge son teklif tarihinde geçerli değil veya tarih doğrulanamadı.';
@@ -21,7 +30,7 @@ export function preparationBlockers(rows, documents, checks = {}, vault) {
const c = checks.documents?.[documentKey(d)] || {};
if (d.mandatory === false && c.status === 'notApplicable' && c.evidence?.trim()) continue;
if (vault) {
const problem = vault.loading ? 'Belge deposu yükleniyor.' : vault.error ? 'Belge deposu doğrulanamadı.' : documentLinkProblem(c,vault.documents || [],vault.deadlineDate);
const problem = vault.loading ? 'Belge deposu yükleniyor.' : vault.error ? 'Belge deposu doğrulanamadı.' : documentLinkProblem(c,vault.documents || [],vault.deadlineDate,vault.tenderIkn);
if(problem) errors.push(`${d.document}: ${problem}`);
}
if (c.status !== 'verified' || !c.evidence?.trim()) errors.push(`${d.document}: belge ve kaynak kontrolü eksik.`);
+18 -1
View File
@@ -19,7 +19,7 @@ test('changed requirement and changed product row require new checks',()=>{
});
test('linked file must exist, match reviewed metadata and remain valid at tender deadline',()=>{
const file={id:'f1',olusturma:'2026-09-01',boyut:100,son_kullanma:'2026-09-10',erisilebilir:true};
const file={id:'f1',tur:'firma',olusturma:'2026-09-01',boyut:100,son_kullanma:'2026-09-10',erisilebilir:true};
const c={documentId:file.id,documentRevision:documentRevision(file)};
assert.equal(documentLinkProblem(c,[file],'2026-09-10'),null);
assert.ok(documentLinkProblem(c,[],'2026-09-10'));
@@ -29,3 +29,20 @@ test('linked file must exist, match reviewed metadata and remain valid at tender
const invalid={...file,son_kullanma:'2026-02-31'};
assert.ok(documentLinkProblem({...c,documentRevision:documentRevision(invalid)},[invalid],'2026-01-01'));
});
test('another tender document cannot satisfy preparation but company documents remain reusable',()=>{
const file={id:'doc1',tur:'ihale',ikn:'2026/111',olusturma:'2026-09-01',boyut:20};
const check={documentId:file.id,documentRevision:documentRevision(file)};
assert.match(documentLinkProblem(check,[file],'2026-09-10','2026/222'),/mevcut ihaleye bağlı değil/);
assert.equal(documentLinkProblem(check,[file],'2026-09-10','2026/111'),null);
const company={...file,tur:'firma',ikn:null};
assert.equal(documentLinkProblem({...check,documentRevision:documentRevision(company)},[company],'2026-09-10','2026/222'),null);
const c=complete();c.documents[documentKey(docs[0])]={...c.documents[documentKey(docs[0])],...check};
assert.ok(preparationBlockers(rows,docs,c,{documents:[file],deadlineDate:'2026-09-10',tenderIkn:'2026/222'}).some(e=>e.includes('mevcut ihaleye bağlı değil')));
});
test('classification or tender-link changes invalidate the previous review',()=>{
const file={id:'doc1',tur:'firma',ikn:null,olusturma:'2026-09-01',boyut:20};
const check={documentId:file.id,documentRevision:documentRevision(file)};
assert.match(documentLinkProblem(check,[{...file,tur:'ihale',ikn:'2026/111'}],'2026-09-10','2026/111'),/yeniden seçip/);
assert.match(documentLinkProblem(check,[{...file,tur:undefined}],'2026-09-10','2026/111'),/Belge türü/);
});