From 0d332afdb101ff488e41d305b6582bb0b8bc2f98 Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 08:52:07 +0300 Subject: [PATCH] feat: prepare bids by official lot selection --- docs/quality/evidence/qa-lot-selection.txt | 83 ++++++++++++++++++++++ docs/quality/uygulama-durumu.md | 10 ++- src/components/BidPackageBuilder.jsx | 47 +++++++++--- src/services/bidLots.js | 12 ++++ src/services/bidSource.js | 2 +- tests/bid-lots.test.mjs | 21 ++++++ 6 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 docs/quality/evidence/qa-lot-selection.txt create mode 100644 src/services/bidLots.js create mode 100644 tests/bid-lots.test.mjs diff --git a/docs/quality/evidence/qa-lot-selection.txt b/docs/quality/evidence/qa-lot-selection.txt new file mode 100644 index 0000000..c3d43dd --- /dev/null +++ b/docs/quality/evidence/qa-lot-selection.txt @@ -0,0 +1,83 @@ + + - checkbox "Yalnız seçtiğim resmi kısımlara teklif hazırlıyorum" [checked] + - text: Yalnız seçtiğim resmi kısımlara teklif hazırlıyorum + - text: Kısmi teklif izni — resmi doküman ve madde + - textbox "Kısmi teklif izni — resmi doküman ve madde": Sentetik QA örneği madde 19 + - paragraph: Resmi kısım numaralarını cetvelden doğrulayın. Aynı kısımdaki tüm kalemler birlikte seçilir; seçilmeyenler toplam ve çıktı dışında kalır. + - checkbox "Kısım 1" [checked] + - text: Kısım 1 + - generic: Sentetik test kalemi + - generic: Resmi kısım + - textbox "Sentetik test kalemi resmi kısım": "1" + - heading "Kalem fiyatlandırma" [level=5] + - paragraph: Ondalık fiyatı 125,50 veya 125.50 biçiminde yazın; binlik ayırıcı kullanmayın. + - table: + - rowgroup: + - row "Kalem Miktar Birim fiyat Tutar": + - columnheader "Kalem" + - columnheader "Miktar" + - columnheader "Birim fiyat" + - columnheader "Tutar" + - rowgroup: + - row "Sentetik test kalemi 2 adet 125,50 251,00": + - cell "Sentetik test kalemi" + - cell "2 adet" + - cell "125,50": + - textbox "0": 125,50 + - cell "251,00" + - region "Belge ve ürün uygunluğu": + - heading "Belge ve ürün uygunluğu" [level=5] + - paragraph: 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. + - group "Sentetik yetki belgesi (zorunlu)": + - generic: Sentetik yetki belgesi (zorunlu) + - paragraph: Yerel QA dokümanı madde 7 + - button "Elle eklenen gereksinimi kaldır" + - combobox "Kontrol durumu": + - option "Kontrol gerekli" + - option "Doğrulandı" [selected] + - text: Belge adı/sürümü, geçerlilik kontrolü ve kaynak madde + - textbox "Belge adı/sürümü, geçerlilik kontrolü ve kaynak madde": QA dosyası v1 — yalnız test, madde 7 + - paragraph: Resmi dokümandan eksik belge gereksinimi ekle + - text: Belge adı + - textbox "Belge adı" + - text: Kaynak doküman ve madde + - textbox "Kaynak doküman ve madde" + - button "Belge gereksinimi ekle" [disabled] + - generic "Sentetik test kalemi — ürün/hizmet ve yükümlülük kontrolü" + - option "Kontrol gerekli" + - option "Uygun" [selected] + - option "Uygun değil" + - option "Kontrol gerekli" + - option "Doğrulandı" + - option "Uygulanmıyor — gerekçe gerekli" [selected] + - option "Kontrol gerekli" + - option "Doğrulandı" + - option "Uygulanmıyor — gerekçe gerekli" [selected] + - option "Kontrol gerekli" + - option "Doğrulandı" + - option "Uygulanmıyor — gerekçe gerekli" [selected] + - option "Kontrol gerekli" + - option "Doğrulandı" + - option "Uygulanmıyor — gerekçe gerekli" [selected] + - generic: Teklif toplamı (KDV hariç) + - generic: 251,00 TL + - paragraph: "Yazı ile: ikiyüzellibir Türk Lirası" + - heading "Hazırlanacak belgeler" [level=5] + - button "Teklif Mektubu Rakam ve yazı ile bedel Yazdır / PDF" [disabled]: + - generic: Teklif Mektubu + - generic: Rakam ve yazı ile bedel + - text: Yazdır / PDF + - button "Birim Fiyat Teklif Cetveli 1 kalem Yazdır / PDF" [disabled]: + - generic: Birim Fiyat Teklif Cetveli + - generic: 1 kalem + - text: Yazdır / PDF + - button "Evrak Kontrol Listesi 1 belge Yazdır / PDF" [disabled]: + - generic: Evrak Kontrol Listesi + - generic: 1 belge + - text: Yazdır / PDF + - paragraph: Tutar, kaynak, belge ve ürün uygunluğu kontrollerini tamamlayın. + - paragraph: Belgeler taslaktır ve idarenin standart formlarının yerine geçmez. İmzalamadan önce ihale dokümanındaki form metinleriyle karşılaştırın. +- button +- button "Kullanım kılavuzu (? tuşu)": +- region "Notifications (F8)": + - list \ No newline at end of file diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 690998f..37c1806 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -71,9 +71,17 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo - Düzeltme sonrasında tarayıcıda `125,50 × 2 = 251,00 TL` görüldü ve yeniden yüklemede korundu. Bir otomatik test ekran hesabı/PDF HTML tutarlılığı, geçersiz sayı ve aşırı tutarı kapsar. - Kanıtlar: [taslak yenilemesi](evidence/qa-draft-after-reload.txt), [virgüllü fiyat](evidence/qa-turkish-price-fixed.txt), [kontrol yenilemesi](evidence/qa-checks-after-reload.txt). +## Kısmi teklif hazırlığı + +- Kısmi teklif izninin doküman/madde kaydı, resmi kısım eşleştirmesi ve kısım seçimi eklendi. Bilinmeyen kısım otomatik tahmin edilmez; kaynakta kısım varsa kullanıcı üzerine yazamaz. +- Aynı kısma ait tüm satırlar birlikte seçilir. Yalnız seçili satırlar fiyat, ürün uygunluğu ve cetvel çıktısına girer. Kısım değişince kaynak karşılaştırması yeniden gerekir; önceki fiyatlar kendi satırında korunur. +- Seçim, eşleştirmeler ve izin kaynağı hazırlık taslağına kaydedilir. Eski seçili kısım güncel kaynakta yoksa çıktı engellenir. +- İki test seçilmeyen 1.000 TL cihazın 80 TL'lik seçili cetvele karışmamasını, eksik kaynak/eşleştirme ve kaybolan kısım engellerini doğrular. Tarayıcıda tek sentetik satır için boş seçim engeli, kısım eşleştirmesi ve seçili fiyat gösterimi doğrulandı: [kanıt](evidence/qa-lot-selection.txt). +- Aksaray'ın gerçek 34 kısmının resmi dokümanıyla karşılaştırma ve çok kısımlı tarayıcı kabulü henüz yoktur. Götürü bedel tutarı kullanıcı tarafından seçilen kapsam için ayrıca girilir. + ## Doğrulama -`npm test`: 31/31 geçti (son yeniden çalıştırma: 6 Eylül 2026). +`npm test`: 33/33 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 dd0127d..49e62a9 100644 --- a/src/components/BidPackageBuilder.jsx +++ b/src/components/BidPackageBuilder.jsx @@ -1,6 +1,7 @@ import React, { useState, useMemo } from 'react'; import { bidRowTotal, validateBidRows, roundMoney, parseBidPrice } from '../services/bidValidation'; import { sourceRows, bidSourceSnapshot, restoreBidPrices } from '../services/bidSource'; +import { rowLot, selectBidLots } from '../services/bidLots'; import { PreparationChecks } from './PreparationChecks'; import { preparationBlockers, documentKey } from '../services/preparationChecks'; import { useApp } from '../context/AppContext'; @@ -66,8 +67,11 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { 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 sourceSnapshot = bidSourceSnapshot(tender, {...effectiveResults, documentChecklist:documents}, kalemler); + 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 [reviewedSource, setReviewedSource] = useState(storedDraft.reviewedSource || null); const [fiyatlar, setFiyatlar] = useState(() => restoreBidPrices(storedDraft)); const kaynakKontrolGerekli = reviewedSource !== sourceSnapshot; @@ -80,13 +84,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { }; const satirlar = useMemo( - () => kalemler.map(k => ({ ...k, birimFiyat: fiyatlar[k.priceKey] })), - [kalemler, fiyatlar], + () => lotSelection.rows.map(k => ({ ...k, birimFiyat: fiyatlar[k.priceKey] })), + [kalemler, fiyatlar, partialBid], ); 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,priceSchema:1,pricesBySource:fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,analysisSource:{itemsAndSpecs:effectiveResults.itemsAndSpecs || [],documentChecklist:effectiveResults.documentChecklist || [],documentVersion:effectiveResults.documentVersion || null}}}); + 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}}}); if (saved) { setDirty(false); setHata(null); } else setHata('Taslak kaydedilemedi. Tekrar deneyin.'); }; @@ -97,14 +101,14 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { 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 kontrolEksikleri = preparationBlockers(kalemler, documents, checks); - const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0; + const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks); + const hazir = eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0; const formVerisi = () => ({ ...firma, teklifTuru, gecerlilikGun, - kisim, + kisim: partialBid.enabled ? partialBid.selected.join(', ') : kisim, idare: tender?.kurum || '', isinAdi: tender?.baslik || '', ikn: tender?.ikn || '', @@ -238,6 +242,27 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { +
+ + {partialBid.enabled && <> + +

Resmi kısım numaralarını cetvelden doğrulayın. Aynı kısımdaki tüm kalemler birlikte seçilir; seçilmeyenler toplam ve çıktı dışında kalır.

+
{allLots.map(lot=>)}
+
{kalemler.map(row=>)}
+ {lotSelection.errors.map(message=>

{message}

)} + } +
+ {/* Fiyatlandırma */} {teklifTuru === 'goturu' ? ( - ) : kalemler.length > 0 ? ( + ) : lotSelection.rows.length > 0 ? (
Kalem fiyatlandırma

Ondalık fiyatı 125,50 veya 125.50 biçiminde yazın; binlik ayırıcı kullanmayın.

@@ -302,11 +327,11 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
) : (

- Kalem dökümü bulunamadı. Resmi teklif cetvelini İhale Dosyaları bölümünden yükleyin. Kalem eksikliği teklif türünü değiştirmez. + {kalemler.length ? 'Henüz bir kısım seçilmedi. Yukarıdaki resmi kısım eşleştirmesini ve seçimini tamamlayın.' : 'Kalem dökümü bulunamadı. Resmi teklif cetvelini İhale Dosyaları bölümünden yükleyin. Kalem eksikliği teklif türünü değiştirmez.'}

)} - {setManualDocuments(value);setDirty(true);}} checks={checks} setChecks={setChecks}/> {kontrolEksikleri.length > 0 &&
{kontrolEksikleri.length} belge/uygunluk kontrolü tamamlanmalı @@ -342,7 +367,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
{[ { t: 'mektup', ad: 'Teklif Mektubu', ikon: FileSignature, aciklama: 'Rakam ve yazı ile bedel' }, - { t: 'cetvel', ad: 'Birim Fiyat Teklif Cetveli', ikon: Table2, aciklama: `${kalemler.length} kalem` }, + { t: 'cetvel', ad: 'Birim Fiyat Teklif Cetveli', ikon: Table2, aciklama: `${lotSelection.rows.length} kalem` }, { t: 'liste', ad: 'Evrak Kontrol Listesi', ikon: ListChecks, aciklama: `${documents.length} belge` }, ].map((b) => (