feat: export validated bid sheets as real Excel workbooks

This commit is contained in:
2026-09-06 08:56:12 +03:00
parent 2dfd32aa9c
commit ca92bebb45
7 changed files with 1083 additions and 8 deletions
+10 -1
View File
@@ -89,7 +89,7 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo
## Doğrulama
`npm test`: 35/35 geçti (son yeniden çalıştırma: 6 Eylül 2026).
`npm test`: 37/37 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.
@@ -112,3 +112,12 @@ Kod testleri bütün planın tamamlandığını kanıtlamaz. Tarayıcı kanıtı
## Çıktı doğrulaması sırasındaki değişiklikler
Dosya kontrolü sürerken form/kaynak veya oturum değişirse çıktı iptal edilir. Kontrol penceresi tıklama sırasında açılır; doğrulama hatasında kapatılır. Aynı anda ikinci çıktı başlatılmaz. Taslak kaydı sürerken yapılan yeni değişiklikler, önceki kaydın yanıtı geldiğinde kaydedilmiş sayılmaz. Mevcut 35 test ve derleme geçti; bu yarış senaryolarının gecikmeli tarayıcı kabulü ayrıca bekliyor.
## Excel teklif cetveli
Hazırlık formuna gerçek XLSX çıktısı eklendi; aynı seçili satırlar ve fiyat ayrıştırıcısı kullanılır. Miktar/fiyatlar sayı hücresidir, satır tutarları yuvarlanan formüller ve önceden hesaplanmış sonuçlarla yazılır. Türkçe başlıklar, firma/İKN/kısım, tekrarlanan baskı başlıkları, filtre ve sabit başlık satırı bulunur. Götürü bedelde birim fiyat cetveli düğmeleri kapalıdır.
İki XLSX testi dosyayı üretip yeniden okuyarak 100 satır, Türkçe, 125,50 fiyat ve 25.100 toplamını; ayrıca 0,335 hassasiyet ve 1,01 yuvarlamasını doğrular. Kullanıcı metni formüle çevrilmez. PDF birim fiyat gösterimi de sekiz ondalığa kadar hassasiyeti korur. Tarayıcı indirme ve masaüstü Excel baskı görünümü kabulü bekliyor.
ExcelJS ayrı, ihtiyaç anında yüklenen pakettir. Node bağımlılığındaki UUID için uyumlu 11.1.1+ override ve mevcut nanoid yaması uygulandı; npm audit sıfır bulgu döndürdü. Bu sonuç paketlerin önceden derlenmiş tarayıcı içeriğinin bağımsız denetimi değildir. Mevcut büyük paket uyarıları sürer.
+996 -4
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"canvas-confetti": "^1.9.4",
"exceljs": "^4.4.0",
"jspdf": "^4.2.1",
"lucide-react": "^0.469.0",
"pdfjs-dist": "^6.2.108",
@@ -28,5 +29,10 @@
"playwright": "^1.62.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
},
"overrides": {
"exceljs": {
"uuid": "^11.1.1"
}
}
}
+11 -2
View File
@@ -1,3 +1,4 @@
import { createBidWorkbook, downloadBidWorkbook } from '../services/bidWorkbook';
import { getToken } from '../services/auth';
import { listDocuments, downloadDocument } from '../services/account';
import { tenderDateKey } from '../services/tenderCalendar';
@@ -152,6 +153,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault);
if (fileErrors.length) throw new Error(fileErrors.join(' '));
const f = formVerisi();
if (tip === 'excel') {
const buffer = await createBidWorkbook(f,satirlar);
if (!mounted.current || session !== getToken() || submitted !== latestDraft.current)
throw new Error('Form veya oturum değişti; Excel çıktısını yeniden oluşturun.');
downloadBidWorkbook(buffer,tender.ikn);
printWindow.current?.close();
}
if (tip === 'mektup') belgeYazdir('Teklif Mektubu', teklifMektubuHtml(f, toplam), printWindow.current);
if (tip === 'cetvel') belgeYazdir('Birim Fiyat Teklif Cetveli', teklifCetveliHtml(f, satirlar, toplam), printWindow.current);
if (tip === 'liste') belgeYazdir('Evrak Kontrol Listesi', evrakListesiHtml(f, documents), printWindow.current);
@@ -403,12 +411,13 @@ 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: `${lotSelection.rows.length} kalem` },
{ t: 'excel', ad: 'Excel Teklif Cetveli', ikon: Table2, aciklama: `${lotSelection.rows.length} kalem · XLSX` },
{ t: 'liste', ad: 'Evrak Kontrol Listesi', ikon: ListChecks, aciklama: `${documents.length} belge` },
].map((b) => (
<button
key={b.t}
type="button"
disabled={!hazir || printing}
disabled={!hazir || printing || ((b.t === 'excel' || b.t === 'cetvel') && teklifTuru !== 'birim')}
onClick={() => uret(b.t)}
className="text-left bg-white border border-slate-300 hover:border-emerald-500 hover:bg-emerald-50/50 disabled:opacity-40 disabled:hover:border-slate-300 disabled:hover:bg-white rounded-xl p-3 transition-colors"
>
@@ -416,7 +425,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender }) => {
<span className="block text-xs font-bold text-slate-900 leading-snug">{b.ad}</span>
<span className="block text-[10px] text-slate-500 mt-0.5">{b.aciklama}</span>
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 mt-1.5">
<Printer className="w-3 h-3" /> Yazdır / PDF
<Printer className="w-3 h-3" /> {b.t === 'excel' ? 'Excel indir' : 'Yazdır / PDF'}
</span>
</button>
))}
+1 -1
View File
@@ -171,7 +171,7 @@ export function teklifCetveliHtml(f, satirlar, toplam) {
<td>${kacir(r.name)}</td>
<td class="s">${miktar ?? kacir(r.quantity || '—')}</td>
<td>${kacir(birim)}</td>
<td class="s">${bf ? paraTR(bf) : '—'}</td>
<td class="s">${bf ? bf.toLocaleString('tr-TR',{minimumFractionDigits:2,maximumFractionDigits:8}) : '—'}</td>
<td class="s">${tutar ? paraTR(tutar) : '—'}</td>
</tr>`;
}).join('');
+35
View File
@@ -0,0 +1,35 @@
import {validateBidRows,quantityParts,parseBidPrice,bidRowTotal,roundMoney} from './bidValidation.js';
export async function createBidWorkbook(form,rows) {
const errors=validateBidRows(rows);
if(errors.length) throw new Error(errors.join(' '));
const {default:ExcelJS}=await import('exceljs');
const book=new ExcelJS.Workbook();
book.creator='İhaleist';
const sheet=book.addWorksheet('Teklif Cetveli');
sheet.columns=[{width:8},{width:55},{width:16},{width:18},{width:22},{width:22}];
sheet.addRow(['BİRİM FİYAT TEKLİF CETVELİ — TASLAK']);sheet.mergeCells('A1:F1');
sheet.addRow(['İKN',form.ikn || '']);
sheet.addRow(['Firma',form.firmaUnvan || '']);
sheet.addRow(['Kısım',form.kisim || 'Tüm kapsam']);
sheet.addRow(['Resmi standart formun yerine geçmez. Kaynak dokümanla karşılaştırın.']);sheet.mergeCells('A5:F5');
sheet.addRow(['Sıra','Mal / hizmet adı','Miktar','Birim','Birim fiyat (TL)','Tutar (TL)']);
rows.forEach((r,i)=>{
const {miktar,birim}=quantityParts(r.quantity);
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}];
sheet.autoFilter={from:'A6',to:`F${rows.length+6}`};
sheet.pageSetup={paperSize:9,orientation:'landscape',fitToPage:true,fitToWidth:1,fitToHeight:0,printTitlesRow:'1:6'};
sheet.eachRow(row=>{row.alignment={vertical:'top',wrapText:true};});
for(const n of [1,6,sheet.rowCount])sheet.getRow(n).font={bold:true};
return book.xlsx.writeBuffer();
}
export function downloadBidWorkbook(buffer,ikn) {
const url=URL.createObjectURL(new Blob([buffer],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}));
const link=document.createElement('a');link.href=url;link.download=`teklif_${String(ikn || 'taslak').replace(/[^\w-]/g,'_')}.xlsx`;
document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),5000);
}
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import ExcelJS from 'exceljs';
import {createBidWorkbook} from '../src/services/bidWorkbook.js';
import {teklifCetveliHtml} from '../src/services/bidDocs.js';
test('real XLSX roundtrip preserves 100 Turkish rows, numeric decimals and matching total',async()=>{
const rows=Array.from({length:100},(_,i)=>({name:i===0?'=1+2':`Ölçüm kiti ${i}`,quantity:'2 adet',birimFiyat:'125,50'}));
const buffer=await createBidWorkbook({ikn:'2026/QA',firmaUnvan:'İğdır Test',kisim:'1'},rows);
const read=new ExcelJS.Workbook();await read.xlsx.load(buffer);const sheet=read.getWorksheet('Teklif Cetveli');
assert.equal(sheet.rowCount,107);assert.equal(sheet.getCell('B3').value,'İğdır Test');
assert.equal(sheet.getCell('B7').type,ExcelJS.ValueType.String);assert.equal(sheet.getCell('B7').value,'=1+2');
assert.equal(sheet.getCell('E7').value,125.5);assert.equal(sheet.getCell('F7').result,251);
assert.equal(sheet.getCell('F107').result,25100);assert.equal(sheet.getCell('B106').value,'Ölçüm kiti 99');
assert.ok(teklifCetveliHtml({},rows,25100).includes('25.100,00'));
assert.equal(sheet.pageSetup.printTitlesRow,'1:6');
});
test('XLSX refuses empty and invalid prices; fractional unit precision is preserved',async()=>{
await assert.rejects(createBidWorkbook({},[]));
const row={name:'Kit',quantity:'3 adet',birimFiyat:'0,335'};
const book=new ExcelJS.Workbook();await book.xlsx.load(await createBidWorkbook({},[row]));
assert.equal(book.worksheets[0].getCell('E7').value,0.335);
assert.equal(book.worksheets[0].getCell('F7').result,1.01);
assert.ok(teklifCetveliHtml({},[row],1.01).includes('0,335'));
});