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>
This commit is contained in:
+40
-14
@@ -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.
|
||||
|
||||
+38
-11
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user