From 3096ca45cd47832f5cf98fe16e9e428e13aa83af Mon Sep 17 00:00:00 2001 From: ht Date: Sun, 6 Sep 2026 13:16:49 +0300 Subject: [PATCH] Unify and validate bid totals across screen and exports --- docs/quality/uygulama-durumu.md | 7 +++++++ src/components/BidPackageBuilder.jsx | 11 +++++----- src/services/bidDocs.js | 8 ++++++- src/services/bidValidation.js | 18 ++++++++++++++++ src/services/bidWorkbook.js | 5 +++-- tests/bid-validation.test.mjs | 31 ++++++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 8 deletions(-) diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 4326ea5..627bcdf 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -846,3 +846,10 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Kaşe üretmeyen dosya hazırlama panelinin eski PDF & Kaşe adı Teklif Dosyası Hazırla olarak düzeltildi; dosya bağlantısı PDF / Excel çıktılarını belirtir. - Yerel 5182 sentetik Giresun fixture tarayıcısında 7 kontrol geçti: firma odağı, tamamlanan alanın listeden kalkması, fiyat odağı/erişilebilir hata bağlantısı, fiyat düzeltmesinin hatayı kaldırması, 125,50 × 2 = 251,00 TL, belge bölümüne odak ve eksik dosyada çıktı engeli. Kanıt: qa-preparation-navigation.json. - Derleme geçti. Hesaplama veya sunucu kodu değişmediğinden 191 testlik önceki otomatik paket tekrar çalıştırılmadı. Gerçek çok sayfalı PDF, resmi beş dosya ve planın diğer açık kabulleri tamamlanmış sayılmaz. + +### D03 — Ortak teklif toplamı ve tutar sınırı — 6 Eylül 2026 +- İncelemede HTML cetvelinin kendisine verilen toplamı satırlarla karşılaştırmadan bastığı, Excel'in ayrı toplam aldığı ve satırlar tek tek geçerliyken toplamın güvenli sayı sınırını aşabildiği görüldü. +- Ekran, HTML cetveli ve Excel ortak bidTotal hesabını kullanır: yuvarlanmış satır tutarları kuruş olarak toplanır; güvenli tam sayı sınırı denetlenir. HTML cetveli dışarıdan verilen toplam ortak hesapla eşleşmiyorsa üretimi reddeder. +- Götürü bedel ve teklif mektubunda ortak bidAmount geçersiz, sıfıra yuvarlanan ve hesaplama sınırını aşan tutarı reddeder. Eksik fiyat artık genel toplamda sıfır gibi gösterilmez; Hesaplanamadı açıklaması görünür. +- 193 otomatik test ve derleme geçti. Son test genişletmesinden sonra ilgili 10 test yeniden geçti. Yüz adet 3 × 0,335 satırının her biri 1,01; ortak toplam, HTML ve gerçek XLSX dosyasından geri okunan toplam 101,00 olarak doğrulandı. Toplam taşması ve tutarsız toplam üretimi reddedildi. +- Bu kanıt HTML/XLSX hesaplamasını kapsar; tarayıcı PDF indirme, çok sayfalı görsel yerleşim, resmi ihale belgelerinin doğruluğu veya firma bilgilerinin gerçekliğini kanıtlamaz. Firma alanlarında bu tur yeni doğrulama uygulanmadı. diff --git a/src/components/BidPackageBuilder.jsx b/src/components/BidPackageBuilder.jsx index 5d8c383..9e72e0a 100644 --- a/src/components/BidPackageBuilder.jsx +++ b/src/components/BidPackageBuilder.jsx @@ -6,7 +6,7 @@ import { getToken } from '../services/auth'; import { listDocuments, downloadDocument, validateDocumentTextReview } from '../services/account'; import { tenderDateKey } from '../services/tenderCalendar'; import React, { useState, useMemo, useEffect, useRef, useId } from 'react'; -import { bidRowTotal, validateBidRows, roundMoney, parseBidPrice } from '../services/bidValidation'; +import { bidRowTotal, validateBidRows, bidTotal, bidAmount } from '../services/bidValidation'; import { sourceRows, bidSourceSnapshot, restoreBidPrices, bidSourceOptions } from '../services/bidSource'; import { rowLot, selectBidLots } from '../services/bidLots'; import { PreparationChecks } from './PreparationChecks'; @@ -117,7 +117,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { [kalemler, fiyatlar, partialBid], ); - const kalemToplami = useMemo(() => roundMoney(satirlar.reduce((t,r) => t + (bidRowTotal(r) ?? 0),0)), [satirlar]); + 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]); const mounted = useRef(true); @@ -132,7 +132,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { else setHata('Taslak kaydedilemedi. Tekrar deneyin.'); }; - const toplam = teklifTuru === 'goturu' ? (parseBidPrice(goturuBedel) || 0) : kalemToplami; + const toplam = teklifTuru === 'goturu' ? bidAmount(goturuBedel) : kalemToplami; const eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim()); const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0; @@ -157,7 +157,8 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => { ...(teklifTuru === 'birim' ? (satirlar.length ? satirlar.flatMap((row,i)=> validateBidRows([row]).length ? [{target:`price:${row.priceKey}`,text:`${i+1}. kalem (${row.name || 'Adı eksik'}): ad, miktar, birim ve fiyatı kontrol edin.`}] : []) : [{target:'pricing',text:'Resmi teklif cetvelindeki kalemleri yükleyin veya kısım seçimini tamamlayın.'}]) : []), - ...(teklifTuru === 'goturu' && !(toplam > 0) ? [{target:'lumpSum',text:'Geçerli, sıfırdan büyük toplam teklif bedeli girin.'}] : []), + ...(teklifTuru === 'goturu' && !(toplam > 0) ? [{target:'lumpSum',text:'Toplam teklif bedeli geçersiz veya hesaplama sınırını aşıyor. Pozitif ve kuruşa yuvarlanabilir bir tutar girin.'}] : []), + ...(teklifTuru === 'birim' && !kalemEksik && toplam === null ? [{target:'pricing',text:'Genel toplam geçersiz veya güvenli hesaplama sınırını aşıyor. Miktarları ve fiyatları kontrol edin.'}] : []), ...kontrolEksikleri.map(text=>({target:'checks',text})), ]; @@ -502,7 +503,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
Teklif toplamı (KDV hariç) - {paraTR(toplam)} TL + {toplam === null ? 'Hesaplanamadı — fiyatları kontrol edin' : `${paraTR(toplam)} TL`}
{toplam > 0 && (

Yazı ile: {yaziylaTL(toplam)}

diff --git a/src/services/bidDocs.js b/src/services/bidDocs.js index 01aab38..ad01456 100644 --- a/src/services/bidDocs.js +++ b/src/services/bidDocs.js @@ -1,4 +1,4 @@ -import { quantityParts, parseBidPrice, bidRowTotal, validateBidRows } from './bidValidation.js'; +import { quantityParts, parseBidPrice, bidRowTotal, validateBidRows, bidTotal, bidAmount } from './bidValidation.js'; /** * İhale dosyası belgeleri — teklif mektubu, birim fiyat teklif cetveli ve * evrak kontrol listesi. @@ -112,6 +112,9 @@ function ustBilgi(f) { /** KİK birim fiyat / götürü bedel teklif mektubu metni. */ export function teklifMektubuHtml(f, toplam) { + const amount = bidAmount(toplam); + if (amount === null) throw new Error('Teklif toplamı geçersiz veya güvenli hesaplama sınırını aşıyor.'); + toplam = amount; const goturu = f.teklifTuru === 'goturu'; const tur = goturu ? 'GÖTÜRÜ BEDEL TEKLİF MEKTUBU' : 'BİRİM FİYAT TEKLİF MEKTUBU'; const bedelCumle = goturu @@ -162,6 +165,9 @@ ihale dokümanındaki standart form metniyle karşılaştırınız.

`; export function teklifCetveliHtml(f, satirlar, toplam) { const errors = validateBidRows(satirlar); if (errors.length) throw new Error(errors.join(" ")); + const calculated = bidTotal(satirlar); + if (calculated === null) throw new Error('Teklif toplamı geçersiz veya güvenli hesaplama sınırını aşıyor.'); + if (toplam !== calculated) throw new Error('Teklif toplamı satır tutarlarıyla uyuşmuyor. Cetveli yeniden hesaplayın.'); const govde = satirlar.map((r, i) => { const { miktar, birim } = miktarAyristir(r.quantity); const bf = parseBidPrice(r.birimFiyat); diff --git a/src/services/bidValidation.js b/src/services/bidValidation.js index d71e205..9fe5eef 100644 --- a/src/services/bidValidation.js +++ b/src/services/bidValidation.js @@ -34,3 +34,21 @@ export function validateBidRows(rows) { ? [`${i+1}. kalemin adı, miktarı, birimi veya fiyatı eksik/geçersiz.`] : []; }); } + +// Sum rounded row amounts in integer kuruş so every output uses the same total. +export function bidTotal(rows) { + if (validateBidRows(rows).length) return null; + let cents = 0; + for (const row of rows) { + const rowCents = Math.round(bidRowTotal(row) * 100); + if (!Number.isSafeInteger(rowCents) || !Number.isSafeInteger(cents + rowCents)) return null; + cents += rowCents; + } + return cents > 0 ? cents / 100 : null; +} +export function bidAmount(value) { + const parsed = parseBidPrice(value); + if (parsed === null) return null; + const cents = Math.round((parsed + Number.EPSILON) * 100); + return Number.isSafeInteger(cents) && cents > 0 ? cents / 100 : null; +} diff --git a/src/services/bidWorkbook.js b/src/services/bidWorkbook.js index 8a094aa..c90323d 100644 --- a/src/services/bidWorkbook.js +++ b/src/services/bidWorkbook.js @@ -1,7 +1,9 @@ -import {validateBidRows,quantityParts,parseBidPrice,bidRowTotal,roundMoney} from './bidValidation.js'; +import {validateBidRows,quantityParts,parseBidPrice,bidRowTotal,bidTotal} from './bidValidation.js'; export async function createBidWorkbook(form,rows) { const errors=validateBidRows(rows); if(errors.length) throw new Error(errors.join(' ')); + const total=bidTotal(rows); + if(total === null) throw new Error('Teklif toplamı geçersiz veya güvenli hesaplama sınırını aşıyor.'); const {default:ExcelJS}=await import('exceljs'); const book=new ExcelJS.Workbook(); book.creator='İhaleist'; @@ -18,7 +20,6 @@ export async function createBidWorkbook(form,rows) { const number=7+i; sheet.addRow([i+1,r.name,miktar,birim,parseBidPrice(r.birimFiyat),{formula:`ROUND(C${number}*E${number},2)`,result:bidRowTotal(r)}]); }); - const total=roundMoney(rows.reduce((sum,row)=>sum+bidRowTotal(row),0)); sheet.addRow(['','GENEL TOPLAM (KDV hariç)','','','',{formula:`SUM(F7:F${rows.length+6})`,result:total}]); sheet.getColumn(3).numFmt='#,##0.########';sheet.getColumn(5).numFmt='#,##0.00######';sheet.getColumn(6).numFmt='#,##0.00'; sheet.views=[{state:'frozen',ySplit:6}]; diff --git a/tests/bid-validation.test.mjs b/tests/bid-validation.test.mjs index f3e25e2..feb898e 100644 --- a/tests/bid-validation.test.mjs +++ b/tests/bid-validation.test.mjs @@ -34,3 +34,34 @@ test('Turkish decimal price is identical in row total and printable sheet',()=>{ for(const value of ['', '125,,50','1e3','125 TL','1.2.3','-20','125,']) assert.equal(parseBidPrice(value),null); assert.equal(bidRowTotal({quantity:'1000000000 adet',birimFiyat:'999999999999999'}),null); }); + +test('aggregate overflow is rejected across printable and workbook output',async()=>{ + const {bidTotal,bidAmount}=await import('../src/services/bidValidation.js'); + const {createBidWorkbook}=await import('../src/services/bidWorkbook.js'); + const {teklifMektubuHtml}=await import('../src/services/bidDocs.js'); + const rows=Array.from({length:3},()=>({name:'Cihaz',quantity:'1 adet',birimFiyat:40000000000000})); + assert.deepEqual(validateBidRows(rows),[]); + assert.equal(bidTotal(rows),null); + assert.throws(()=>teklifCetveliHtml({},rows,120000000000000),/sınır/); + await assert.rejects(createBidWorkbook({},rows),/sınır/); + for(const amount of ['999999999999999', '0.001', '', '-1', Infinity]) { + assert.equal(bidAmount(amount),null); + assert.throws(()=>teklifMektubuHtml({},amount),/geçersiz/); + } + assert.equal(bidAmount('125,505'),125.51); +}); + +test('print total must equal the common rounded row sum',async()=>{ + const {bidTotal}=await import('../src/services/bidValidation.js'); + const rows=Array.from({length:100},()=>({name:'Kit',quantity:'3 adet',birimFiyat:'0.335'})); + assert.equal(bidTotal(rows),101); + const {createBidWorkbook}=await import('../src/services/bidWorkbook.js'); + const {default:ExcelJS}=await import('exceljs'); + const workbook=new ExcelJS.Workbook(); + await workbook.xlsx.load(await createBidWorkbook({},rows)); + assert.equal(workbook.worksheets[0].getCell('F107').result,101); + for(let i=7;i<=106;i++) assert.equal(workbook.worksheets[0].getCell(`F${i}`).result,1.01); + assert.throws(()=>teklifCetveliHtml({},rows,100.5),/uyuşmuyor/); + assert.ok(teklifCetveliHtml({},rows,101).includes('101,00')); + assert.equal(bidTotal([{name:'Kit',quantity:'2 adet',birimFiyat:''}]),null); +});