Files
hikmettanandClaude Fable 5.1 1fce9df06f feat: OKAS kod aramasi, toplu ihale dokumani yukleme, kazanan istihbarati
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>
2026-09-03 20:16:53 +03:00

198 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}