Unify and validate bid totals across screen and exports
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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ı.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getToken } from '../services/auth';
|
|||||||
import { listDocuments, downloadDocument, validateDocumentTextReview } from '../services/account';
|
import { listDocuments, downloadDocument, validateDocumentTextReview } from '../services/account';
|
||||||
import { tenderDateKey } from '../services/tenderCalendar';
|
import { tenderDateKey } from '../services/tenderCalendar';
|
||||||
import React, { useState, useMemo, useEffect, useRef, useId } from 'react';
|
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 { sourceRows, bidSourceSnapshot, restoreBidPrices, bidSourceOptions } from '../services/bidSource';
|
||||||
import { rowLot, selectBidLots } from '../services/bidLots';
|
import { rowLot, selectBidLots } from '../services/bidLots';
|
||||||
import { PreparationChecks } from './PreparationChecks';
|
import { PreparationChecks } from './PreparationChecks';
|
||||||
@@ -117,7 +117,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
[kalemler, fiyatlar, partialBid],
|
[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('');
|
const latestDraft = useRef('');
|
||||||
latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource]);
|
latestDraft.current = JSON.stringify([firma,teklifTuru,gecerlilikGun,kisim,goturuBedel,fiyatlar,sourceSnapshot,reviewedSource,checks,manualDocuments,partialBid,itemSource]);
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
@@ -132,7 +132,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
else setHata('Taslak kaydedilemedi. Tekrar deneyin.');
|
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 eksikler = ALANLAR.filter((a) => a.zorunlu && !String(firma[a.k] || '').trim());
|
||||||
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
|
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)=>
|
...(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.`}] : [])
|
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.'}]) : []),
|
: [{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})),
|
...kontrolEksikleri.map(text=>({target:'checks',text})),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -502,7 +503,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
|
|||||||
<div className="bg-slate-900 text-white rounded-xl p-4 space-y-1">
|
<div className="bg-slate-900 text-white rounded-xl p-4 space-y-1">
|
||||||
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
||||||
<span className="text-xs font-semibold text-slate-300">Teklif toplamı (KDV hariç)</span>
|
<span className="text-xs font-semibold text-slate-300">Teklif toplamı (KDV hariç)</span>
|
||||||
<span className="text-lg font-black text-amber-300">{paraTR(toplam)} TL</span>
|
<span className="text-lg font-black text-amber-300">{toplam === null ? 'Hesaplanamadı — fiyatları kontrol edin' : `${paraTR(toplam)} TL`}</span>
|
||||||
</div>
|
</div>
|
||||||
{toplam > 0 && (
|
{toplam > 0 && (
|
||||||
<p className="text-[11px] text-slate-400 leading-relaxed">Yazı ile: {yaziylaTL(toplam)}</p>
|
<p className="text-[11px] text-slate-400 leading-relaxed">Yazı ile: {yaziylaTL(toplam)}</p>
|
||||||
|
|||||||
@@ -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
|
* İhale dosyası belgeleri — teklif mektubu, birim fiyat teklif cetveli ve
|
||||||
* evrak kontrol listesi.
|
* evrak kontrol listesi.
|
||||||
@@ -112,6 +112,9 @@ function ustBilgi(f) {
|
|||||||
|
|
||||||
/** KİK birim fiyat / götürü bedel teklif mektubu metni. */
|
/** KİK birim fiyat / götürü bedel teklif mektubu metni. */
|
||||||
export function teklifMektubuHtml(f, toplam) {
|
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 goturu = f.teklifTuru === 'goturu';
|
||||||
const tur = goturu ? 'GÖTÜRÜ BEDEL TEKLİF MEKTUBU' : 'BİRİM FİYAT TEKLİF MEKTUBU';
|
const tur = goturu ? 'GÖTÜRÜ BEDEL TEKLİF MEKTUBU' : 'BİRİM FİYAT TEKLİF MEKTUBU';
|
||||||
const bedelCumle = goturu
|
const bedelCumle = goturu
|
||||||
@@ -162,6 +165,9 @@ ihale dokümanındaki standart form metniyle karşılaştırınız.</p>`;
|
|||||||
export function teklifCetveliHtml(f, satirlar, toplam) {
|
export function teklifCetveliHtml(f, satirlar, toplam) {
|
||||||
const errors = validateBidRows(satirlar);
|
const errors = validateBidRows(satirlar);
|
||||||
if (errors.length) throw new Error(errors.join(" "));
|
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 govde = satirlar.map((r, i) => {
|
||||||
const { miktar, birim } = miktarAyristir(r.quantity);
|
const { miktar, birim } = miktarAyristir(r.quantity);
|
||||||
const bf = parseBidPrice(r.birimFiyat);
|
const bf = parseBidPrice(r.birimFiyat);
|
||||||
|
|||||||
@@ -34,3 +34,21 @@ export function validateBidRows(rows) {
|
|||||||
? [`${i+1}. kalemin adı, miktarı, birimi veya fiyatı eksik/geçersiz.`] : [];
|
? [`${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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
export async function createBidWorkbook(form,rows) {
|
||||||
const errors=validateBidRows(rows);
|
const errors=validateBidRows(rows);
|
||||||
if(errors.length) throw new Error(errors.join(' '));
|
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 {default:ExcelJS}=await import('exceljs');
|
||||||
const book=new ExcelJS.Workbook();
|
const book=new ExcelJS.Workbook();
|
||||||
book.creator='İhaleist';
|
book.creator='İhaleist';
|
||||||
@@ -18,7 +20,6 @@ export async function createBidWorkbook(form,rows) {
|
|||||||
const number=7+i;
|
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)}]);
|
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.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.getColumn(3).numFmt='#,##0.########';sheet.getColumn(5).numFmt='#,##0.00######';sheet.getColumn(6).numFmt='#,##0.00';
|
||||||
sheet.views=[{state:'frozen',ySplit:6}];
|
sheet.views=[{state:'frozen',ySplit:6}];
|
||||||
|
|||||||
@@ -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);
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user