Uc ozellik, 27+1 depo taramasinda gorulen fikirlerden; kod kopyalanmadi
(ilgili depolar lisanssiz), uygulamalar sifirdan yazildi.
1) OKAS koduyla arama (fikir: firatbadur/ihaletakip-api'nin kullandigi
IhtiyacKalemleri/GetAll ucu). EKAP'in kendi OKAS katalogunda ad ile
(TR/EN) arama: "ortoped" → 33141700 Ortopedik malzemeler, 33183000
Ortopedik destek cihazlari. Secilen kodla EKAP'ta ihale aramasi
(okasBransKodList). Olculen: 33141700 altinda 335 ihale, basliklari
"VETERINER ILAC VE SARF MALZEME" — basliktan gorunmeyen turden.
- ekap-v2.mjs: okasAra(), searchByOkas(); sonuc normalize ortak
- GET /api/okas/ara?q= (1 sa onbellek); /api/urun-ara?okas=kod,kod
- Urun Arama modalinda "OKAS koduyla" modu (katalog onerisi + cok kod)
- Sol menude CPV bolumunun ustunde OKAS kutusu: kod secilince modal
o kodla acilip hemen arar
2) Toplu ihale dokumani yukleme (fikir: barkem-tayyar/ihale'nin izlenen
klasor + extractIkn deseni). Belge Kasasi'na coklu dosya secimi: her
dosyanin IKN'si METNINDEN bulunur ("Ihale Kayit Numarasi (IKN):
2026/1166496"), kullaniciya sorulmaz; ihaleye baglanir ve teklif
cetveli kalemleri otomatik havuza yazilir. Onlarca sartnameyi tek
seferde atarak kalem havuzunu hizla doldurmak icin.
- kalem-cikar.mjs#iknCikar + istemci esi (PDF tarayicida okunuyor)
- Yukleme ucu: otoIkn → ofis metninden IKN; donuste ikn alani
- Dosya basina sonuc satiri: IKN / kalem sayisi / "IKN bulunamadi"
Dogrulandi: ikn parametresiz docx → 2026/1588948 bulundu, 2 kalem.
3) Kazanan istihbarati (fikir: gulmezeren2-byte/ihalent, MIT). Rakip
Radari'nda firma basina kirim orani (1 − sozlesme/yaklasik maliyet,
ortalama), ortalama gecerli teklif sayisi (rekabet derinligi) ve tek
teklifle kazanma orani. Veri yoksa null — 0 ile karismasin; bedel >
maliyet×1,5 olan bozuk satirlar orana katilmiyor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
198 lines
7.3 KiB
JavaScript
198 lines
7.3 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|