From 36859188bf2d916988b7a888ad35141c68061fc5 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 13:52:40 +0300 Subject: [PATCH] fix: invalidate preparation approval for changed portfolio products --- docs/quality/uygulama-durumu.md | 7 +++++++ src/components/BidPackageBuilder.jsx | 15 +++++++++----- src/components/PreparationChecks.jsx | 7 +++++++ src/components/PreparationOverview.jsx | 4 ++-- src/services/portfolioRevision.js | 15 ++++++++++++++ src/services/preparationChecks.js | 3 +++ tests/health-portfolio.test.mjs | 28 ++++++++++++++++++++++++++ 7 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 src/services/portfolioRevision.js diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 18e15fb..ce85ead 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -944,3 +944,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Oturum geri yükleme sırasında 5xx, bozuk yanıt ve zaman aşımı saklı oturumu silmez. Açık 401/403 yetki reddi oturumu temizler. Yanıt beklerken yeni hesaba geçilmişse eski yanıt yeni hesabı değiştiremez. Sunucu tarafındaki yetkilendirme geçerliliğini korur. - 215 otomatik test geçti; yeni testler yapılandırma ayrımı, iptal, geçersiz giriş, yarım depolama, 401/403, 5xx/bozuk yanıt, hesap değişimi ve takılmış yanıt gövdesinin zaman aşımını kapsar. Üretim derlemesi geçti; büyük paket uyarısı sürüyor. - Gerçek Google OAuth hesabıyla giriş, SDK yükleme hatasının tarayıcı kabulü ve tüm mobil giriş etkileşimleri henüz doğrulanmadı. Uygulama Google hesabı kullanıyor; bağımsız parola sıfırlama akışı bulunmuyor. Canlıya yayın yapılmadı. + +### Portföy değiştiğinde eski ürün onaylarının engellenmesi — 6 Eylül 2026 +- Seçim anındaki portföy kopyası güncel kaydın kimliği, ürün grubu, marka, model, kapsam, ÜTS beyanı/açıklaması ve servis kapasitesiyle karşılaştırılır. Kayıt silinmişse, aynı kimlik birden fazla kayıtla eşleşiyorsa veya beyan değişmişse eski uygunluk işaretleri çıktıya izin vermez. Elle girilen kapsamın mevcut kontrol akışı korunur. +- Ürün kontrolünde değişiklik uyarısı ve güncel ürünü alıp kontrolleri sıfırlama düğmesi eklendi. Önceki kayıt otomatik değiştirilmez; kullanıcı güncel seçimi aldığında teknik kaynak, uygunluk ve dört sağlık kontrolü yeniden doldurulur. Hazırlık özeti aynı engeli gösterir. +- Bağlı portföy kullanan çıktı işlemi sunucudaki firma profilini yeniden okur. Excel oluşturmanın ardından portföy tekrar okunur; yerel profil değişimi de bekleyen çıktı işlemini geçersiz kılar. Profil isteği başarısızsa çıktı tamamlanmaz. +- 217 otomatik test ve üretim derlemesi geçti. Testler yedi beyan alanındaki değişiklikleri, silinmiş/belirsiz kimliği, eksik profil/kopyayı, ilgisiz metadata değişimini ve tamamen onaylı bir ürünün eski portföyle engellenmesini kapsar. Yeniden seçimden sonra beş uygunluk kontrolünün tekrar gerektiği doğrulandı. +- Yeni düğmenin tarayıcı etkileşimi ve iki gerçek oturumda profil değiştirirken çıktı denemesi henüz kabul edilmedi. Profil sunucudan okunduğu andaki durumu temsil eder; sunucu kilidi veya resmi ürün kayıt doğrulaması değildir. Canlıya yayın yapılmadı. diff --git a/src/components/BidPackageBuilder.jsx b/src/components/BidPackageBuilder.jsx index dc70924..8dffc5f 100644 --- a/src/components/BidPackageBuilder.jsx +++ b/src/components/BidPackageBuilder.jsx @@ -4,7 +4,7 @@ import { preparationDeadlineProblem } from '../services/preparationDeadline'; import { bidValidityForForm, bidTypeForForm } from '../services/bidTerms'; import { createBidWorkbook, downloadBidWorkbook } from '../services/bidWorkbook'; import { getToken } from '../services/auth'; -import { listDocuments, downloadDocument, validateDocumentTextReview } from '../services/account'; +import { getAllUserData, listDocuments, downloadDocument, validateDocumentTextReview } from '../services/account'; import { tenderDateKey } from '../services/tenderCalendar'; import React, { useState, useMemo, useEffect, useRef, useId } from 'react'; import { bidRowTotal, validateBidRows, bidTotal, bidAmount } from '../services/bidValidation'; @@ -115,7 +115,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu const kalemToplami = useMemo(() => bidTotal(satirlar), [satirlar]); const latestDraft = useRef(''); - latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource]); + latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource,userProfile.scrapedCapabilities?.healthPortfolio]); const mounted = useRef(true); const printWindow = useRef(null); const [printing,setPrinting] = useState(false); @@ -137,7 +137,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu const [deadlineNow,setDeadlineNow] = useState(() => new Date()); useEffect(() => {const timer=setInterval(()=>setDeadlineNow(new Date()),30000);return ()=>clearInterval(timer);},[]); const deadlineProblem = preparationDeadlineProblem(tender,deadlineNow); - const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn}); + const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,healthPortfolio:userProfile.scrapedCapabilities?.healthPortfolio || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn}); const analizEksik = effectiveResults.sourceReview?.status === 'partial'; const hazir = !deadlineProblem && eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection; @@ -181,12 +181,14 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu // Open during the click; async validation must not lose popup activation. printWindow.current = belgeYazdir('Dosya kontrolü', '

Belgeler kontrol ediliyor…

'); setPrinting(true); - const currentFiles = await listDocuments(); + const linkedPortfolio=lotSelection.rows.some(row=>checks.products?.[row.priceKey]?.portfolioId); + const readPortfolio=async()=>linkedPortfolio ? (await getAllUserData()).firma_profil?.scrapedCapabilities?.healthPortfolio || [] : []; + const [currentFiles,currentPortfolio] = await Promise.all([listDocuments(),readPortfolio()]); const reviewedText = effectiveResults.sourceReview?.humanReview; 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),tenderIkn:tender.ikn}; + const currentVault = {healthPortfolio:currentPortfolio,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(' ')); @@ -196,6 +198,9 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu if (tip === 'excel') { const buffer = await createBidWorkbook(f,satirlar); if (reviewedText) await validateDocumentTextReview(reviewedText,tender.ikn); + const finalPortfolio=await readPortfolio(); + const finalErrors=preparationBlockers(lotSelection.rows,documents,checks,{...currentVault,healthPortfolio:finalPortfolio}); + if(finalErrors.length)throw new Error(finalErrors.join(' ')); if (!mounted.current || session !== getToken() || submitted !== latestDraft.current) throw new Error('Form veya oturum değişti; Excel çıktısını yeniden oluşturun.'); const finalDeadlineProblem=preparationDeadlineProblem(tender); diff --git a/src/components/PreparationChecks.jsx b/src/components/PreparationChecks.jsx index 8a0746e..1aca607 100644 --- a/src/components/PreparationChecks.jsx +++ b/src/components/PreparationChecks.jsx @@ -1,3 +1,4 @@ +import {portfolioSelectionProblem} from '../services/portfolioRevision'; import {useApp} from '../context/AppContext'; import {portfolioProductText,selectPortfolioProduct} from '../services/healthPortfolio'; import React, {useState} from 'react'; @@ -57,6 +58,8 @@ export function PreparationChecks({rows, documents, manualDocuments, setManualDo {rows.map(row => { const c=checks.products?.[row.priceKey] || {}; + const portfolioProblem=portfolioSelectionProblem(c,portfolio); + const currentProduct=portfolio.find(item=>item.id===c.portfolioId); return
{row.name} — ürün/hizmet ve yükümlülük kontrolü
@@ -68,6 +71,10 @@ export function PreparationChecks({rows, documents, manualDocuments, setManualDo {portfolio.filter(item=>portfolioProductText(item)).map(item=>)} + {portfolioProblem&&
+

{portfolioProblem}

+ {currentProduct&&} +
} {c.portfolioSnapshot&&

Seçim anındaki firma beyanı: {c.portfolioSnapshot.kind==='device'?'Cihaz ve servis':c.portfolioSnapshot.kind==='staffedService'?'Personelli hizmet':'Sarf / test ürünü'} · ÜTS: {{declared:'Kayıt mevcut — firma beyanı',notApplicable:'Uygulanmıyor — firma beyanı',unknown:'Kontrol gerekli'}[c.portfolioSnapshot.utsStatus] || 'Kontrol gerekli'} · {c.portfolioSnapshot.utsReference || 'Kayıt açıklaması yok'} · Servis: {c.portfolioSnapshot.serviceCapacity || 'Belirtilmedi'}. İhale koşullarını aşağıda ayrıca doğrulayın.

} diff --git a/src/components/PreparationOverview.jsx b/src/components/PreparationOverview.jsx index 0b08538..da09482 100644 --- a/src/components/PreparationOverview.jsx +++ b/src/components/PreparationOverview.jsx @@ -9,7 +9,7 @@ export function PreparationOverview({tenders,now}) { return ; } function Overview({tenders,now}) { - const {setActiveTenderDetail}=useApp(); + const {setActiveTenderDetail,userProfile}=useApp(); const [vault,setVault]=useState({loading:true,error:null,documents:[]}),[refresh,setRefresh]=useState(0); useEffect(()=>{ let cancelled=false; @@ -29,7 +29,7 @@ function Overview({tenders,now}) {
{tenders.map(tender=>{ - const summary=preparationSummary(tender,vault,now); + const summary=preparationSummary(tender,{...vault,healthPortfolio:userProfile.scrapedCapabilities?.healthPortfolio || []},now); return diff --git a/src/services/portfolioRevision.js b/src/services/portfolioRevision.js new file mode 100644 index 0000000..bc46939 --- /dev/null +++ b/src/services/portfolioRevision.js @@ -0,0 +1,15 @@ +// Compare declared product facts only; field order and unrelated metadata do not +// invalidate a review. Missing/duplicate identities cannot establish provenance. +export function portfolioRevision(item) { + if (!item || typeof item !== 'object') return null; + return JSON.stringify(['id','group','brand','model','kind','utsStatus','utsReference','serviceCapacity'].map(key=>String(item[key] ?? ''))); +} +export function portfolioSelectionProblem(check, portfolio) { + if (!check?.portfolioId) return null; + if (!Array.isArray(portfolio)) return 'Firma portföyü doğrulanamadı; güncel profili kontrol edin.'; + const matches=portfolio.filter(item=>item?.id===check.portfolioId); + if (matches.length!==1) return 'Seçilen portföy kaydı kaldırılmış veya kimliği belirsiz; ürünü yeniden seçin.'; + if (check.portfolioSnapshot?.id!==check.portfolioId || portfolioRevision(check.portfolioSnapshot)!==portfolioRevision(matches[0])) + return 'Firma portföyündeki ürün bilgileri değişti; güncel ürünü seçip uygunluk kontrollerini yeniden yapın.'; + return null; +} diff --git a/src/services/preparationChecks.js b/src/services/preparationChecks.js index 237de69..86bb1f5 100644 --- a/src/services/preparationChecks.js +++ b/src/services/preparationChecks.js @@ -1,3 +1,4 @@ +import {portfolioSelectionProblem} from './portfolioRevision.js'; export const HEALTH_CHECKS = [ ['uts', 'ÜTS / ürün kaydı'], ['sample', 'Numune / demonstrasyon'], @@ -37,6 +38,8 @@ export function preparationBlockers(rows, documents, checks = {}, vault) { } for (const row of rows) { const c = checks.products?.[row.priceKey] || {}; + const portfolioProblem=portfolioSelectionProblem(c,vault?.healthPortfolio); + if(portfolioProblem)errors.push(`${row.name}: ${portfolioProblem}`); if (c.status !== 'suitable' || !c.product?.trim() || !c.source?.trim()) errors.push(`${row.name}: ürün/hizmet uygunluğu ve teknik kaynak eksik veya uygun değil.`); for (const [key, label] of HEALTH_CHECKS) { diff --git a/tests/health-portfolio.test.mjs b/tests/health-portfolio.test.mjs index ed61dc0..14c5fcb 100644 --- a/tests/health-portfolio.test.mjs +++ b/tests/health-portfolio.test.mjs @@ -10,3 +10,31 @@ test('portfolio selection preserves declaration snapshot without approving tende for(const [key] of HEALTH_CHECKS)assert.deepEqual(check[key],{status:'pending',evidence:''}); const manual=selectPortfolioProduct(null);assert.equal(manual.portfolioId,null);assert.equal(manual.portfolioSnapshot,null); }); + +test('portfolio revisions reject changed, missing and ambiguous declarations',async()=>{ + const {portfolioSelectionProblem}=await import('../src/services/portfolioRevision.js'); + const item={id:'device',group:'Analiz',brand:'QA',model:'M1',kind:'device',utsStatus:'declared',utsReference:'TEST',serviceCapacity:'2 uzman'}; + const check=selectPortfolioProduct(item); + assert.equal(portfolioSelectionProblem(check,[{...item,updatedAt:'later'}]),null); + for(const key of ['group','brand','model','kind','utsStatus','utsReference','serviceCapacity']) + assert.match(portfolioSelectionProblem(check,[{...item,[key]:'changed'}]),/değişti/,key); + assert.match(portfolioSelectionProblem(check,[]),/kaldırılmış/); + assert.match(portfolioSelectionProblem(check,[item,item]),/belirsiz/); + assert.match(portfolioSelectionProblem(check,undefined),/doğrulanamadı/); + assert.match(portfolioSelectionProblem({...check,portfolioSnapshot:null},[item]),/değişti/); + assert.equal(portfolioSelectionProblem(selectPortfolioProduct(null),undefined),null); +}); +test('fully approved product is blocked when its portfolio changes; reselecting requires new evidence',async()=>{ + const {preparationBlockers}=await import('../src/services/preparationChecks.js'); + const item={id:'kit',group:'Test kiti',model:'M1',kind:'consumable'}; + const approved={...selectPortfolioProduct(item),status:'suitable',source:'Teknik madde 2', + ...Object.fromEntries(HEALTH_CHECKS.map(([key])=>[key,{status:'verified',evidence:'Kontrol belgesi 1'}]))}; + const rows=[{priceKey:'p1',name:'Kit'}],documents=[{document:'İsteğe bağlı',mandatory:false}]; + const checks={products:{p1:approved},documents:{'["İsteğe bağlı",false,""]':{status:'notApplicable',evidence:'Şartname 4'}}}; + assert.deepEqual(preparationBlockers(rows,documents,checks,{healthPortfolio:[item]}),[]); + const changed={...item,model:'M2'}; + assert.ok(preparationBlockers(rows,documents,checks,{healthPortfolio:[changed]}).some(e=>e.includes('değişti'))); + const refreshed={...checks,products:{p1:selectPortfolioProduct(changed)}}; + const errors=preparationBlockers(rows,documents,refreshed,{healthPortfolio:[changed]}); + assert.equal(errors.length,5);assert.ok(errors.every(e=>!e.includes('değişti'))); +});
İhaleSorumluBelge eksikleriSıradaki kontrol

{summary.issues.length ? `${summary.issues.length} kontrol eksik` : 'Kayıtlı kontroller tamam; son kontrol gerekli'}

{summary.owner}