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. */} +
+ setOkasQ(e.target.value)} + placeholder="OKAS kodu bul: ortopedik, röntgen…" + className="w-full border border-indigo-200 bg-indigo-50/40 rounded-lg px-2 py-1.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-indigo-500/30" + /> + {okasSonuc.length > 0 && ( +
+ {okasSonuc.map((k) => ( + + ))} +
+ )} +
setCpvAra(e.target.value)} diff --git a/src/components/UrunAramaModal.jsx b/src/components/UrunAramaModal.jsx index c2994d7..73bab3e 100644 --- a/src/components/UrunAramaModal.jsx +++ b/src/components/UrunAramaModal.jsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useApp } from '../context/AppContext'; import { X, Search, Loader2, Package, AlertTriangle, Building2, MapPin, CalendarDays, Info, } from 'lucide-react'; import { urunAra } from '../services/urunArama'; +import { okasAra } from '../services/kalem'; /** * Ürün Arama — ürün adını ihale dokümanlarının içinde aratır. @@ -23,15 +24,55 @@ const gecmisMi = (tarihSaat) => { }; export const UrunAramaModal = () => { - const { isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders } = useApp(); + const { + isUrunAramaOpen, setIsUrunAramaOpen, setActiveTenderDetail, tenders, + urunAramaBaslangic, setUrunAramaBaslangic, + } = useApp(); const [q, setQ] = useState(''); const [teknik, setTeknik] = useState(false); const [sadeceAcik, setSadeceAcik] = useState(true); const [durum, setDurum] = useState({ yukleniyor: false, sonuc: null, hata: null, sayfa: 0 }); + // OKAS modu: idarenin kendi sınıflandırma koduyla arama. Serbest metinle + // bulunamayan ("ortopedi" başlıkta hiç geçmiyor) ihaleleri kod yakalar. + const [mod, setMod] = useState('metin'); // 'metin' | 'okas' + const [okasQ, setOkasQ] = useState(''); + const [okasAdaylar, setOkasAdaylar] = useState([]); + const [okasSecili, setOkasSecili] = useState([]); // [{kod, ad}] + + // Sol menüden bir OKAS koduyla açıldıysa o kodla başla ve hemen ara + useEffect(() => { + if (!isUrunAramaOpen || !urunAramaBaslangic?.okasKod) return; + setMod('okas'); + setOkasSecili([{ kod: urunAramaBaslangic.okasKod, ad: urunAramaBaslangic.okasAd || '' }]); + setUrunAramaBaslangic(null); + araOkas([urunAramaBaslangic.okasKod], 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isUrunAramaOpen, urunAramaBaslangic]); + + // OKAS adı yazdıkça katalogda ara (350 ms) + useEffect(() => { + const t = okasQ.trim(); + if (mod !== 'okas' || t.length < 2) { setOkasAdaylar([]); return; } + let iptal = false; + const z = setTimeout(() => okasAra(t).then((k) => { if (!iptal) setOkasAdaylar(k); }).catch(() => {}), 350); + return () => { iptal = true; clearTimeout(z); }; + }, [okasQ, mod]); if (!isUrunAramaOpen) return null; + const araOkas = async (kodlar, sayfa = 0) => { + if (!kodlar.length) { setDurum((d) => ({ ...d, hata: 'En az bir OKAS kodu seçin.' })); return; } + setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa }); + try { + const d = await urunAra('', { skip: sayfa * SAYFA, take: SAYFA, okas: kodlar }); + setDurum({ yukleniyor: false, sonuc: d, hata: null, sayfa }); + } catch (e) { + setDurum({ yukleniyor: false, sonuc: null, hata: e.message, sayfa }); + } + }; + const ara = async (sayfa = 0) => { + if (mod === 'okas') return araOkas(okasSecili.map((k) => k.kod), sayfa); const terim = q.trim(); if (terim.length < 3) { setDurum((d) => ({ ...d, hata: 'En az 3 karakter yazın.' })); return; } setDurum({ yukleniyor: true, sonuc: null, hata: null, sayfa }); @@ -43,6 +84,11 @@ export const UrunAramaModal = () => { } }; + const okasEkle = (k) => { + if (!okasSecili.some((x) => x.kod === k.kod)) setOkasSecili((o) => [...o, { kod: k.kod, ad: k.ad }]); + setOkasQ(''); setOkasAdaylar([]); + }; + const sonuc = durum.sonuc; const liste = (sonuc?.list || []).filter((t) => !sadeceAcik || !gecmisMi(t.ihaleTarihSaat)); const gizlenen = (sonuc?.list?.length || 0) - liste.length; @@ -74,7 +120,55 @@ export const UrunAramaModal = () => {
{ e.preventDefault(); ara(0); }} className="space-y-3"> +
+ {[['metin', 'Ürün adıyla'], ['okas', 'OKAS koduyla']].map(([m, ad]) => ( + + ))} +
+ + {mod === 'okas' && ( +
+
+ + setOkasQ(e.target.value)} + placeholder="OKAS adı yazın — örn. ortopedik, röntgen, eldiven…" + className="w-full text-sm pl-9 pr-3 py-2.5 rounded-xl border border-slate-300 focus:ring-2 focus:ring-indigo-500 outline-none" + /> + {okasAdaylar.length > 0 && ( +
+ {okasAdaylar.map((k) => ( + + ))} +
+ )} +
+ {okasSecili.length > 0 && ( +
+ {okasSecili.map((k) => ( + + {k.kod} {k.ad} + + + ))} +
+ )} +
+ )} +
+ {mod === 'metin' && (
{ className="w-full text-sm pl-9 pr-3 py-2.5 rounded-xl border border-slate-300 focus:ring-2 focus:ring-indigo-500 outline-none" />
+ )} + {mod === 'okas' &&
}
+ {mod === 'metin' && ( + )}
+ {/* Toplu ihale dokümanı yükleme */} +
+
+
+ +
+
+

İhale Dokümanlarını Toplu Yükle

+

+ 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. +

+
+
+ + {toplu.length > 0 && ( +
    + {toplu.map((t, i) => ( +
  • + {t.durum === 'tamam' ? + : t.durum === 'hata' ? + : } + {t.ad} + {t.durum === 'tamam' && ( + + {t.ikn + ? {t.ikn} + : İKN bulunamadı — ihaleye bağlanmadı} + {t.kalem ? {t.kalem} kalem : null} + + )} + {t.durum === 'hata' && {t.hata}} +
  • + ))} +
+ )} +
+ {hata && (
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index b4e6c63..e9e0f54 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -236,6 +236,8 @@ export const AppProvider = ({ children }) => { const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false); // Ürün araması: metni ihale dokümanlarının içinde arar (EKAP indeksi) const [isUrunAramaOpen, setIsUrunAramaOpen] = useState(false); + // Sol menüden OKAS koduyla açılınca modal bu kodla başlar + const [urunAramaBaslangic, setUrunAramaBaslangic] = useState(null); const [aiAnalysisResults, setAiAnalysisResults] = useState(null); // Analiz modal dışından (ör. doküman panelindeki derin analiz düğmesi) da @@ -591,6 +593,8 @@ export const AppProvider = ({ children }) => { isAiSpecAnalyzerOpen, isUrunAramaOpen, setIsUrunAramaOpen, + urunAramaBaslangic, + setUrunAramaBaslangic, setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, aiAnalysisResults, diff --git a/src/services/account.js b/src/services/account.js index 8faa0cc..555d737 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -44,7 +44,7 @@ export const revokeApiKey = (id) => iste(`/api/me/apikeys?id=${encodeURIComponen export const listDocuments = () => iste('/api/me/documents'); export const deleteDocument = (id) => iste(`/api/me/documents?id=${encodeURIComponent(id)}`, { method: 'DELETE' }); -export async function uploadDocument(file, kategori, ikn) { +export async function uploadDocument(file, kategori, ikn, ek = {}) { const icerikBase64 = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result).split(',')[1]); @@ -53,7 +53,7 @@ export async function uploadDocument(file, kategori, ikn) { }); return iste('/api/me/documents', { method: 'POST', - body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64 }), + body: JSON.stringify({ ad: file.name, mime: file.type, kategori, ikn, icerikBase64, ...ek }), }); } diff --git a/src/services/analytics.js b/src/services/analytics.js index ff05760..898ddff 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -34,15 +34,38 @@ export function firmStats(tenders) { awardCount++; const name = t.kazananFirma.trim(); const val = parseTL(t.sozlesmeBedeli); - const cur = map.get(name) || { name, wins: 0, total: 0, sectors: new Set(), cities: new Set() }; + const cur = map.get(name) || { + name, wins: 0, total: 0, sectors: new Set(), cities: new Set(), + // Kazanan istihbaratı: kırım oranı (yaklaşık maliyete göre ne kadar + // aşağı verdi) ve rekabet derinliği (kaç istekliye karşı kazandı) + kirimToplam: 0, kirimAdet: 0, teklifToplam: 0, teklifAdet: 0, tekTeklif: 0, + }; cur.wins++; if (val != null) cur.total += val; + const maliyet = parseTL(t.yaklasikMaliyet); + if (val != null && maliyet > 0 && val <= maliyet * 1.5) { + cur.kirimToplam += 1 - val / maliyet; + cur.kirimAdet++; + } + const teklif = Number(t.gecerliTeklifSayisi ?? t.teklifSayisi); + if (Number.isFinite(teklif) && teklif > 0) { + cur.teklifToplam += teklif; + cur.teklifAdet++; + if (teklif === 1) cur.tekTeklif++; + } if (t.sektor) cur.sectors.add(t.sektor); if (t.sehir) cur.cities.add(t.sehir); map.set(name, cur); } const firms = [...map.values()] - .map((f) => ({ ...f, sectors: [...f.sectors], cities: [...f.cities] })) + .map((f) => ({ + ...f, + sectors: [...f.sectors], cities: [...f.cities], + // yüzde; veri yoksa null — 0 ile karıştırılmasın + kirimOrani: f.kirimAdet ? Math.round((f.kirimToplam / f.kirimAdet) * 100) : null, + ortTeklif: f.teklifAdet ? Math.round((f.teklifToplam / f.teklifAdet) * 10) / 10 : null, + tekTeklifOrani: f.teklifAdet ? Math.round((f.tekTeklif / f.teklifAdet) * 100) : null, + })) .sort((a, b) => b.wins - a.wins || b.total - a.total); const totalMarket = firms.reduce((s, f) => s + f.total, 0); return { diff --git a/src/services/kalem.js b/src/services/kalem.js index 35e0082..3c24fd7 100644 --- a/src/services/kalem.js +++ b/src/services/kalem.js @@ -37,3 +37,23 @@ export async function kalemCikar({ ikn, metin, dosya }) { if (!d.success) throw new Error(d.error || 'kalem çıkarılamadı'); return d; } + +/** + * Doküman metninden İKN — toplu yüklemede PDF tarayıcıda okunduğu için + * sunucudaki kalem-cikar.mjs#iknCikar'ın istemci eşi. + */ +export function iknCikar(metin) { + const duz = String(metin || '').slice(0, 20000).toLocaleLowerCase('tr-TR') + .replace(/ı/g, 'i').replace(/ş/g, 's').replace(/ğ/g, 'g').replace(/ç/g, 'c').replace(/ö/g, 'o').replace(/ü/g, 'u'); + const m = /ihale\s+kayit\s+numarasi\s*(?:\(\s*ikn\s*\))?\s*:?\s*(\d{4}[/-]\d{3,8})/.exec(duz) + || /\bikn\s*[:=]?\s*(\d{4}[/-]\d{3,8})/.exec(duz); + return m ? m[1].replace('-', '/') : null; +} + +/** OKAS kataloğunda ad ile arama — "ortoped" → 33141700 Ortopedik malzemeler */ +export async function okasAra(q) { + const r = await fetch(`/api/okas/ara?q=${encodeURIComponent(q)}`); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'OKAS araması başarısız'); + return d.kalemler || []; +} diff --git a/src/services/urunArama.js b/src/services/urunArama.js index d9f5e99..b6ad01f 100644 --- a/src/services/urunArama.js +++ b/src/services/urunArama.js @@ -6,9 +6,10 @@ * "ORTOPEDİK VİDA" hiçbir ihale başlığında geçmiyor ama teklif cetvellerinde * geçiyor — o ihalelerin adı "VETERİNER İLAÇ ... SARF MALZEME". */ -export async function urunAra(q, { skip = 0, take = 20, teknik = false } = {}) { - const p = new URLSearchParams({ q, skip: String(skip), take: String(take) }); +export async function urunAra(q, { skip = 0, take = 20, teknik = false, okas = [] } = {}) { + const p = new URLSearchParams({ q: q || '', skip: String(skip), take: String(take) }); if (teknik) p.set('teknik', '1'); + if (okas.length) p.set('okas', okas.join(',')); const r = await fetch(`/api/urun-ara?${p}`); const d = await r.json().catch(() => ({})); if (!d.success) throw new Error(d.error || 'arama başarısız');