/** * Birim fiyat teklif cetveli kalem çıkarımı. * * NEDEN: Firmalar ihalenin sadece başlığına bakarak kendi ürünlerinin içinde * olup olmadığını göremiyor ("148 KALEM KIRTASİYE MALZEME ALIMI"). Kalem * listesi EKAP'ın API'sinde yok — yalnızca ihale dokümanının içinde. Kullanıcı * dokümanı yükleyince buradan ayrıştırılıp İKN'ye bağlanıyor; o ihale artık * herkesin kartında kalemleriyle görünüyor. * * GİRDİ BİÇİMLERİ: * - xlsx/docx → office-text.mjs tabloları " | " ayraçlı verir (asıl hedef) * - pdf → tarayıcıda pdfjs düz metin verir, ayraç yok (sezgisel yol) */ import { normalizeTr } from './siniflandir.mjs'; const MAX_KALEM = 500; const MAX_AD = 300; // Teklif cetvellerinde geçen birimler. "test" tıbbi kitlerde kalem miktarı // olarak kullanılıyor (ör. "25.000 TEST"), kullanıcı özellikle bunu istedi. const BIRIMLER = [ 'adet', 'test', 'kutu', 'paket', 'kalem', 'takım', 'takim', 'çift', 'cift', 'koli', 'flakon', 'tablet', 'ampul', 'şişe', 'sise', 'rulo', 'top', 'poşet', 'poset', 'kg', 'gr', 'gram', 'ton', 'lt', 'litre', 'ml', 'mg', 'cc', 'metre', 'mt', 'm', 'm2', 'm3', 'cm', 'mm', 'saat', 'gün', 'gun', 'ay', 'yıl', 'yil', 'kişi', 'kisi', 'sefer', 'öğün', 'ogun', 'kwh', 'kva', 'litre/gün', ]; const BIRIM_RE = new RegExp(`^(?:${BIRIMLER.map((b) => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`, 'i'); // Kalem OLMAYAN satırlar — toplam/ara toplam/KDV vb. const ATLA_RE = /^(genel\s+)?(ara\s+)?toplam|^kdv|^tutar$|^teklif\s+edilen|^i̇?mza|^kaşe|^sıra\s*no?$|^açıklama$/i; const bosluk = (s) => String(s ?? '').replace(/\s+/g, ' ').trim(); /** "1.500,50" → 1500.5 ; "25000" → 25000 ; okunamazsa null */ export function sayiyaCevir(s) { const t = bosluk(s).replace(/[^\d.,-]/g, ''); if (!t) return null; // Türkçe biçim: nokta binlik, virgül ondalık const norm = t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t.replace(/\.(?=\d{3}\b)/g, ''); const n = Number(norm); return Number.isFinite(n) ? n : null; } const HEADER_AD = /(mal|hizmet|i̇?ş|is)\s*(kalemi|kalem)?|kalem\s*(ad|tanım)|açıklama|aciklama|cinsi|malzeme\s*ad|ürün|urun/i; const HEADER_MIKTAR = /miktar/i; const HEADER_BIRIM = /birim/i; const HEADER_SIRA = /sıra|sira|s\.?\s*no|^no$/i; /** Bir " | " satırının başlık satırı olup olmadığına ve sütun eşlemesine karar verir. */ function basligiCoz(hucreler) { const idx = { sira: -1, ad: -1, miktar: -1, birim: -1 }; hucreler.forEach((h, i) => { const t = bosluk(h); if (!t) return; // "Teklif edilen birim fiyat" bir birim sütunu DEĞİL — önce eleriz. const birimFiyat = /birim\s*fiyat/i.test(t); if (idx.miktar < 0 && HEADER_MIKTAR.test(t) && !birimFiyat) idx.miktar = i; else if (idx.birim < 0 && HEADER_BIRIM.test(t) && !birimFiyat) idx.birim = i; else if (idx.ad < 0 && HEADER_AD.test(t) && !birimFiyat) idx.ad = i; else if (idx.sira < 0 && HEADER_SIRA.test(t)) idx.sira = i; }); // Ad sütunu ve (miktar ya da birim) yoksa bu bir kalem tablosu başlığı değil. if (idx.ad < 0) return null; if (idx.miktar < 0 && idx.birim < 0) return null; return idx; } function hucreleriAyir(satir) { return satir.split('|').map((h) => bosluk(h)); } function kalemGecerli(ad) { const t = bosluk(ad); if (t.length < 3 || t.length > MAX_AD) return false; if (ATLA_RE.test(t)) return false; if (/^[\d.,\s%-]+$/.test(t)) return false; // sadece sayı return true; } /** " | " ayraçlı tablolardan kalem çıkarır (xlsx/docx yolu). */ function tablodanCikar(satirlar) { const kalemler = []; let idx = null; let basliktanBeri = 0; for (const ham of satirlar) { const satir = ham.trim(); if (!satir) { if (idx) basliktanBeri++; continue; } if (satir.includes('|')) { const h = hucreleriAyir(satir); const yeniBaslik = h.length >= 2 ? basligiCoz(h) : null; if (yeniBaslik) { idx = yeniBaslik; basliktanBeri = 0; continue; } if (idx) { const ad = h[idx.ad] ?? ''; if (!kalemGecerli(ad)) { basliktanBeri++; continue; } const miktarHam = idx.miktar >= 0 ? h[idx.miktar] : ''; const birimHam = idx.birim >= 0 ? h[idx.birim] : ''; // Miktar ve birim tek hücrede olabilir: "25000 TEST". sayiyaCevir // harfleri attığı için önce hücrenin salt sayı olup olmadığına bakılır; // yoksa sayı+birim olarak ayrıştırılır. let birim = bosluk(birimHam); let miktar; if (/^[\d.,\s]+$/.test(bosluk(miktarHam))) { miktar = sayiyaCevir(miktarHam); } else { const m = /([\d][\d.,]*)\s*([\p{L}/²³]+)?/u.exec(miktarHam || ''); miktar = m ? sayiyaCevir(m[1]) : null; if (!birim && m?.[2]) birim = m[2]; } kalemler.push({ sira: idx.sira >= 0 ? sayiyaCevir(h[idx.sira]) : null, ad: bosluk(ad), miktar, birim: birim || null, }); basliktanBeri = 0; if (kalemler.length >= MAX_KALEM) break; } } else if (idx) { // Tablo dışı düz metin: birkaç satır sonra tablo bitmiş sayılır. if (++basliktanBeri > 3) idx = null; } } return kalemler; } // "12 ELDİVEN LATEKS PUDRASIZ M 50.000 ADET" const SATIR_RE = new RegExp( `^(\\d{1,4})[.)]?\\s+(.{3,${MAX_AD}}?)\\s+([\\d][\\d.,]*)\\s*(${BIRIMLER.join('|')})\\b`, 'i', ); /** Ayraçsız düz metinden (pdf) sezgisel kalem çıkarımı. */ function duzMetindenCikar(satirlar) { const kalemler = []; for (const ham of satirlar) { const satir = bosluk(ham); if (!satir || satir.includes('|')) continue; const m = SATIR_RE.exec(satir); if (!m) continue; if (!kalemGecerli(m[2])) continue; kalemler.push({ sira: sayiyaCevir(m[1]), ad: bosluk(m[2]), miktar: sayiyaCevir(m[3]), birim: m[4] }); if (kalemler.length >= MAX_KALEM) break; } return kalemler; } function tekilles(kalemler) { const gorulen = new Set(); const out = []; for (const k of kalemler) { const anahtar = normalizeTr(k.ad) + '|' + (k.miktar ?? '') + '|' + normalizeTr(k.birim || ''); if (gorulen.has(anahtar)) continue; gorulen.add(anahtar); out.push(k); } return out; } /** * Doküman metninden teklif cetveli kalemlerini çıkarır. * Önce tablo yolu denenir; hiç kalem bulunamazsa düz metin sezgisine düşer. */ export function kalemleriCikar(metin) { const satirlar = String(metin || '').split('\n'); let kalemler = tekilles(tablodanCikar(satirlar)); let yontem = 'tablo'; if (kalemler.length === 0) { kalemler = tekilles(duzMetindenCikar(satirlar)); yontem = 'duz-metin'; } return { kalemler: kalemler.slice(0, MAX_KALEM), yontem: kalemler.length ? yontem : 'yok' }; } /** * Doküman metninden İKN çıkarır — toplu yüklemede dosya hangi ihaleye ait, * kullanıcıya sormadan bulmak için. İhale dokümanları neredeyse her zaman * "İhale Kayıt Numarası (İKN): 2026/1166496" ya da "İhale kayıt numarası : * 2023/1105557" biçiminde başlar. */ export function iknCikar(metin) { const duz = normalizeTr(String(metin || '').slice(0, 20000)); const kaliplar = [ /ihale\s+kayit\s+numarasi\s*(?:\(\s*ikn\s*\))?\s*:?\s*(\d{4}[/-]\d{3,8})/, /\bikn\s*[:=]?\s*(\d{4}[/-]\d{3,8})/, ]; for (const re of kaliplar) { const m = re.exec(duz); if (m) return m[1].replace('-', '/'); } return null; }