feat: export validated bid sheets as real Excel workbooks
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
+996
-4
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user