diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 24322d5..d71a4f3 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -27,9 +27,16 @@ Planın tamamı henüz bitmedi. Canlı yayın yapılmadı. Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-reload.png), [ekran metni](evidence/qa-kanban-after-reload.txt). Bu kanıt gerçek beş ihale dokümanıyla başvuru kabulü değildir. +## Kaynak değişikliği ve fiyat koruması + +- Fiyatlar dizi sırasından bağımsız, kalem kimliği/içeriğine bağlı saklanır. Sıralama değişimi fiyatı başka ürüne taşımaz; miktar veya teknik özellik değişince eski fiyat yeni satıra uygulanmaz. +- Kaynak ilan, tarih/saat, kalemler ve belge gereksinimleri için karşılaştırılabilir bir içerik kaydı tutulur. Kaynak değişince resmi dokümanla karşılaştırma onayı geçersiz olur, çıktı yeniden kontrol bekler. +- Eski konumsal fiyatlar kaynak satırları olmadan güvenle eşlenemediği için otomatik taşınmaz; kullanıcıya yeniden giriş gerektiği gösterilir. Bu davranış yeni taslak kaydında da korunur. +- Üç regresyon testi sıralama, içerik değişikliği ve eski taslak senaryolarını doğrular. Henüz gerçek belge yükleme/zeyilname ve tarayıcı form kabulü tamamlanmadı. Bu kontrol tüm belge/AI kaynak sürüm modelinin yerine geçmez. + ## Doğrulama -`npm test`: 19/19 geçti (son yeniden çalıştırma: 6 Eylül 2026). +`npm test`: 22/22 geçti (son yeniden çalıştırma: 6 Eylül 2026). `npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor. `node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti. diff --git a/src/components/BidPackageBuilder.jsx b/src/components/BidPackageBuilder.jsx index f0f880d..0e1a5f9 100644 --- a/src/components/BidPackageBuilder.jsx +++ b/src/components/BidPackageBuilder.jsx @@ -1,5 +1,6 @@ import React, { useState, useMemo } from 'react'; import { bidRowTotal, validateBidRows, roundMoney } from '../services/bidValidation'; +import { sourceRows, bidSourceSnapshot, restoreBidPrices } from '../services/bidSource'; import { useApp } from '../context/AppContext'; import { FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks, @@ -58,10 +59,12 @@ export const BidPackageBuilder = ({ results, tender }) => { const [kisim, setKisim] = useState(storedDraft.kisim || ''); const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || ''); - const kalemler = tender?.items?.length - ? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]})) - : (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []); - const [fiyatlar, setFiyatlar] = useState(storedDraft.fiyatlar || {}); + const kalemler = sourceRows(tender, results); + const sourceSnapshot = bidSourceSnapshot(tender, results, kalemler); + const [reviewedSource, setReviewedSource] = useState(storedDraft.reviewedSource || null); + const [fiyatlar, setFiyatlar] = useState(() => restoreBidPrices(storedDraft)); + const kaynakKontrolGerekli = reviewedSource !== sourceSnapshot; + const eskiFiyatlar = storedDraft.priceSchema !== 1 && Object.keys(storedDraft.fiyatlar || {}).length > 0; const alanYaz = (k, v) => { const yeni = { ...firma, [k]: v }; @@ -70,13 +73,13 @@ export const BidPackageBuilder = ({ results, tender }) => { }; const satirlar = useMemo( - () => kalemler.map((k, i) => ({ ...k, birimFiyat: fiyatlar[i] })), + () => kalemler.map(k => ({ ...k, birimFiyat: fiyatlar[k.priceKey] })), [kalemler, fiyatlar], ); const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]); const saveDraft = async () => { - const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar}}); + const saved = await mutateTender(tender.id, {bidDraft:{firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource}}); if (saved) { setDirty(false); setHata(null); } else setHata('Taslak kaydedilemedi. Tekrar deneyin.'); }; @@ -87,7 +90,7 @@ export const BidPackageBuilder = ({ results, tender }) => { const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0; const turCeliskisi = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || ''); const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1])); - const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik; + const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli; const formVerisi = () => ({ ...firma, @@ -142,6 +145,19 @@ export const BidPackageBuilder = ({ results, tender }) => { {dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'} +
+ {kaynakKontrolGerekli &&

+ {reviewedSource ? 'İlan, kalem veya belge bilgisi değişti. Önceki kontrol bu sürüm için geçerli değil.' : 'Kalemler ve teklif koşulları resmi dokümanla henüz karşılaştırılmadı.'} + {' '}Kaynağı kontrol etmeden teklif çıktısı oluşturulamaz. +

} + {eskiFiyatlar &&

Eski taslak fiyatları satır sırasına göre kaydedilmiş. Yanlış ürüne aktarılmaması için fiyatları yeniden girin.

} + +
+ {/* Sizden beklenen bilgiler */} {eksikler.length > 0 && (
@@ -245,16 +261,16 @@ export const BidPackageBuilder = ({ results, tender }) => { const { miktar, birim } = miktarAyristir(r.quantity); const tutar = bidRowTotal(r); return ( - + {r.name || '—'} {miktar ?? '—'} {birim} - setFiyatlar((o) => ({ ...o, [i]: e.target.value.replace(/[^\d.]/g, '') }))} + setFiyatlar((o) => ({ ...o, [r.priceKey]: e.target.value.replace(/[^\d.]/g, '') }))} placeholder={r.estimatedUnitPrice != null ? String(r.estimatedUnitPrice) : '0'} className="w-28 text-xs text-right px-2 py-1 rounded border border-slate-300" /> diff --git a/src/components/DeepAnalysisReport.jsx b/src/components/DeepAnalysisReport.jsx index 53f6345..09f32fc 100644 --- a/src/components/DeepAnalysisReport.jsx +++ b/src/components/DeepAnalysisReport.jsx @@ -323,7 +323,7 @@ export const DeepAnalysisReport = ({ results, tender }) => { )} {/* Analiz ve yol haritası okunduktan sonraki adım: teklif zarfını hazırla */} - +
); diff --git a/src/services/bidSource.js b/src/services/bidSource.js new file mode 100644 index 0000000..319fc79 --- /dev/null +++ b/src/services/bidSource.js @@ -0,0 +1,31 @@ +// Prices belong to a specific source row, never to its current array position. +export function sourceRows(tender, results) { + const items = 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(); + return items.map(k => { + const identity = JSON.stringify([k.id ?? k.no ?? null, k.kisim ?? null, k.name, k.quantity, k.keySpecs || []]); + const occurrence = occurrences.get(identity) || 0; + occurrences.set(identity, occurrence + 1); + return { ...k, priceKey: JSON.stringify([identity, occurrence]) }; + }); +} + +// 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)) { + return JSON.stringify({ + tender: tender?.ikn || tender?.id, + announcement: tender?.aciklama || '', + deadline: [tender?.tarih || '', tender?.saat || ''], + documentVersion: results?.documentVersion ?? tender?.documentVersion ?? null, + rows: rows.map(r => r.priceKey).sort(), + documents: results?.documentChecklist || [], + }); +} + +export function restoreBidPrices(draft = {}) { + // Old positional prices cannot safely be assigned without their original rows. + return draft.priceSchema === 1 && draft.pricesBySource ? draft.pricesBySource : {}; +} diff --git a/tests/bid-source.test.mjs b/tests/bid-source.test.mjs new file mode 100644 index 0000000..a98b351 --- /dev/null +++ b/tests/bid-source.test.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {sourceRows,bidSourceSnapshot,restoreBidPrices} 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('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'}}; + const reordered={...tender,items:[...tender.items].reverse()}; + const rows=sourceRows(reordered),prices=restoreBidPrices(draft); + assert.equal(prices[rows[0].priceKey],'900'); + assert.equal(prices[rows[1].priceKey],'15'); + assert.equal(bidSourceSnapshot(tender),bidSourceSnapshot(reordered)); +}); +test('changed quantity, requirements and announcement invalidate reviewed source',()=>{ + const original=sourceRows(tender),prices={[original[0].priceKey]:'15'}; + const changed={...tender,items:[{...tender.items[0],miktar:20},tender.items[1]]}; + assert.equal(prices[sourceRows(changed)[0].priceKey],undefined); + const snapshot=bidSourceSnapshot(tender); + for(const updated of [changed,{...tender,aciklama:'150 gün'},{...tender,saat:'10:30'}]) + assert.notEqual(snapshot,bidSourceSnapshot(updated)); + assert.notEqual(snapshot,bidSourceSnapshot(tender,{documentChecklist:['Numune']})); +}); +test('legacy positional prices are not silently assigned to potentially changed source rows',()=>{ + assert.deepEqual(restoreBidPrices({fiyatlar:{0:'15',1:'900'}}),{}); +});