fix: bind bid prices and review to source content
This commit is contained in:
@@ -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.
|
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
|
## 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.
|
`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.
|
`node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { bidRowTotal, validateBidRows, roundMoney } from '../services/bidValidation';
|
import { bidRowTotal, validateBidRows, roundMoney } from '../services/bidValidation';
|
||||||
|
import { sourceRows, bidSourceSnapshot, restoreBidPrices } from '../services/bidSource';
|
||||||
import { useApp } from '../context/AppContext';
|
import { useApp } from '../context/AppContext';
|
||||||
import {
|
import {
|
||||||
FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks,
|
FolderCheck, ChevronDown, ChevronUp, FileSignature, Table2, ListChecks,
|
||||||
@@ -58,10 +59,12 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
|||||||
const [kisim, setKisim] = useState(storedDraft.kisim || '');
|
const [kisim, setKisim] = useState(storedDraft.kisim || '');
|
||||||
const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || '');
|
const [goturuBedel, setGoturuBedel] = useState(storedDraft.goturuBedel || '');
|
||||||
|
|
||||||
const kalemler = tender?.items?.length
|
const kalemler = sourceRows(tender, results);
|
||||||
? tender.items.map(k => ({name:k.ad,quantity:`${k.miktar} ${k.birim || ''}`,keySpecs:[]}))
|
const sourceSnapshot = bidSourceSnapshot(tender, results, kalemler);
|
||||||
: (Array.isArray(results?.itemsAndSpecs) ? results.itemsAndSpecs : []);
|
const [reviewedSource, setReviewedSource] = useState(storedDraft.reviewedSource || null);
|
||||||
const [fiyatlar, setFiyatlar] = useState(storedDraft.fiyatlar || {});
|
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 alanYaz = (k, v) => {
|
||||||
const yeni = { ...firma, [k]: v };
|
const yeni = { ...firma, [k]: v };
|
||||||
@@ -70,13 +73,13 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const satirlar = useMemo(
|
const satirlar = useMemo(
|
||||||
() => kalemler.map((k, i) => ({ ...k, birimFiyat: fiyatlar[i] })),
|
() => kalemler.map(k => ({ ...k, birimFiyat: fiyatlar[k.priceKey] })),
|
||||||
[kalemler, fiyatlar],
|
[kalemler, fiyatlar],
|
||||||
);
|
);
|
||||||
|
|
||||||
const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]);
|
const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]);
|
||||||
const saveDraft = async () => {
|
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); }
|
if (saved) { setDirty(false); setHata(null); }
|
||||||
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
|
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
|
||||||
};
|
};
|
||||||
@@ -87,7 +90,7 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
|||||||
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 = teklifTuru === 'goturu' && /birim fiyat/i.test(tender?.aciklama || '');
|
||||||
const sureEksik = !(Number(gecerlilikGun) > 0) || (kaynakGecerlilik && Number(gecerlilikGun) < Number(kaynakGecerlilik[1]));
|
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 = () => ({
|
const formVerisi = () => ({
|
||||||
...firma,
|
...firma,
|
||||||
@@ -142,6 +145,19 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
|||||||
<span role="status" className="text-xs">{dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'}</span>
|
<span role="status" className="text-xs">{dirty ? 'Kaydedilmemiş değişiklikler var' : storedDraft.firma ? 'Sunucuda kayıtlı taslak' : 'Henüz kaydedilmedi'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||||
|
{kaynakKontrolGerekli && <p role="alert" className="text-xs text-amber-900">
|
||||||
|
{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.
|
||||||
|
</p>}
|
||||||
|
{eskiFiyatlar && <p className="text-xs text-amber-900">Eski taslak fiyatları satır sırasına göre kaydedilmiş. Yanlış ürüne aktarılmaması için fiyatları yeniden girin.</p>}
|
||||||
|
<label className="flex items-start gap-2 text-xs text-slate-800">
|
||||||
|
<input type="checkbox" checked={!kaynakKontrolGerekli}
|
||||||
|
onChange={e => setReviewedSource(e.target.checked ? sourceSnapshot : null)} />
|
||||||
|
Bu sürümün kalemlerini, miktarlarını, teklif türünü ve geçerlilik süresini resmi dokümanla karşılaştırdım.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sizden beklenen bilgiler */}
|
{/* Sizden beklenen bilgiler */}
|
||||||
{eksikler.length > 0 && (
|
{eksikler.length > 0 && (
|
||||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-3.5 flex gap-2.5">
|
<div className="bg-amber-50 border border-amber-300 rounded-xl p-3.5 flex gap-2.5">
|
||||||
@@ -245,16 +261,16 @@ export const BidPackageBuilder = ({ results, tender }) => {
|
|||||||
const { miktar, birim } = miktarAyristir(r.quantity);
|
const { miktar, birim } = miktarAyristir(r.quantity);
|
||||||
const tutar = bidRowTotal(r);
|
const tutar = bidRowTotal(r);
|
||||||
return (
|
return (
|
||||||
<tr key={i} className="border-b border-slate-100">
|
<tr key={r.priceKey} className="border-b border-slate-100">
|
||||||
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td>
|
<td className="py-1.5 pr-2 text-slate-800">{r.name || '—'}</td>
|
||||||
<td className="py-1.5 text-right text-slate-600 whitespace-nowrap">
|
<td className="py-1.5 text-right text-slate-600 whitespace-nowrap">
|
||||||
{miktar ?? '—'} {birim}
|
{miktar ?? '—'} {birim}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 text-right">
|
<td className="py-1.5 text-right">
|
||||||
<input
|
<input
|
||||||
value={fiyatlar[i] ?? ''}
|
value={fiyatlar[r.priceKey] ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
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'}
|
placeholder={r.estimatedUnitPrice != null ? String(r.estimatedUnitPrice) : '0'}
|
||||||
className="w-28 text-xs text-right px-2 py-1 rounded border border-slate-300"
|
className="w-28 text-xs text-right px-2 py-1 rounded border border-slate-300"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export const DeepAnalysisReport = ({ results, tender }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Analiz ve yol haritası okunduktan sonraki adım: teklif zarfını hazırla */}
|
{/* Analiz ve yol haritası okunduktan sonraki adım: teklif zarfını hazırla */}
|
||||||
<BidPackageBuilder results={results} tender={tender} />
|
<BidPackageBuilder key={tender?.ikn || tender?.id} results={results} tender={tender} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 : {};
|
||||||
|
}
|
||||||
@@ -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'}}),{});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user