diff --git a/server/ekap-v2.mjs b/server/ekap-v2.mjs index af949da..6e31970 100644 --- a/server/ekap-v2.mjs +++ b/server/ekap-v2.mjs @@ -167,6 +167,45 @@ export async function searchTenders(opts = {}) { * ve o ihalelerin adı "VETERİNER İLAÇ ... SARF MALZEME". EKAP dokümanı * kendi tarafında indekslediği için doküman indirmeye gerek yok. */ +/** + * OKAS kataloğunda ad ile arama (TR/EN). Kaynak: EKAP'ın kendi kalem servisi, + * DevExtreme loadOptions gövdesiyle. "ortoped" → 33141700 Ortopedik malzemeler. + */ +export async function okasAra(q, { take = 30 } = {}) { + const terim = String(q || '').trim(); + const filter = terim ? [['kalemAdi', 'contains', terim], 'or', ['kalemAdiEng', 'contains', terim]] : []; + const d = await ekapPost('/b_ihalearama/api/IhtiyacKalemleri/GetAll', { + loadOptions: { + filter: { sort: [], group: [], filter, totalSummary: [], groupSummary: [], select: [], preSelect: [], primaryKey: [] }, + take, + }, + }); + const liste = d?.loadResult?.data || []; + return liste.map((k) => ({ + kod: k.kod, ad: (k.kalemAdi || '').trim(), adEn: (k.kalemAdiEng || '').trim(), + seviye: k.kodLevel, altSayisi: k.childCount ?? 0, + })); +} + +function normalizeKayit(t) { + return { + ikn: t.ikn, baslik: t.ihaleAdi, kurum: t.idareAdi, sehir: t.ihaleIlAdi, + tur: t.ihaleTipAciklama, usul: t.ihaleUsulAciklama, durum: t.ihaleDurumAciklama, + ihaleTarihSaat: t.ihaleTarihSaat, dokumanSayisi: t.dokumanSayisi ?? 0, + }; +} + +/** OKAS kodlarıyla EKAP araması — idarenin kendi sınıflandırmasına göre. */ +export async function searchByOkas(kodlar, { skip = 0, take = 20 } = {}) { + const liste = (Array.isArray(kodlar) ? kodlar : [kodlar]).map(String).filter(Boolean); + if (!liste.length) return { totalCount: 0, list: [] }; + const data = await ekapPost( + '/b_ihalearama/api/Ihale/GetListByParameters', + searchBody({ okasBransKodList: liste, skip, take }), + ); + return { totalCount: data.totalCount ?? 0, list: (data.list || []).map(normalizeKayit) }; +} + export async function searchInDocuments(metin, { teknikSartname = false, skip = 0, take = 20 } = {}) { const kapsam = { ikNdeAra: false, ihaleAdindaAra: false, ihaleIlanindaAra: false, @@ -179,20 +218,7 @@ export async function searchInDocuments(metin, { teknikSartname = false, skip = '/b_ihalearama/api/Ihale/GetListByParameters', searchBody({ searchText: metin, kapsam, skip, take }), ); - return { - totalCount: data.totalCount ?? 0, - list: (data.list || []).map((t) => ({ - ikn: t.ikn, - baslik: t.ihaleAdi, - kurum: t.idareAdi, - sehir: t.ihaleIlAdi, - tur: t.ihaleTipAciklama, - usul: t.ihaleUsulAciklama, - durum: t.ihaleDurumAciklama, - ihaleTarihSaat: t.ihaleTarihSaat, - dokumanSayisi: t.dokumanSayisi ?? 0, - })), - }; + return { totalCount: data.totalCount ?? 0, list: (data.list || []).map(normalizeKayit) }; } // NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan. diff --git a/server/index.js b/server/index.js index 2559a94..08c7984 100644 --- a/server/index.js +++ b/server/index.js @@ -23,7 +23,8 @@ import { emailConfigured, sendEmail, tenderDigestHtml, emailFrom } from './email import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs'; import { kalemleriCikar } from './kalem-cikar.mjs'; -import { searchInDocuments } from './ekap-v2.mjs'; +import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs'; +import { iknCikar } from './kalem-cikar.mjs'; // Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma. const urunAramaCache = new Map(); @@ -761,18 +762,21 @@ const server = http.createServer(async (req, res) => { // cetvellerinde geçiyor — firmanın kendi ürününü bulma yolu bu. if (url.pathname === '/api/urun-ara') { const q = (url.searchParams.get('q') || '').trim(); - if (q.length < 3) return json(res, 400, { success: false, error: 'en az 3 karakter' }); + const okas = (url.searchParams.get('okas') || '').split(',').map((k) => k.trim()).filter(Boolean); + if (!okas.length && q.length < 3) return json(res, 400, { success: false, error: 'en az 3 karakter' }); const skip = Math.max(0, Number(url.searchParams.get('skip')) || 0); const take = Math.min(50, Math.max(1, Number(url.searchParams.get('take')) || 20)); const teknik = url.searchParams.get('teknik') === '1'; - const anahtar = `${q}|${skip}|${take}|${teknik}`; + const anahtar = `${okas.length ? 'okas:' + okas.join(',') : q}|${skip}|${take}|${teknik}`; const simdi = Date.now(); const onbellek = urunAramaCache.get(anahtar); if (onbellek && simdi - onbellek.zaman < 5 * 60 * 1000) { return json(res, 200, { success: true, onbellekten: true, ...onbellek.veri }); } try { - const out = await searchInDocuments(q, { teknikSartname: teknik, skip, take }); + const out = okas.length + ? await searchByOkas(okas, { skip, take }) + : await searchInDocuments(q, { teknikSartname: teknik, skip, take }); urunAramaCache.set(anahtar, { zaman: simdi, veri: out }); if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value); return json(res, 200, { success: true, ...out }); @@ -780,6 +784,21 @@ const server = http.createServer(async (req, res) => { return json(res, 502, { success: false, error: e.message }); } } + // OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için + if (url.pathname === '/api/okas/ara') { + const q = (url.searchParams.get('q') || '').trim(); + if (q.length < 2) return json(res, 400, { success: false, error: 'en az 2 karakter' }); + const anahtar = `okas|${q}`; + const onb = urunAramaCache.get(anahtar); + if (onb && Date.now() - onb.zaman < 60 * 60 * 1000) return json(res, 200, { success: true, kalemler: onb.veri }); + try { + const kalemler = await okasAra(q, { take: 30 }); + urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler }); + return json(res, 200, { success: true, kalemler }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } if (url.pathname === '/api/health/kalem') { return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() }); } @@ -861,6 +880,15 @@ const server = http.createServer(async (req, res) => { kotaKontrol(user.email, 'belgeAdet', kullanimDurumu.adet, 1, opts); kotaKontrol(user.email, 'belgeBayt', kullanimDurumu.bayt, buf.length, opts); + // Toplu yüklemede dosyanın hangi ihaleye ait olduğu metninden bulunur + // ("İhale Kayıt Numarası (İKN): 2026/1166496"); kullanıcıya sorulmaz. + // PDF tarayıcıda okunduğu için istemci ikn'yi kendisi gönderir. + let ikn = body.ikn ? String(body.ikn).slice(0, 40) : null; + let ofisMetni = null; + if (sunucuOkuyabilir(body.ad)) { + try { ofisMetni = arsivMetni(String(body.ad), buf); } catch { /* metin çıkmazsa devam */ } + if (!ikn && body.otoIkn && ofisMetni) ikn = iknCikar(ofisMetni); + } const dosyaAdi = `${Date.now()}-${crypto.randomUUID()}`; fs.writeFileSync(path.join(store.BELGE_DIR, dosyaAdi), buf); const id = store.addDocument(user.email, { @@ -870,26 +898,25 @@ const server = http.createServer(async (req, res) => { mime: body.mime || null, dosya: dosyaAdi, sonKullanma: body.sonKullanma || null, - ikn: body.ikn ? String(body.ikn).slice(0, 40) : null, // ihaleye bağlı belge + ikn, // ihaleye bağlı belge }); // Yükleme anında otomatik kalem çıkarımı — AI kullanmaz, kullanıcıdan // tıklama beklemez. PDF'ler tarayıcıda çözüldüğü için istemci // metni /api/kalem/cikar'a gönderir. let kalemBilgi = null; - if (body.ikn && sunucuOkuyabilir(body.ad)) { + if (ikn && ofisMetni) { try { - const metin = arsivMetni(String(body.ad), buf); - const { kalemler, yontem } = kalemleriCikar(metin); + const { kalemler, yontem } = kalemleriCikar(ofisMetni); if (kalemler.length) { - kalemStore.kalemYaz(String(body.ikn), kalemler, { dosya: String(body.ad), yontem, ekleyen: user.email }); + kalemStore.kalemYaz(ikn, kalemler, { dosya: String(body.ad), yontem, ekleyen: user.email }); kalemBilgi = { sayi: kalemler.length, yontem }; - console.log(`[kalem] ${body.ikn} → ${kalemler.length} kalem (${yontem}, ${body.ad})`); + console.log(`[kalem] ${ikn} → ${kalemler.length} kalem (${yontem}, ${body.ad})`); } } catch (e) { console.warn('[kalem] çıkarılamadı:', e.message); } } - return json(res, 200, { success: true, id, kalem: kalemBilgi, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) }); + return json(res, 200, { success: true, id, ikn, kalem: kalemBilgi, belgeler: store.listDocuments(user.email), kullanim: store.documentUsage(user.email) }); } catch (e) { return json(res, e.kod === 'KOTA_ASILDI' ? 402 : 400, { success: false, error: e.message, kod: e.kod || null }); } diff --git a/server/kalem-cikar.mjs b/server/kalem-cikar.mjs index e7fa045..8089001 100644 --- a/server/kalem-cikar.mjs +++ b/server/kalem-cikar.mjs @@ -176,3 +176,22 @@ export function kalemleriCikar(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; +} diff --git a/src/components/LeftSidebar.jsx b/src/components/LeftSidebar.jsx index cf758e0..c8d20c2 100644 --- a/src/components/LeftSidebar.jsx +++ b/src/components/LeftSidebar.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, {useState, useEffect } from 'react'; import { useApp } from '../context/AppContext'; import { INSTITUTION_TYPES_LIST, CITIES_LIST } from '../data/constants'; import { @@ -24,6 +24,8 @@ export const LeftSidebar = () => { setSelectedCpvler, toggleCpv, cpvBolumListesi, + setIsUrunAramaOpen, + setUrunAramaBaslangic, selectedKurumTipi, setSelectedKurumTipi, selectedCity, @@ -33,6 +35,23 @@ export const LeftSidebar = () => { const [sektorAra, setSektorAra] = useState(''); const [cpvAra, setCpvAra] = useState(''); + // OKAS kataloğu: idarenin kendi kodu. "ortopedi" başlıkta geçmese de kod yakalar. + const [okasQ, setOkasQ] = useState(''); + const [okasSonuc, setOkasSonuc] = useState([]); + useEffect(() => { + const t = okasQ.trim(); + if (t.length < 2) { setOkasSonuc([]); return; } + let iptal = false; + const z = setTimeout(() => import('../services/kalem') + .then(({ okasAra }) => okasAra(t)) + .then((k) => { if (!iptal) setOkasSonuc(k.slice(0, 12)); }) + .catch(() => {}), 350); + return () => { iptal = true; clearTimeout(z); }; + }, [okasQ]); + const okasIleAra = (k) => { + setUrunAramaBaslangic({ okasKod: k.kod, okasAd: k.ad }); + setIsUrunAramaOpen(true); + }; const [openSections, setOpenSections] = useState({ alimTuru: true, sektorler: true, @@ -264,6 +283,27 @@ export const LeftSidebar = () => {
CPV, AB'nin ihale konusunu sınıflandırdığı standart koddur. Türkiye'deki NACE/faaliyet kodunun yerine geçmez — NACE firmanızı, CPV ihalenin konusunu sınıflandırır.
+ {/* OKAS: EKAP'ta idarenin seçtiği kod. Bulunan kodla EKAP'ta arama açılır. */} ++ EKAP'tan indirdiğiniz şartname/doküman dosyalarını (PDF, DOCX, XLSX, ZIP) hepsini birden seçin. + Her dosyanın İKN'si metninden bulunur, ihaleye bağlanır ve + teklif cetveli kalemleri otomatik çıkarılır — o ihale herkesin kartında kalemleriyle görünür. +
+