feat: ihale dokümanları + OKAS kalem verisi + doğrudan temin (Playwright) + kalıcı arşiv
1) İhale evrakları (yeni)
- server/documents.mjs: EKAP doküman URL üretimi (imzalı), ilan metni +
OKAS kalem çıkarımı, Playwright ile doküman metni (varsa)
- /api/documents/url · /ilan · /text · /config uçları
- TenderDocuments paneli: 'EKAP Dokümanlarını Aç' + 'İlan Metnini AI ile
Analiz Et' (11K+ karakter gerçek ilan metni doğrudan AI'ya gider)
- NOT: eski EKAP host'u bot isteklerine bağlantı kesiyor; bağlantı
kullanıcının tarayıcısında açılır, sunucu tarafı metin için Chromium gerekir
2) Kalem verisi gerçek oldu
- ekap-scan: her ihale için IhaleDetay'dan OKAS ihtiyaç kalemleri çekilir
- history-store: okas_kalemler kolonu + otomatik şema geçişi
- --kalem-doldur modu: mevcut kayıtlara IKN'den çözerek kalem ekler
- scheduler: tarama sonrası kalem doldurmayı zincirler
- PriceAnalysis: yöntem şeffaflığı notu (birim fiyatlar kamuya açık değil;
kategori bazlı yaklaşık değer olduğu açıkça yazıyor)
3) Doğrudan temin (Playwright)
- Dockerfile: node:22-slim + nginx + Playwright/Chromium
- scheduler: DT worker periyodik (DT_INTERVAL_SAAT, varsayılan 4 saat)
- Bellek koruması: boş bellek DT_MIN_BOS_MB altındaysa tarama atlanır ve
uyarı loglanır; DT_ENABLED=0 ile tamamen kapatılabilir
4) Kalıcı arşiv
- Coolify'da /app/server/data volume bağlandı; arşiv artık deploy'lar
arası korunuyor
Ayrıca: TenderDetailModal'daki sabit '3-5 Firma' tahmini kaldırıldı, yerine
gerçek geçerli teklif sayısı gösteriliyor.
This commit is contained in:
+23
-13
@@ -1,32 +1,42 @@
|
||||
# Stage 1: Vite SPA build
|
||||
FROM node:22-alpine AS build
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: nginx (frontend) + node (canlı veri backend) tek container'da
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache nginx
|
||||
# Stage 2: nginx (frontend) + node (canlı veri backend) + Playwright/Chromium
|
||||
# NOT: Chromium yalnızca doğrudan temin (EKAP anti-bot) ve ihale dokümanı
|
||||
# indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir;
|
||||
# uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır.
|
||||
FROM node:22-slim
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Playwright + Chromium (yalnızca chromium; sistem bağımlılıklarıyla)
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
RUN npm init -y >/dev/null \
|
||||
&& npm i --omit=dev playwright@1.62.1 \
|
||||
&& npx playwright install --with-deps chromium \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Frontend
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY --from=build /app/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
|
||||
|
||||
# Backend (yalnızca Node builtin'leri kullanır — npm bağımlılığı yok)
|
||||
WORKDIR /app
|
||||
# Backend (Node builtin'leri + playwright)
|
||||
COPY --from=build /app/server ./server
|
||||
|
||||
# Geçmiş sonuç veritabanı (SQLite). Deploy'lar arası kalıcılık için Coolify'da
|
||||
# /app/server/data yoluna bir volume bağlanmalı; bağlanmazsa tarayıcı veriyi
|
||||
# her yeniden dağıtımdan sonra yeniden biriktirir.
|
||||
# Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı)
|
||||
RUN mkdir -p /app/server/data
|
||||
VOLUME ["/app/server/data"]
|
||||
|
||||
# Başlangıç: Node backend'i (sabit 3001) arka planda + nginx (80) ön planda.
|
||||
# Coolify container'a PORT=80 verir; backend'i 3001'e sabitleyip nginx ile
|
||||
# çakışmasını önlüyoruz (nginx /api → 127.0.0.1:3001 proxy'ler).
|
||||
# Başlangıç: Node backend (3001) arka planda + nginx (80) ön planda.
|
||||
RUN printf '#!/bin/sh\nset -e\nPORT=3001 node /app/server/index.js &\nexec nginx -g "daemon off;"\n' > /start.sh \
|
||||
&& chmod +x /start.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* İhale dokümanları — EKAP doküman URL'i üretimi + (Playwright varsa) metin çıkarma.
|
||||
*
|
||||
* Neden Playwright: doküman URL'i EKAP v2 API'sinden imzalı olarak üretilebiliyor,
|
||||
* ancak eski EKAP host'u (ekap.kik.gov.tr) otomatik istemcilere bağlantıyı kesiyor
|
||||
* (BIG-IP anti-bot → ECONNRESET). Kullanıcı tarayıcısında link sorunsuz açılır;
|
||||
* sunucu tarafında metin çıkarmak için gerçek tarayıcı gerekir.
|
||||
*
|
||||
* Chromium yoksa: URL yine döner (kullanıcı kendi tarayıcısında açar),
|
||||
* yalnızca sunucu tarafı metin çıkarma devre dışı kalır.
|
||||
*/
|
||||
|
||||
import { searchTenders, ekapPost, getIhaleDetay } from './ekap-v2.mjs';
|
||||
|
||||
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36';
|
||||
|
||||
/** IKN'den taze bir EKAP kaydı bulur (id token anlıktır, saklanamaz). */
|
||||
export async function findByIkn(ikn) {
|
||||
const [yil, sayi] = String(ikn || '').split('/');
|
||||
if (!yil || !sayi) throw new Error('Geçersiz İKN');
|
||||
const r = await searchTenders({ iknYili: Number(yil), iknSayi: Number(sayi), take: 1 });
|
||||
const t = r.list[0];
|
||||
if (!t) throw new Error('İhale EKAP’ta bulunamadı');
|
||||
return t;
|
||||
}
|
||||
|
||||
/** İhale dokümanlarının EKAP görüntüleme/indirme adresini üretir. */
|
||||
export async function documentUrl(ikn) {
|
||||
const t = await findByIkn(ikn);
|
||||
const d = await ekapPost('/b_ihalearama/api/EkapDokumanYonlendirme/GetDokumanUrl', {
|
||||
islemId: '1',
|
||||
ihaleId: t.id,
|
||||
});
|
||||
if (!d?.success || !d?.url) {
|
||||
throw new Error(d?.message || 'Bu ihale için doküman bağlantısı üretilemedi');
|
||||
}
|
||||
return {
|
||||
url: d.url,
|
||||
ikn: t.ikn,
|
||||
ihaleAdi: t.ihaleAdi,
|
||||
idareAdi: t.idareAdi,
|
||||
dokumanSayisi: t.dokumanSayisi ?? 0,
|
||||
dokumanListe: t.dokumanListe || [],
|
||||
};
|
||||
}
|
||||
|
||||
/** İhale ilan metni + kalem listesi (dokümana gerek kalmadan analiz için). */
|
||||
export async function ilanMetni(ikn) {
|
||||
const t = await findByIkn(ikn);
|
||||
const d = await getIhaleDetay(t.id);
|
||||
const bilgi = d?.ihaleBilgi || {};
|
||||
const okas = d?.ihtiyacKalemiOkasList || [];
|
||||
const parcalar = [
|
||||
`İhale adı: ${d?.ihaleAdi || t.ihaleAdi || ''}`,
|
||||
`İKN: ${d?.ikn || t.ikn || ''}`,
|
||||
`İdare: ${d?.idareAdi || t.idareAdi || ''}`,
|
||||
`Usul: ${bilgi.ihaleUsulAciklama || d?.ihaleUsul || ''}`,
|
||||
`Tür: ${bilgi.ihaleTipiAciklama || ''}`,
|
||||
`İhale tarihi: ${bilgi.ihaleTarihSaat || ''}`,
|
||||
`İşin yapılacağı yer: ${bilgi.isinYapilacagiYer || ''}`,
|
||||
`İhale yeri: ${bilgi.ihaleYeri || ''}`,
|
||||
bilgi.itirazenSikayetBasvuruBedeli ? `İtirazen şikâyet bedeli: ${bilgi.itirazenSikayetBasvuruBedeli}` : '',
|
||||
okas.length ? `İhtiyaç kalemleri (OKAS):\n` + okas.map((k) => ` - ${k.kodu} ${k.adi || k.koduAdi}`).join('\n') : '',
|
||||
Array.isArray(d?.ihaleOzellikList) && d.ihaleOzellikList.length
|
||||
? 'Özellikler:\n' + d.ihaleOzellikList.map((o) => ` - ${o.ihaleOzellik}`).join('\n') : '',
|
||||
].filter(Boolean);
|
||||
|
||||
// İlan HTML'i varsa düz metne çevir (en zengin kaynak)
|
||||
const ilanlar = d?.ilanList || [];
|
||||
for (const il of ilanlar) {
|
||||
if (il.veriHtml) {
|
||||
parcalar.push(`\n--- ${il.ilanTipAciklama || 'İLAN'} (${il.ilanTarihi || ''}) ---`);
|
||||
parcalar.push(htmlToText(il.veriHtml));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ikn: t.ikn,
|
||||
ihaleAdi: d?.ihaleAdi || t.ihaleAdi,
|
||||
idareAdi: d?.idareAdi || t.idareAdi,
|
||||
ilanSayisi: ilanlar.length,
|
||||
okasSayisi: okas.length,
|
||||
metin: parcalar.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const ENT = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
||||
function htmlToText(html) {
|
||||
return String(html || '')
|
||||
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ')
|
||||
.replace(/<\s*\/\s*(tr|p|div|h\d)\s*>/gi, '\n')
|
||||
.replace(/<\s*\/\s*t[dh]\s*>/gi, ' | ')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&[a-z#0-9]+;/gi, (m) => ENT[m.toLowerCase()] ?? ' ')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Chromium var mı? (DT worker ile aynı bağımlılık) */
|
||||
export async function browserAvailable() {
|
||||
try {
|
||||
await import('playwright');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Doküman sayfasını gerçek tarayıcıyla açıp metnini çıkarır.
|
||||
* Chromium yoksa anlaşılır hata verir.
|
||||
*/
|
||||
export async function documentText(ikn, { timeout = 60000 } = {}) {
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = await import('playwright'));
|
||||
} catch {
|
||||
throw new Error('Sunucuda tarayıcı bileşeni yok — doküman metni çıkarılamıyor. Bağlantıyı kendi tarayıcınızda açabilirsiniz.');
|
||||
}
|
||||
const { url, ihaleAdi } = await documentUrl(ikn);
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
try {
|
||||
const ctx = await browser.newContext({ userAgent: UA, locale: 'tr-TR' });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
|
||||
await page.waitForTimeout(1500);
|
||||
const metin = await page.evaluate(() => document.body?.innerText || '');
|
||||
return { ikn, ihaleAdi, url, metin: String(metin).replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim() };
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
+55
-5
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
searchTenders, fetchResults, normalizeCompanyName, DURUM_SONUC_ILANI,
|
||||
searchTenders, fetchResults, getIhaleDetay, normalizeCompanyName, DURUM_SONUC_ILANI,
|
||||
} from './ekap-v2.mjs';
|
||||
import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs';
|
||||
|
||||
@@ -40,6 +40,7 @@ if (!bas || !son) {
|
||||
const LIMIT = Number(arg('limit', 500));
|
||||
const SAYFA = 50;
|
||||
const ESZAMANLI = Number(arg('paralel', 6));
|
||||
const ATLA_KALEM = has('kalemsiz'); // --kalemsiz ile OKAS çekimini kapat (daha hızlı)
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function havuz(items, worker, n) {
|
||||
@@ -56,6 +57,35 @@ async function havuz(items, worker, n) {
|
||||
return sonuc;
|
||||
}
|
||||
|
||||
// --- Mevcut kayıtlara OKAS kalemi doldur ---
|
||||
// Eski kayıtlarda EKAP'ın anlık `id` token'ı saklanmadığı için IKN ile yeniden
|
||||
// arama yapıp taze bir id alınır, sonra detaydan kalemler çekilir.
|
||||
async function kalemDoldur(db, limit) {
|
||||
const eksik = db.prepare(
|
||||
'SELECT ikn FROM sonuclar WHERE okas_kalemler IS NULL GROUP BY ikn LIMIT ?'
|
||||
).all(limit);
|
||||
console.log(`[ekap-scan] OKAS kalemi eksik ${eksik.length} ihale işlenecek`);
|
||||
let ok = 0, bos = 0, hata = 0;
|
||||
|
||||
const upd = db.prepare('UPDATE sonuclar SET okas_kalemler=? WHERE ikn=?');
|
||||
|
||||
await havuz(eksik, async (row) => {
|
||||
const [yil, sayi] = String(row.ikn).split('/');
|
||||
if (!yil || !sayi) { bos++; return; }
|
||||
const r = await searchTenders({ iknYili: Number(yil), iknSayi: Number(sayi), take: 1 });
|
||||
const t = r.list[0];
|
||||
if (!t) { bos++; return; }
|
||||
const d = await getIhaleDetay(t.id);
|
||||
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
|
||||
if (!Array.isArray(okas) || !okas.length) { bos++; return; }
|
||||
upd.run(JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi }))), row.ikn);
|
||||
ok++;
|
||||
if ((ok + bos) % 25 === 0) console.log(`[ekap-scan] kalem doldurma: ${ok} dolduruldu, ${bos} boş`);
|
||||
}, ESZAMANLI).catch(() => { hata++; });
|
||||
|
||||
console.log(`[ekap-scan] KALEM DOLDURMA BİTTİ — ${ok} ihaleye kalem yazıldı, ${bos} kalemsiz, ${hata} hata.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!available()) {
|
||||
console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.');
|
||||
@@ -63,12 +93,22 @@ async function main() {
|
||||
}
|
||||
const db = openDb({ create: true });
|
||||
const baslangic = new Date().toISOString();
|
||||
|
||||
// Kalem doldurma modu: mevcut kayıtlara OKAS ekler, tarama yapmaz
|
||||
const doldur = arg('kalem-doldur');
|
||||
if (doldur) {
|
||||
await kalemDoldur(db, Number(doldur) || 200);
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ekap-scan] aralık ${bas} → ${son}, limit ${LIMIT}, paralel ${ESZAMANLI}`);
|
||||
|
||||
let skip = 0;
|
||||
let taranan = 0;
|
||||
let eklenen = 0;
|
||||
let hata = 0;
|
||||
let kalemli = 0;
|
||||
let toplamHavuz = null;
|
||||
|
||||
while (taranan < LIMIT) {
|
||||
@@ -102,17 +142,27 @@ async function main() {
|
||||
const rs = await fetchResults(t.id);
|
||||
if (!rs.length) return { bos: true };
|
||||
for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma);
|
||||
// OKAS ihtiyaç kalemleri (kategori bazlı fiyat analizi için)
|
||||
if (!ATLA_KALEM) {
|
||||
try {
|
||||
const d = await getIhaleDetay(t.id);
|
||||
const okas = d?.ihtiyacKalemiOkasList || d?.IhtiyacKalemiOkasList || [];
|
||||
if (Array.isArray(okas) && okas.length) {
|
||||
t.okasJson = JSON.stringify(okas.map((k) => ({ kodu: k.kodu, adi: k.adi || k.koduAdi })));
|
||||
}
|
||||
} catch { /* kalem alınamazsa sonucu yine de kaydet */ }
|
||||
}
|
||||
const n = saveResults(db, t, rs);
|
||||
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma };
|
||||
return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma, kalem: t.okasJson ? 1 : 0 };
|
||||
}, ESZAMANLI);
|
||||
|
||||
for (const s of sonuclar) {
|
||||
if (s?.hata) hata++;
|
||||
else if (s?.kayit) eklenen += s.kayit;
|
||||
else if (s?.kayit) { eklenen += s.kayit; kalemli += s.kalem || 0; }
|
||||
}
|
||||
taranan += sayfa.list.length;
|
||||
skip += sayfa.list.length;
|
||||
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${atlanan} atlandı · ${hata} hata`);
|
||||
console.log(`[ekap-scan] ${taranan}/${Math.min(LIMIT, toplamHavuz)} tarandı · ${eklenen} kayıt · ${kalemli} kalemli · ${atlanan} atlandı · ${hata} hata`);
|
||||
|
||||
if (skip >= toplamHavuz) break;
|
||||
await sleep(300); // EKAP'ı yormamak için nazik ara
|
||||
@@ -124,7 +174,7 @@ async function main() {
|
||||
not: `havuz=${toplamHavuz}`,
|
||||
});
|
||||
db.close();
|
||||
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı yazıldı, ${hata} hata.`);
|
||||
console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı (${kalemli} OKAS kalemli), ${hata} hata.`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); });
|
||||
|
||||
@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS sonuclar (
|
||||
yaklasik_maliyet REAL,
|
||||
teklif_sayisi INTEGER,
|
||||
gecerli_teklif_sayisi INTEGER,
|
||||
okas_kalemler TEXT,
|
||||
cekilme_zamani TEXT,
|
||||
PRIMARY KEY (ikn, kisim)
|
||||
);
|
||||
@@ -67,6 +68,16 @@ export function openDb({ create = false } = {}) {
|
||||
}
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(SCHEMA);
|
||||
// Geçiş: eski şemada okas_kalemler yoksa ekle
|
||||
try {
|
||||
const kolonlar = db.prepare('PRAGMA table_info(sonuclar)').all().map((c) => c.name);
|
||||
if (!kolonlar.includes('okas_kalemler')) {
|
||||
db.exec('ALTER TABLE sonuclar ADD COLUMN okas_kalemler TEXT');
|
||||
console.log('[history-store] okas_kalemler kolonu eklendi');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[history-store] şema geçişi atlandı:', e.message);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -74,12 +85,13 @@ export function saveResults(db, tender, results) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO sonuclar (ikn,kisim,ihale_adi,idare_adi,il,ihale_tipi,ihale_tarihi,ilan_tarihi,
|
||||
kazanan_firma,kazanan_norm,kazanan_adres,sozlesme_bedeli,para_birimi,sozlesme_tarihi,
|
||||
yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,cekilme_zamani)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
yaklasik_maliyet,teklif_sayisi,gecerli_teklif_sayisi,okas_kalemler,cekilme_zamani)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(ikn,kisim) DO UPDATE SET
|
||||
kazanan_firma=excluded.kazanan_firma, kazanan_norm=excluded.kazanan_norm,
|
||||
sozlesme_bedeli=excluded.sozlesme_bedeli, yaklasik_maliyet=excluded.yaklasik_maliyet,
|
||||
teklif_sayisi=excluded.teklif_sayisi, gecerli_teklif_sayisi=excluded.gecerli_teklif_sayisi,
|
||||
okas_kalemler=COALESCE(excluded.okas_kalemler, sonuclar.okas_kalemler),
|
||||
cekilme_zamani=excluded.cekilme_zamani
|
||||
`);
|
||||
const now = new Date().toISOString();
|
||||
@@ -92,6 +104,7 @@ export function saveResults(db, tender, results) {
|
||||
r.kazananFirma || null, r.kazananNorm || null, r.kazananAdres || null,
|
||||
r.sozlesmeBedeli ?? null, r.paraBirimi || null, r.sozlesmeTarihi || null,
|
||||
r.yaklasikMaliyet ?? null, r.teklifSayisi ?? null, r.gecerliTeklifSayisi ?? null,
|
||||
tender.okasJson ?? null,
|
||||
now
|
||||
);
|
||||
n++;
|
||||
@@ -106,6 +119,28 @@ export function hasIkn(db, ikn) {
|
||||
|
||||
// --- Okuma tarafı (API için) --------------------------------------------
|
||||
|
||||
// OKAS kalem listesi JSON'unu tender.items biçimine çevirir.
|
||||
// NOT: teklif birim fiyatları kamuya açık DEĞİL. Burada üretilen 'birimFiyat',
|
||||
// sözleşme bedelinin kalem sayısına bölünmesiyle elde edilen YAKLAŞIK bir
|
||||
// göstergedir; kesin birim fiyat değildir.
|
||||
function okasParse(json, bedel) {
|
||||
if (!json) return [];
|
||||
try {
|
||||
const list = JSON.parse(json);
|
||||
if (!Array.isArray(list) || !list.length) return [];
|
||||
const pay = bedel != null && list.length ? Number(bedel) / list.length : null;
|
||||
return list.map((k, i) => ({
|
||||
id: `okas-${i}`,
|
||||
kod: k.kodu || null,
|
||||
ad: k.adi || k.koduAdi || 'Kalem',
|
||||
miktar: null,
|
||||
birim: null,
|
||||
birimFiyat: pay,
|
||||
yaklasik: true,
|
||||
}));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function rowToTender(r) {
|
||||
return {
|
||||
id: `hist-${r.ikn}-${r.kisim}`,
|
||||
@@ -125,7 +160,7 @@ function rowToTender(r) {
|
||||
teklifSayisi: r.teklif_sayisi,
|
||||
gecerliTeklifSayisi: r.gecerli_teklif_sayisi,
|
||||
durum: 'Sonuçlandı',
|
||||
items: [],
|
||||
items: okasParse(r.okas_kalemler, r.sozlesme_bedeli),
|
||||
// Arayüzün beklediği alanlar (geçmiş kayıtta karşılığı yok — güvenli varsayılan)
|
||||
eklenmeZamani: 'arşivden',
|
||||
isYeni: false,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { searchTed } from './ted.mjs';
|
||||
import { readHistory, historyStats } from './history-store.mjs';
|
||||
import { startScheduler } from './scheduler.mjs';
|
||||
import { searchKik, kararMetni } from './kik.mjs';
|
||||
import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs';
|
||||
|
||||
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
||||
@@ -343,6 +344,45 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { success: true, user });
|
||||
}
|
||||
|
||||
// --- İhale dokümanları ---
|
||||
if (url.pathname === '/api/documents/url') {
|
||||
try {
|
||||
const ikn = url.searchParams.get('ikn');
|
||||
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
|
||||
const out = await documentUrl(ikn);
|
||||
return json(res, 200, { success: true, ...out });
|
||||
} catch (e) {
|
||||
return json(res, 502, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
// İlan metni + kalemler — analiz için (tarayıcı gerektirmez)
|
||||
if (url.pathname === '/api/documents/ilan') {
|
||||
try {
|
||||
const ikn = url.searchParams.get('ikn');
|
||||
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
|
||||
const out = await ilanMetni(ikn);
|
||||
return json(res, 200, { success: true, ...out });
|
||||
} catch (e) {
|
||||
return json(res, 502, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
// Doküman metni — sunucu tarafı tarayıcı gerektirir
|
||||
if (url.pathname === '/api/documents/text') {
|
||||
const user = userFromAuthHeader(req.headers['authorization']);
|
||||
if (!user) return json(res, 401, { success: false, error: 'oturum yok' });
|
||||
try {
|
||||
const ikn = url.searchParams.get('ikn');
|
||||
if (!ikn) return json(res, 400, { success: false, error: 'ikn gerekli' });
|
||||
const out = await documentText(ikn);
|
||||
return json(res, 200, { success: true, ...out });
|
||||
} catch (e) {
|
||||
return json(res, 502, { success: false, error: e.message });
|
||||
}
|
||||
}
|
||||
if (url.pathname === '/api/documents/config') {
|
||||
return json(res, 200, { tarayiciVar: await browserAvailable() });
|
||||
}
|
||||
|
||||
// --- KİK kurul kararları ---
|
||||
if (url.pathname === '/api/kik/search') {
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { historyStats } from './history-store.mjs';
|
||||
@@ -54,6 +55,8 @@ function tara() {
|
||||
p.on('close', (code) => {
|
||||
calisiyor = false;
|
||||
console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`);
|
||||
// Taramanın ardından eksik OKAS kalemlerini doldur (fiyat analizi için)
|
||||
if (code === 0 && process.env.KALEM_DOLDUR !== '0') kalemDoldur();
|
||||
});
|
||||
p.on('error', (e) => {
|
||||
calisiyor = false;
|
||||
@@ -61,6 +64,58 @@ function tara() {
|
||||
});
|
||||
}
|
||||
|
||||
// Eksik OKAS kalemlerini arka planda doldurur (kategori bazlı fiyat analizi)
|
||||
function kalemDoldur() {
|
||||
const n = process.env.KALEM_DOLDUR_LIMIT || '150';
|
||||
const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--kalem-doldur', n], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
p.stdout.on('data', (d) => process.stdout.write(String(d)));
|
||||
p.on('error', (e) => console.warn('[scheduler] kalem doldurma başlatılamadı:', e.message));
|
||||
}
|
||||
|
||||
// --- Doğrudan temin (Playwright/Chromium gerektirir) ---
|
||||
let dtCalisiyor = false;
|
||||
|
||||
// Chromium ~350MB RSS kullanabilir; sunucu darsa DT'yi atla ve uyar.
|
||||
function bellekYeterliMi() {
|
||||
const bosMB = Math.round(os.freemem() / 1024 / 1024);
|
||||
const esik = Number(process.env.DT_MIN_BOS_MB || 600);
|
||||
if (bosMB < esik) {
|
||||
console.warn(`[scheduler] ⚠ Sunucu belleği dar (${bosMB}MB boş < ${esik}MB) — doğrudan temin taraması bu tur atlandı. Kalıcı sorun olursa DT_ENABLED=0 ile kapatın.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function dtTara() {
|
||||
if (process.env.DT_ENABLED === '0') return;
|
||||
if (dtCalisiyor) {
|
||||
console.log('[scheduler] önceki DT taraması sürüyor, bu tur atlandı');
|
||||
return;
|
||||
}
|
||||
if (!bellekYeterliMi()) return;
|
||||
dtCalisiyor = true;
|
||||
const pages = process.env.DT_PAGES || '5';
|
||||
const details = process.env.DT_DETAILS || '30';
|
||||
console.log(`[scheduler] doğrudan temin taraması başlıyor (${pages} sayfa, ${details} detay)`);
|
||||
|
||||
const p = spawn(process.execPath, [path.join(__dirname, 'dt-worker.mjs'), '--pages', pages, '--details', details], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
p.stdout.on('data', (d) => process.stdout.write(String(d)));
|
||||
p.stderr.on('data', (d) => process.stderr.write(String(d)));
|
||||
p.on('close', (code) => {
|
||||
dtCalisiyor = false;
|
||||
if (code !== 0) console.warn(`[scheduler] ⚠ DT taraması ${code} koduyla bitti — Chromium kurulu mu / bellek yeterli mi kontrol edin.`);
|
||||
else console.log('[scheduler] DT taraması bitti');
|
||||
});
|
||||
p.on('error', (e) => {
|
||||
dtCalisiyor = false;
|
||||
console.warn('[scheduler] ⚠ DT worker başlatılamadı:', e.message, '— Chromium yoksa DT_ENABLED=0 yapın.');
|
||||
});
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
if (process.env.SCAN_ENABLED === '0') {
|
||||
console.log('[scheduler] devre dışı (SCAN_ENABLED=0)');
|
||||
@@ -72,4 +127,14 @@ export function startScheduler() {
|
||||
// Açılıştan 90 sn sonra ilk tur (sunucu ısınsın), sonra periyodik
|
||||
setTimeout(tara, 90 * 1000).unref?.();
|
||||
setInterval(tara, ms).unref?.();
|
||||
|
||||
// Doğrudan temin: sonuç taramasıyla çakışmasın diye kaydırmalı
|
||||
if (process.env.DT_ENABLED === '0') {
|
||||
console.log('[scheduler] doğrudan temin devre dışı (DT_ENABLED=0)');
|
||||
} else {
|
||||
const dtSaat = Number(process.env.DT_INTERVAL_SAAT || 4);
|
||||
console.log(`[scheduler] doğrudan temin taraması her ${dtSaat} saatte bir çalışacak`);
|
||||
setTimeout(dtTara, 4 * 60 * 1000).unref?.();
|
||||
setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { TenderDocuments } from './TenderDocuments';
|
||||
import {
|
||||
X, Star, Share2, Calendar, MapPin, Building2, Tag, FileText, CheckSquare,
|
||||
DollarSign, Calculator, Download, Sparkles, AlertTriangle, ShieldCheck,
|
||||
@@ -460,8 +461,16 @@ END:VCALENDAR`;
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/10 p-4 rounded-xl border border-white/10 space-y-1">
|
||||
<span className="text-slate-300 text-xs font-semibold block">Tahmini Rekabet</span>
|
||||
<span className="text-lg font-black text-blue-300">3 - 5 Firma</span>
|
||||
<span className="text-slate-300 text-xs font-semibold block">
|
||||
{tender.gecerliTeklifSayisi != null || tender.teklifSayisi != null ? 'Katılan Firma' : 'Rekabet Verisi'}
|
||||
</span>
|
||||
<span className="text-lg font-black text-blue-300">
|
||||
{tender.gecerliTeklifSayisi != null
|
||||
? `${tender.gecerliTeklifSayisi} geçerli teklif`
|
||||
: tender.teklifSayisi != null
|
||||
? `${tender.teklifSayisi} teklif`
|
||||
: 'Sonuçlanınca gelir'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -476,6 +485,8 @@ END:VCALENDAR`;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TenderDocuments tender={tender} />
|
||||
|
||||
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
|
||||
<h4 className="font-bold text-sm text-slate-900">Kritik Yeterlik Şartları</h4>
|
||||
<ul className="space-y-2 text-xs text-slate-700">
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { getToken } from '../services/auth';
|
||||
import { FileText, ExternalLink, Loader2, Sparkles, AlertTriangle, Download } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* İhale dokümanları paneli.
|
||||
* - EKAP doküman bağlantısını üretip kullanıcının tarayıcısında açar
|
||||
* - İlan metnini (ilan + OKAS kalemleri) çeker ve doğrudan AI analizine verir
|
||||
*/
|
||||
export const TenderDocuments = ({ tender }) => {
|
||||
const { analyzeSpecPdfWithGemini, setIsAiSpecAnalyzerOpen } = useApp();
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [bilgi, setBilgi] = useState(null);
|
||||
|
||||
const ikn = tender?.ikn;
|
||||
const ekapIhalesi = /^\d{4}\/\d+$/.test(String(ikn || ''));
|
||||
|
||||
const dokumaniAc = async () => {
|
||||
setBusy('url'); setError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
setBilgi({ dokumanSayisi: d.dokumanSayisi });
|
||||
window.open(d.url, '_blank', 'noopener,noreferrer');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const ilaniAnalizEt = async () => {
|
||||
setBusy('analiz'); setError(null);
|
||||
try {
|
||||
const r = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) throw new Error(d.error);
|
||||
if (!d.metin || d.metin.length < 100) throw new Error('İlan metni analiz için yeterli değil.');
|
||||
setBilgi({ karakter: d.metin.length, ilanSayisi: d.ilanSayisi, okasSayisi: d.okasSayisi });
|
||||
setIsAiSpecAnalyzerOpen(true);
|
||||
await analyzeSpecPdfWithGemini({ text: d.metin, fileName: `${d.ikn} — EKAP ilan metni`, tender });
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-5 rounded-xl border border-slate-200 space-y-3">
|
||||
<h4 className="font-bold text-sm text-slate-900 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-blue-600" /> İhale Dokümanları
|
||||
</h4>
|
||||
|
||||
{!ekapIhalesi ? (
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
Bu kayıt EKAP İKN formatında değil; doküman bağlantısı yalnızca EKAP ihaleleri için üretilebilir.
|
||||
{tender?.kaynakUrl && (
|
||||
<> İlanın kaynağını{' '}
|
||||
<a href={tender.kaynakUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-semibold hover:underline">
|
||||
buradan açabilirsiniz
|
||||
</a>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
İhale dokümanları EKAP üzerinde tutulur. Aşağıdaki bağlantı sizi doğrudan bu ihalenin
|
||||
EKAP doküman sayfasına götürür; ilan metnini ise analiz için buradan çekebilirsiniz.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={dokumaniAc}
|
||||
disabled={busy !== null}
|
||||
className="bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
|
||||
>
|
||||
{busy === 'url' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5" />}
|
||||
EKAP Dokümanlarını Aç
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={ilaniAnalizEt}
|
||||
disabled={busy !== null}
|
||||
className="bg-amber-400 hover:bg-amber-300 disabled:opacity-40 text-slate-950 font-black text-xs px-4 py-2.5 rounded-xl flex items-center gap-2"
|
||||
>
|
||||
{busy === 'analiz' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||
İlan Metnini AI ile Analiz Et
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bilgi && (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{bilgi.dokumanSayisi != null && <>EKAP'ta {bilgi.dokumanSayisi} doküman kayıtlı. </>}
|
||||
{bilgi.karakter != null && <>{bilgi.ilanSayisi} ilan · {bilgi.okasSayisi} kalem · {bilgi.karakter.toLocaleString('tr-TR')} karakter metin analize gönderildi.</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-2.5 flex gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-600 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-red-800 leading-relaxed">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-slate-400 leading-relaxed flex items-start gap-1.5">
|
||||
<Download className="w-3 h-3 shrink-0 mt-0.5" />
|
||||
EKAP, doküman indirmeyi kendi oturumu üzerinden yaptırır; bağlantı sizin tarayıcınızda açılır.
|
||||
Şartname dosyanız elinizdeyse AI analiz penceresinden doğrudan yükleyebilirsiniz.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -52,7 +52,7 @@ export const PriceAnalysis = () => {
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-slate-900">Kalem Bazlı Fiyat Analizi</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
Toplanan ihalelerdeki gerçek birim fiyatlardan hesaplanır · <strong>{uniqueItems}</strong> farklı kalem, <strong>{itemCount}</strong> fiyat kaydı.
|
||||
EKAP sonuç ilanlarındaki OKAS ihtiyaç kalemleri ve sözleşme bedellerinden hesaplanır · <strong>{uniqueItems}</strong> farklı kalem kategorisi, <strong>{itemCount}</strong> kayıt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,11 +64,24 @@ export const PriceAnalysis = () => {
|
||||
<Download className="w-4 h-4" /> CSV İndir
|
||||
</button>
|
||||
</div>
|
||||
{/* Yöntem şeffaflığı */}
|
||||
{items.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3.5 flex gap-2.5">
|
||||
<span className="text-blue-600 font-bold shrink-0">ℹ</span>
|
||||
<p className="text-[11px] text-blue-900 leading-relaxed">
|
||||
<strong>Yöntem:</strong> Türkiye'de ihale <em>teklif birim fiyatları</em> kamuya açık değildir.
|
||||
Buradaki tutarlar, EKAP sonuç ilanındaki <strong>sözleşme bedelinin</strong> ihalenin OKAS
|
||||
ihtiyaç kalemlerine bölünmesiyle elde edilen <strong>kategori bazlı yaklaşık</strong> değerlerdir —
|
||||
kesin birim fiyat değildir. Aynı OKAS kategorisindeki ihaleler arasında karşılaştırma için kullanın.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-10 text-center text-sm text-slate-500 flex flex-col items-center gap-2">
|
||||
<Inbox className="w-8 h-8 text-slate-300" />
|
||||
Henüz kalem fiyat verisi birikmedi. Kalem/birim fiyat içeren ihaleler (doğrudan temin sözleşme kalemleri) geldikçe bu analiz gerçek fiyatlarla otomatik dolar.
|
||||
Henüz kalem verisi birikmedi. EKAP sonuç ilanları ve OKAS ihtiyaç kalemleri arşive eklendikçe bu analiz otomatik dolar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
Reference in New Issue
Block a user