diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 2b393db..62ada5f 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -184,3 +184,11 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Detay penceresine dialog rolü, başlığıyla erişilebilir ad, kapatma düğmesine ad, başlangıç odağı, Tab/Shift+Tab döngüsü ve Escape ile kapanış eklendi. Kapanışta hâlâ DOM’da bulunan açan kontrole odak dönüyor. İç içe başka dialogdan gelen olaylar alt pencereyi kapatmıyor. - Tarayıcı kanıtı: `evidence/qa-detail-keyboard.txt`. Yerel sentetik Giresun kaydı başlık düğmesinden Enter ile açıldı; ilk/son kontrol arasında ileri-geri döngü ve Escape sonrası başlık düğmesine dönüş doğrulandı. Sağdaki yaklaşan ihale düğmesinden açılış/dönüş de geçti. - Üretim derlemesi geçti; mevcut paket boyutu uyarıları sürüyor. Bu kontrol bütün modallerin erişilebilirliğini, ekran okuyucu deneyimini veya gerçek mobil genişlikleri doğrulamaz. Arka planın inert yönetimi ve üst üste farklı modallerin tüm kombinasyonları ayrıca ele alınmalı. + +### D04 / D06 — Havuz ve analiz kalemlerinin farklı olması + +- Önceden havuzda en az bir kalem olduğunda analiz kalemleri sessizce kullanılmıyordu. Form artık iki dolu listenin ad/miktar/teknik özellik envanterini karşılaştırıyor; farklılık varsa kaynak seçilene kadar çıktı hazır sayılmıyor. +- Kullanıcı resmi cetvelle karşılaştırarak ihale havuzu veya doküman analizi listesini seçebiliyor. Seçim taslakla kaydediliyor; liste içeriği ya da analiz belge sürümü değişirse yeniden seçim gerekiyor. Seçim, kaynak ve uygunluk incelemesi yerine geçmiyor. +- Kaynak anlık görüntüsü kullanılmayan analiz listesini de içeriyor; analiz listesi değişimi önceki kaynak onayını geçersizleştiriyor. Fiyatlar seçilen gerçek satır kimlikleriyle eşleniyor. +- Bekleyen taslak kaydı sonrasında kapatılmış bileşene hata yazılması da önlendi. +- Kanıt: 55/55 test ve üretim derlemesi geçti. Testler iki farklı miktar listesi, açık seçim, sürüm/liste değişimi ve aynı satırların farklı sırada bulunmasını kapsıyor. Gerçek resmi cetvellerin semantik eşleştirmesi ve kaynak seçiminin tarayıcıda uçtan uca kabulü hâlâ açık; farklı isimlendirmeler kullanıcı karşılaştırması gerektirebilir. diff --git a/src/components/BidPackageBuilder.jsx b/src/components/BidPackageBuilder.jsx index 2a13548..661026d 100644 --- a/src/components/BidPackageBuilder.jsx +++ b/src/components/BidPackageBuilder.jsx @@ -4,7 +4,7 @@ import { listDocuments, downloadDocument } from '../services/account'; import { tenderDateKey } from '../services/tenderCalendar'; import React, { useState, useMemo, useEffect, useRef } from 'react'; import { bidRowTotal, validateBidRows, roundMoney, parseBidPrice } from '../services/bidValidation'; -import { sourceRows, bidSourceSnapshot, restoreBidPrices } from '../services/bidSource'; +import { sourceRows, bidSourceSnapshot, restoreBidPrices, bidSourceOptions } from '../services/bidSource'; import { rowLot, selectBidLots } from '../services/bidLots'; import { PreparationChecks } from './PreparationChecks'; import { preparationBlockers, documentKey } from '../services/preparationChecks'; @@ -77,14 +77,16 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { 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 || []); const [checks, setChecks] = useState(storedDraft.checks || {}); const documents = [...new Map([...(effectiveResults.documentChecklist || []), ...manualDocuments].map(d => [documentKey(d),d])).values()]; const [partialBid,setPartialBid] = useState(storedDraft.partialBid || {enabled:false,source:'',selected:[],assignments:{}}); - const kalemler = sourceRows(tender, effectiveResults); + const kalemler = sourceRows(tender, effectiveResults, sourceOptions.selected || 'tender'); const lotSelection = selectBidLots(kalemler,partialBid); const allLots = [...new Set(kalemler.map(r=>rowLot(r,partialBid.assignments)).filter(Boolean))]; - const sourceSnapshot = JSON.stringify([bidSourceSnapshot(tender, {...effectiveResults, documentChecklist:documents}, kalemler), partialBid]); + const sourceSnapshot = JSON.stringify([bidSourceSnapshot(tender, {...effectiveResults, documentChecklist:documents}, kalemler), partialBid, itemSource, sourceOptions.signature]); const [reviewedSource, setReviewedSource] = useState(storedDraft.reviewedSource || null); const [fiyatlar, setFiyatlar] = useState(() => restoreBidPrices(storedDraft)); const kaynakKontrolGerekli = reviewedSource !== sourceSnapshot; @@ -103,15 +105,16 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]); const latestDraft = useRef(''); - latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid]); + latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource]); const mounted = useRef(true); const printWindow = useRef(null); const [printing,setPrinting] = useState(false); 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,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null,sourceReview:effectiveResults.sourceReview || null}}}); - if (saved && mounted.current) { setDirty(latestDraft.current !== submitted); setHata(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,sourceReview:effectiveResults.sourceReview || null}}}); + if (!mounted.current) return; + if (saved) { setDirty(latestDraft.current !== submitted); setHata(null); } else setHata('Taslak kaydedilemedi. Tekrar deneyin.'); }; @@ -123,7 +126,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1])); 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; + const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection; const formVerisi = () => ({ ...firma, @@ -214,6 +217,21 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { + {sourceOptions.conflict &&
+ Kalem kaynakları farklı +

İhale havuzundaki liste ile analiz listesi aynı değil. Resmi cetvelle karşılaştırıp kullanılacak listeyi seçin. Seçim uygunluk onayı değildir; ardından kalem, fiyat ve belge kontrollerini tamamlayın.

+ + {sourceOptions.needsSelection &&

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

} +
} {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 2281d3b..bcc1dfd 100644 --- a/src/services/bidSource.js +++ b/src/services/bidSource.js @@ -1,6 +1,6 @@ // Prices belong to a specific source row, never to its current array position. -export function sourceRows(tender, results) { - const items = tender?.items?.length +export function sourceRows(tender, results, preferredSource = 'tender') { + const items = preferredSource !== 'analysis' && tender?.items?.length ? tender.items.map(k => ({ ...k, name: k.ad, quantity: `${k.miktar} ${k.birim || ''}`, keySpecs: k.keySpecs || [] })) : (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []); const occurrences = new Map(); @@ -12,6 +12,18 @@ export function sourceRows(tender, results) { }); } +export function bidSourceOptions(tender, results, selection = {}) { + const tenderRows = sourceRows(tender, {}); + const analysisRows = sourceRows({}, results); + const inventory = rows => rows.map(r => JSON.stringify([r.name?.trim(),r.quantity?.trim(),r.keySpecs || []])).sort(); + const signature = JSON.stringify([inventory(tenderRows), inventory(analysisRows), results?.documentVersion || null]); + const conflict = tenderRows.length > 0 && analysisRows.length > 0 && + JSON.stringify(inventory(tenderRows)) !== JSON.stringify(inventory(analysisRows)); + const selected = selection.signature === signature && ['tender','analysis'].includes(selection.source) ? selection.source : ''; + return { signature, conflict, selected, tenderCount:tenderRows.length, analysisCount:analysisRows.length, + needsSelection:conflict && !selected }; +} + // Exact canonical content, not a lossy hash. Reordering distinct rows is harmless; // a changed quantity, specification, announcement or document requirement is not. export function bidSourceSnapshot(tender, results, rows = sourceRows(tender, results)) { @@ -20,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, + analyzedItems: results?.itemsAndSpecs || [], rows: rows.map(r => r.priceKey).sort(), documents: results?.documentChecklist || [], }); diff --git a/tests/bid-source.test.mjs b/tests/bid-source.test.mjs index a98b351..c24867e 100644 --- a/tests/bid-source.test.mjs +++ b/tests/bid-source.test.mjs @@ -1,7 +1,21 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {sourceRows,bidSourceSnapshot,restoreBidPrices} from '../src/services/bidSource.js'; +import {sourceRows,bidSourceSnapshot,restoreBidPrices,bidSourceOptions} from '../src/services/bidSource.js'; const tender = {ikn:'2026/test',aciklama:'120 gün',items:[{id:1,ad:'Kit',miktar:10,birim:'adet'},{id:2,ad:'Cihaz',miktar:2,birim:'adet'}]}; +test('conflicting source inventories require explicit choice and invalidate that choice on change',()=>{ + const results={itemsAndSpecs:[{name:'Kit',quantity:'20 adet'}],documentVersion:'v1'}; + const options=bidSourceOptions(tender,results); + assert.equal(options.needsSelection,true); + const selection={source:'analysis',signature:options.signature}; + assert.equal(bidSourceOptions(tender,results,selection).needsSelection,false); + assert.deepEqual(sourceRows(tender,results,'analysis').map(r=>r.quantity),['20 adet']); + assert.equal(bidSourceOptions(tender,{...results,documentVersion:'v2'},selection).needsSelection,true); + assert.equal(bidSourceOptions({...tender,items:[tender.items[0]]},results,selection).needsSelection,true); + assert.notEqual(bidSourceSnapshot(tender,results),bidSourceSnapshot(tender,{...results,itemsAndSpecs:[{name:'Kit',quantity:'30 adet'}]})); + const matching={itemsAndSpecs:[{name:'Cihaz',quantity:'2 adet'},{name:'Kit',quantity:'10 adet'}]}; + assert.equal(bidSourceOptions(tender,matching).conflict,false); + assert.equal(bidSourceOptions({},results).conflict,false); +}); test('reordered source rows retain their own prices without invalidating identical content',()=>{ const original=sourceRows(tender); const draft={priceSchema:1,pricesBySource:{[original[0].priceKey]:'15',[original[1].priceKey]:'900'}};