diff --git a/.claude/launch.json b/.claude/launch.json index 896fda6..df9d8f3 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,8 +4,8 @@ { "name": "ihaleist-dev", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 3000 + "runtimeArgs": ["run", "dev", "--", "--port", "3005", "--strictPort"], + "port": 3005 } ] } diff --git a/Dockerfile b/Dockerfile index 60327a7..71b5699 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,12 @@ COPY --from=build /app/nginx.conf /etc/nginx/http.d/default.conf WORKDIR /app 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. +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). diff --git a/package-lock.json b/package-lock.json index 45ddae2..4d8a6ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "canvas-confetti": "^1.9.4", "jspdf": "^4.2.1", "lucide-react": "^0.469.0", + "pdfjs-dist": "^6.2.108", "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.15.0" @@ -808,6 +809,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz", + "integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.8", + "@napi-rs/canvas-darwin-arm64": "1.0.8", + "@napi-rs/canvas-darwin-x64": "1.0.8", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.8", + "@napi-rs/canvas-linux-arm64-musl": "1.0.8", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-gnu": "1.0.8", + "@napi-rs/canvas-linux-x64-musl": "1.0.8", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.8", + "@napi-rs/canvas-win32-x64-msvc": "1.0.8" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz", + "integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz", + "integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz", + "integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz", + "integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz", + "integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz", + "integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz", + "integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz", + "integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz", + "integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz", + "integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz", + "integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2580,6 +2846,18 @@ ], "license": "(MIT AND Zlib)" }, + "node_modules/pdfjs-dist": { + "version": "6.2.108", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", + "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", diff --git a/package.json b/package.json index 1f2084a..faa0218 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "canvas-confetti": "^1.9.4", "jspdf": "^4.2.1", "lucide-react": "^0.469.0", + "pdfjs-dist": "^6.2.108", "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.15.0" diff --git a/server/ai.mjs b/server/ai.mjs new file mode 100644 index 0000000..696b9eb --- /dev/null +++ b/server/ai.mjs @@ -0,0 +1,206 @@ +/** + * Gerçek AI şartname analizi — çoklu sağlayıcı + fallback zinciri. + * Bağımlılık yok (Node 18+ built-in fetch). + * + * Env (hangisi varsa o sağlayıcı kullanılabilir olur): + * ZAI_API_KEY → GLM (Z.ai) + * DEEPSEEK_API_KEY → DeepSeek + * GEMINI_API_KEY → Gemini (Google) + * XAI_API_KEY → Grok (xAI) + * OPENAI_API_KEY → GPT (OpenAI) + * ANTHROPIC_API_KEY → Claude (Anthropic) + * + * Model kimlikleri env ile değiştirilebilir (ör. GLM_MODEL=glm-5.3). + */ + +export const PROVIDERS = { + glm: { + label: 'GLM', provider: 'Z.ai', envKey: 'ZAI_API_KEY', + model: () => process.env.GLM_MODEL || 'glm-5.3', + url: 'https://api.z.ai/api/paas/v4/chat/completions', style: 'openai', + }, + deepseek: { + label: 'DeepSeek', provider: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY', + model: () => process.env.DEEPSEEK_MODEL || 'deepseek-chat', + url: 'https://api.deepseek.com/chat/completions', style: 'openai', + }, + gemini: { + label: 'Gemini', provider: 'Google', envKey: 'GEMINI_API_KEY', + model: () => process.env.GEMINI_MODEL || 'gemini-flash-latest', + style: 'gemini', + }, + grok: { + label: 'Grok', provider: 'xAI', envKey: 'XAI_API_KEY', + model: () => process.env.XAI_MODEL || 'grok-4', + url: 'https://api.x.ai/v1/chat/completions', style: 'openai', + }, + gpt: { + label: 'GPT', provider: 'OpenAI', envKey: 'OPENAI_API_KEY', + model: () => process.env.OPENAI_MODEL || 'gpt-5.2', + url: 'https://api.openai.com/v1/chat/completions', style: 'openai', + }, + claude: { + label: 'Claude', provider: 'Anthropic', envKey: 'ANTHROPIC_API_KEY', + model: () => process.env.ANTHROPIC_MODEL || 'claude-sonnet-5', + url: 'https://api.anthropic.com/v1/messages', style: 'anthropic', + }, +}; + +export function availableProviders() { + return Object.entries(PROVIDERS) + .filter(([, p]) => Boolean(process.env[p.envKey])) + .map(([id, p]) => ({ id, label: p.label, provider: p.provider, model: p.model() })); +} + +export function aiConfigured() { + return availableProviders().length > 0; +} + +const SYSTEM_PROMPT = `Sen Türkiye kamu ihale mevzuatı (4734/4735 sayılı kanunlar) konusunda uzman bir ihale danışmanısın. +Sana verilen teknik şartname / ihale ilanı metnini analiz et. +YALNIZCA geçerli JSON döndür, başka hiçbir metin yazma. Şema: +{ + "summary": "şartnamenin 2-3 cümlelik özeti", + "qualificationCriteria": ["istenen yeterlik belgeleri, madde madde"], + "criticalRequirements": ["dikkat edilmesi gereken kritik teknik/idari şartlar"], + "risksAndPenalties": [{"level":"Yüksek|Orta|Düşük","clause":"madde no veya bölüm","text":"risk açıklaması"}], + "bidAnalysis": { + "estimatedCost": sayı_veya_null, + "recommendedBid": sayı_veya_null, + "minBidLimit": sayı_veya_null, + "maxBidLimit": sayı_veya_null, + "winProbability": 0-100_arası_sayı_veya_null, + "competitionLevel": "rekabet seviyesi yorumu", + "notes": "teklif stratejisi notu" + } +} +Metinde olmayan bilgiyi UYDURMA — bilinmiyorsa null veya boş dizi kullan.`; + +function buildUserPrompt({ text, fileName, tender }) { + const parts = []; + if (fileName) parts.push(`Dosya: ${fileName}`); + if (tender) { + parts.push(`İhale başlığı: ${tender.baslik || '-'}`); + parts.push(`İdare: ${tender.kurum || '-'}`); + parts.push(`Şehir: ${tender.sehir || '-'}`); + parts.push(`Usul: ${tender.usul || tender.alimTuru || '-'}`); + if (tender.yaklasikMaliyet) parts.push(`Yaklaşık maliyet: ${tender.yaklasikMaliyet}`); + if (Array.isArray(tender.items) && tender.items.length) { + parts.push('Kalemler: ' + tender.items.map((i) => `${i.ad} (${i.miktar} ${i.birim})`).join('; ')); + } + } + parts.push('\n--- ŞARTNAME / İLAN METNİ ---\n' + String(text || '').slice(0, 60000)); + return parts.join('\n'); +} + +async function callOpenAiStyle(p, key, userPrompt, signal) { + const res = await fetch(p.url, { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: p.model(), + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.2, + max_tokens: 4000, + }), + signal, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`); + return data?.choices?.[0]?.message?.content || ''; +} + +async function callGemini(p, key, userPrompt, signal) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${p.model()}:generateContent?key=${encodeURIComponent(key)}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] }, + contents: [{ role: 'user', parts: [{ text: userPrompt }] }], + generationConfig: { temperature: 0.2, maxOutputTokens: 4000, responseMimeType: 'application/json' }, + }), + signal, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`); + return data?.candidates?.[0]?.content?.parts?.map((x) => x.text).join('') || ''; +} + +async function callAnthropic(p, key, userPrompt, signal) { + const res = await fetch(p.url, { + method: 'POST', + headers: { + 'x-api-key': key, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: p.model(), + max_tokens: 4000, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: userPrompt }], + }), + signal, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`); + return data?.content?.map((c) => c.text || '').join('') || ''; +} + +function extractJson(raw) { + if (!raw) throw new Error('Model boş yanıt döndürdü'); + let s = String(raw).trim(); + // ```json ... ``` sarmalını temizle + const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fence) s = fence[1].trim(); + const start = s.indexOf('{'); + const end = s.lastIndexOf('}'); + if (start === -1 || end === -1) throw new Error('Yanıtta JSON bulunamadı'); + return JSON.parse(s.slice(start, end + 1)); +} + +/** + * Zinciri sırayla dener; ilk başarılı sonucu döndürür. + * @returns {{result:object, model:object, attempts:Array}} + */ +export async function analyzeSpec({ text, fileName, tender, chain }) { + const order = (Array.isArray(chain) && chain.length ? chain : ['glm', 'deepseek', 'gemini', 'grok', 'gpt', 'claude']) + .filter((id) => PROVIDERS[id]); + const userPrompt = buildUserPrompt({ text, fileName, tender }); + const attempts = []; + + for (const id of order) { + const p = PROVIDERS[id]; + const key = process.env[p.envKey]; + if (!key) { + attempts.push({ id, ok: false, error: `${p.envKey} tanımlı değil` }); + continue; + } + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 90000); + try { + let raw; + if (p.style === 'gemini') raw = await callGemini(p, key, userPrompt, ac.signal); + else if (p.style === 'anthropic') raw = await callAnthropic(p, key, userPrompt, ac.signal); + else raw = await callOpenAiStyle(p, key, userPrompt, ac.signal); + const result = extractJson(raw); + attempts.push({ id, ok: true }); + return { + result, + model: { id, label: p.label, provider: p.provider, model: p.model() }, + attempts, + }; + } catch (e) { + attempts.push({ id, ok: false, error: e.name === 'AbortError' ? 'zaman aşımı' : e.message }); + } finally { + clearTimeout(timer); + } + } + const err = new Error('Tüm AI sağlayıcıları başarısız oldu: ' + attempts.map((a) => `${a.id}(${a.error})`).join(', ')); + err.attempts = attempts; + throw err; +} diff --git a/server/billing.mjs b/server/billing.mjs new file mode 100644 index 0000000..01fc0eb --- /dev/null +++ b/server/billing.mjs @@ -0,0 +1,128 @@ +/** + * Abonelik / ödeme — Stripe Checkout (zero-dependency, form-encoded REST). + * + * Env: + * STRIPE_SECRET_KEY — sk_live_... / sk_test_... + * STRIPE_PRICE_PRO — Pro planın Stripe Price ID'si (price_...) + * STRIPE_PRICE_KURUMSAL — Kurumsal planın Price ID'si + * APP_URL — başarı/iptal dönüş adresi (örn. https://ihaleist.seymata.com) + * + * Not: Abonelik hakkının kalıcı tanınması için Stripe webhook'u + kullanıcı + * kaydı gerekir (bkz. /api/billing/webhook). Anahtar yoksa uçlar "yapılandırılmadı" + * döner; uygulama ücretsiz katmanda çalışmaya devam eder. + */ + +export const PLANS = [ + { + id: 'ucretsiz', + ad: 'Ücretsiz', + fiyat: 0, + periyot: 'ay', + ozet: 'İhaleleri keşfedin, takibe alın.', + ozellikler: [ + 'Canlı ihale + doğrudan temin listesi', + 'Gelişmiş arama ve filtreler', + 'Favori kurumlar', + '10 ihale takibi', + 'Tarayıcı bildirimleri', + ], + sinirlar: { takip: 10, ekip: 1 }, + }, + { + id: 'pro', + ad: 'Pro', + fiyat: 899, + periyot: 'ay', + vurgu: true, + rozet: 'En popüler', + ozet: 'Teklif hazırlayan firmalar için tam analiz.', + ozellikler: [ + 'Ücretsiz plandaki her şey', + 'Sınırsız ihale takibi', + 'Kanban süreç yönetimi', + 'Kazanan firma & rakip analizi', + 'Kalem bazlı fiyat analizi', + 'AI şartname analizi', + 'PDF teklif cetveli (kaşe/imza)', + 'E-posta bildirimleri', + '5 ekip üyesi', + ], + sinirlar: { takip: null, ekip: 5 }, + priceEnv: 'STRIPE_PRICE_PRO', + }, + { + id: 'kurumsal', + ad: 'Kurumsal', + fiyat: 2490, + periyot: 'ay', + ozet: 'Çok kullanıcılı ekipler ve entegrasyon.', + ozellikler: [ + 'Pro plandaki her şey', + 'Sınırsız ekip üyesi', + 'Kırmızı bayrak & şeffaflık analizi', + 'KİK kurul kararı eşleştirme', + 'AB ihaleleri (TED) katmanı', + 'API erişimi & webhook', + 'Öncelikli destek', + ], + sinirlar: { takip: null, ekip: null }, + priceEnv: 'STRIPE_PRICE_KURUMSAL', + }, +]; + +export function billingConfigured() { + return Boolean(process.env.STRIPE_SECRET_KEY); +} + +export function planCatalog() { + return PLANS.map((p) => ({ + ...p, + satinAlinabilir: Boolean(p.priceEnv && process.env[p.priceEnv] && billingConfigured()), + })); +} + +function formEncode(obj) { + const parts = []; + const walk = (prefix, val) => { + if (val === undefined || val === null) return; + if (Array.isArray(val)) val.forEach((v, i) => walk(`${prefix}[${i}]`, v)); + else if (typeof val === 'object') Object.entries(val).forEach(([k, v]) => walk(`${prefix}[${k}]`, v)); + else parts.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(val))}`); + }; + Object.entries(obj).forEach(([k, v]) => walk(k, v)); + return parts.join('&'); +} + +export async function createCheckoutSession({ planId, email }) { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) throw new Error('Ödeme yapılandırılmamış (STRIPE_SECRET_KEY yok)'); + const plan = PLANS.find((p) => p.id === planId); + if (!plan || !plan.priceEnv) throw new Error('Geçersiz plan'); + const price = process.env[plan.priceEnv]; + if (!price) throw new Error(`${plan.ad} planı için ${plan.priceEnv} tanımlı değil`); + + const appUrl = process.env.APP_URL || 'https://ihaleist.seymata.com'; + const body = formEncode({ + mode: 'subscription', + 'line_items[0][price]': price, + 'line_items[0][quantity]': 1, + success_url: `${appUrl}/?odeme=basarili&plan=${planId}`, + cancel_url: `${appUrl}/?odeme=iptal`, + customer_email: email, + allow_promotion_codes: 'true', + 'metadata[planId]': planId, + 'metadata[email]': email || '', + }); + + const res = await fetch('https://api.stripe.com/v1/checkout/sessions', { + method: 'POST', + headers: { + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error?.message || `Stripe hatası: HTTP ${res.status}`); + return { url: data.url, id: data.id }; +} diff --git a/server/ekap-scan.mjs b/server/ekap-scan.mjs new file mode 100644 index 0000000..9031907 --- /dev/null +++ b/server/ekap-scan.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * EKAP geçmiş sonuç tarayıcısı — kazanan firma + sözleşme bedeli + teklif sayısı biriktirir. + * + * Kullanım: + * node server/ekap-scan.mjs --gun 7 # son 7 günün sonuçlananları + * node server/ekap-scan.mjs --bas 2026-01-01 --son 2026-06-30 + * node server/ekap-scan.mjs --gun 1 --limit 200 # cron için (günlük artımlı) + * + * Notlar: + * - EKAP arama indeksi sonuç ilanlarını kapsamaz; bu yüzden sonuçlanmış ihaleler + * sayfalanır ve HER ihalenin ilanları ayrıca çekilir (N+1 kaçınılmaz). + * - Eşzamanlılık 6 (repo referansı 8) — EKAP'ı yormamak için. + * - Zaten kayıtlı IKN'ler atlanır, böylece tekrar çalıştırmak ucuzdur. + */ + +import { + searchTenders, fetchResults, normalizeCompanyName, DURUM_SONUC_ILANI, +} from './ekap-v2.mjs'; +import { openDb, saveResults, hasIkn, logScan, available } from './history-store.mjs'; + +const args = process.argv.slice(2); +const arg = (n, d = null) => { + const i = args.indexOf(`--${n}`); + return i >= 0 && args[i + 1] ? args[i + 1] : d; +}; +const has = (n) => args.includes(`--${n}`); + +const iso = (d) => d.toISOString().slice(0, 10); + +const gun = Number(arg('gun', 0)); +let bas = arg('bas'); +let son = arg('son'); +if (!bas || !son) { + const bugun = new Date(); + const geri = new Date(bugun.getTime() - (gun || 7) * 86400000); + bas = bas || iso(geri); + son = son || iso(bugun); +} +const LIMIT = Number(arg('limit', 500)); +const SAYFA = 50; +const ESZAMANLI = Number(arg('paralel', 6)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function havuz(items, worker, n) { + const sonuc = []; + let i = 0; + const calisanlar = Array.from({ length: Math.min(n, items.length) }, async () => { + while (i < items.length) { + const idx = i++; + try { sonuc.push(await worker(items[idx])); } + catch (e) { sonuc.push({ hata: e.message }); } + } + }); + await Promise.all(calisanlar); + return sonuc; +} + +async function main() { + if (!available()) { + console.error('[ekap-scan] node:sqlite yok — bu Node sürümünde tarama yapılamaz.'); + process.exit(1); + } + const db = openDb({ create: true }); + const baslangic = new Date().toISOString(); + 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 toplamHavuz = null; + + while (taranan < LIMIT) { + let sayfa; + try { + sayfa = await searchTenders({ + ihaleDurumIdList: [DURUM_SONUC_ILANI], + ihaleTarihBaslangic: bas, + ihaleTarihBitis: son, + orderBy: 'ihaleTarihi', + siralamaTipi: 'desc', + skip, + take: Math.min(SAYFA, LIMIT - taranan), + }); + } catch (e) { + console.error('[ekap-scan] arama hatası:', e.message); + hata++; + break; + } + if (toplamHavuz == null) { + toplamHavuz = sayfa.totalCount; + console.log(`[ekap-scan] aralıkta ${toplamHavuz} sonuçlanmış ihale bulundu`); + } + if (!sayfa.list.length) break; + + // Zaten kayıtlı olanları atla (IKN kalıcı anahtar) + const yeni = sayfa.list.filter((t) => !hasIkn(db, t.ikn)); + const atlanan = sayfa.list.length - yeni.length; + + const sonuclar = await havuz(yeni, async (t) => { + const rs = await fetchResults(t.id); + if (!rs.length) return { bos: true }; + for (const r of rs) r.kazananNorm = normalizeCompanyName(r.kazananFirma); + const n = saveResults(db, t, rs); + return { kayit: n, ikn: t.ikn, firma: rs[0].kazananFirma }; + }, ESZAMANLI); + + for (const s of sonuclar) { + if (s?.hata) hata++; + else if (s?.kayit) eklenen += s.kayit; + } + 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`); + + if (skip >= toplamHavuz) break; + await sleep(300); // EKAP'ı yormamak için nazik ara + } + + logScan(db, { + baslangic, bitis: new Date().toISOString(), + aralikBas: bas, aralikSon: son, taranan, eklenen, hata, + not: `havuz=${toplamHavuz}`, + }); + db.close(); + console.log(`[ekap-scan] BİTTİ — ${taranan} ihale tarandı, ${eklenen} sonuç kaydı yazıldı, ${hata} hata.`); +} + +main().catch((e) => { console.error('[ekap-scan] ölümcül hata:', e); process.exit(1); }); diff --git a/server/ekap-v2.mjs b/server/ekap-v2.mjs new file mode 100644 index 0000000..6e9573e --- /dev/null +++ b/server/ekap-v2.mjs @@ -0,0 +1,235 @@ +/** + * EKAP v2 API istemcisi — ihale arama + sonuç ilanı (kazanan firma, sözleşme bedeli). + * Zero-dependency: node:crypto + built-in fetch. + * + * Güvenlik başlıkları AES-256-CBC ile üretilir. Anahtar EKAP tarafından + * dönemsel olarak değiştirilir; değişirse EKAP_R8FACT_KEY env'i ile geçilebilir. + * (Yeni anahtar ekapv2.kik.gov.tr/ekap/search konsolunda environment.r8fact içinde.) + * + * Sunucu hata kodları: 1200 = başlık eksik, 1202 = timestamp penceresi dışı (±30sn), + * 1204 = guid uyuşmuyor. + */ + +import crypto from 'node:crypto'; + +const BASE = 'https://ekapv2.kik.gov.tr'; +const DEFAULT_KEY = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g'; + +function aesKey() { + return Buffer.from(process.env.EKAP_R8FACT_KEY || DEFAULT_KEY, 'utf8'); +} + +function aesB64(plain, iv) { + const c = crypto.createCipheriv('aes-256-cbc', aesKey(), iv); + return Buffer.concat([c.update(plain, 'utf8'), c.final()]).toString('base64'); +} + +// Her istekte yeniden üretilmeli (timestamp penceresi ±30 sn) +export function securityHeaders() { + const guid = crypto.randomUUID(); + const iv = crypto.randomBytes(16); // Sec-1 ve Sec-4 AYNI IV'yi kullanmalı + const ts = String(Date.now()); // milisaniye zorunlu + return { + 'X-Ekap-Sec-1': aesB64(guid, iv), + 'X-Ekap-Sec-2': iv.toString('base64'), + 'X-Ekap-Sec-3': guid, + 'X-Ekap-Sec-4': aesB64(ts, iv), + }; +} + +const BASE_HEADERS = { + Accept: 'application/json', + 'Accept-Language': 'tr', + 'Content-Type': 'application/json', + Origin: BASE, + Referer: `${BASE}/ekap/search`, + 'User-Agent': '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', + 'api-version': 'v1', +}; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export async function ekapPost(path, body, { retries = 2, timeout = 30000 } = {}) { + let lastErr; + for (let i = 0; i <= retries; i++) { + try { + const res = await fetch(BASE + path, { + method: 'POST', + headers: { ...BASE_HEADERS, ...securityHeaders() }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + const raw = await res.text(); + if (!res.ok) { + // 401/1202 = timestamp kayması → yeni başlıkla tekrar dene + if ((res.status === 401 || res.status >= 500) && i < retries) { + await sleep(500 * (i + 1)); + continue; + } + throw new Error(`EKAP ${res.status}: ${raw.slice(0, 200)}`); + } + return JSON.parse(raw); + } catch (e) { + lastErr = e; + if (i >= retries) break; + await sleep(500 * (i + 1)); + } + } + throw lastErr; +} + +// --- İhale arama --------------------------------------------------------- + +// EKAP ihale durumu: 15 = Sonuç İlanı Yayımlanmış +export const DURUM_SONUC_ILANI = 15; +// İlan tipi: 1=Ön İlan 2=İhale İlanı 3=İptal 4=Sonuç İlanı 5=Ön Yeterlik 6=Düzeltme +export const ILAN_TIP_SONUC = '4'; + +function searchBody(opts = {}) { + return { + searchText: opts.searchText || '', + filterType: null, + ikNdeAra: true, ihaleAdindaAra: true, ihaleIlanindaAra: true, + teknikSartnamedeAra: true, idariSartnamedeAra: true, benzerIsMaddesindeAra: true, + isinYapilacagiYerMaddesindeAra: true, nitelikTurMiktarMaddesindeAra: true, + ihaleBilgilerindeAra: true, sozlesmeTasarisindaAra: true, teklifCetvelindeAra: true, + searchType: 'GirdigimGibi', + iknYili: opts.iknYili ?? null, + iknSayi: opts.iknSayi ?? null, + ihaleTarihSaatBaslangic: opts.ihaleTarihBaslangic ?? null, // "YYYY-MM-DD" + ihaleTarihSaatBitis: opts.ihaleTarihBitis ?? null, + ilanTarihSaatBaslangic: opts.ilanTarihBaslangic ?? null, + ilanTarihSaatBitis: opts.ilanTarihBitis ?? null, + yasaKapsami4734List: [], + ihaleTuruIdList: opts.ihaleTuruIdList || [], + ihaleUsulIdList: [], ihaleUsulAltIdList: [], + ihaleIlIdList: opts.ihaleIlIdList || [], // EKAP il id (245-325) — PLAKA DEĞİL + ihaleDurumIdList: opts.ihaleDurumIdList || [], + idareIdList: opts.idareIdList || [], + ihaleIlanTuruIdList: opts.ihaleIlanTuruIdList || [], + teklifTuruIdList: [], asiriDusukTeklifIdList: [], istisnaMaddeIdList: [], + okasBransKodList: opts.okasBransKodList || [], + okasBransAdiList: [], titubbKodList: [], gmdnKodList: [], + eIhale: null, eEksiltmeYapilacakMi: null, ortakAlimMi: null, kismiTeklifMi: null, + fiyatDisiUnsurVarmi: null, + ekonomikVeMaliYeterlilikBelgeleriIsteniyorMu: null, + meslekiTeknikYeterlilikBelgeleriIsteniyorMu: null, + isDeneyimiGosterenBelgelerIsteniyorMu: null, + yerliIstekliyeFiyatAvantajiUgulaniyorMu: null, + yabanciIsteklilereIzinVeriliyorMu: null, + alternatifTeklifVerilebilirMi: null, konsorsiyumKatilabilirMi: null, + altYukleniciCalistirilabilirMi: null, fiyatFarkiVerilecekMi: null, + avansVerilecekMi: null, cerceveAnlasmaMi: null, + personelCalistirilmasinaDayaliMi: null, + orderBy: opts.orderBy || 'ihaleTarihi', + siralamaTipi: opts.siralamaTipi || 'desc', + paginationSkip: opts.skip ?? 0, + paginationTake: opts.take ?? 20, + }; +} + +export async function searchTenders(opts = {}) { + const data = await ekapPost('/b_ihalearama/api/Ihale/GetListByParameters', searchBody(opts)); + return { list: data.list || [], totalCount: data.totalCount ?? 0 }; +} + +// NOT: dönen `id` per-response şifreli token — kalıcı değil, aynı oturumda kullan. +export async function getIlanlar(ihaleId) { + const data = await ekapPost('/b_ihalearama/api/Ilan/GetList', { ihaleId }); + return data.list || []; +} + +export async function getIhaleDetay(ihaleId) { + const data = await ekapPost('/b_ihalearama/api/IhaleDetay/GetByIhaleIdIhaleDetay', { ihaleId }); + return data.item || data; +} + +// --- Sonuç ilanı parse --------------------------------------------------- + +const ENTITIES = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" }; + +// EKAP'ın veriHtml tablosunu, Python tarafındaki markdown ile aynı "| hücre |" +// biçimine indirger; böylece aynı regex'ler çalışır. +export function htmlToPipeText(html) { + if (!html) return ''; + return String(html) + .replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, ' ') + .replace(/<\s*\/\s*t[dh]\s*>/gi, ' | ') + .replace(/<\s*\/\s*tr\s*>/gi, ' |\n') + .replace(/<\s*br\s*\/?\s*>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ') + .replace(/[ \t]+/g, ' '); +} + +function cleanCell(v) { + if (v == null) return null; + const t = String(v).replace(/-->/g, ' ').replace(/\*\*/g, ' ').replace(/\s+/g, ' ').trim(); + return t || null; +} + +function findField(text, labelPattern) { + if (!text) return null; + const re = new RegExp(`${labelPattern}\\s*\\**\\s*\\|\\s*\\**:?\\**\\s*\\|([^|\\n]*)`, 'i'); + const m = re.exec(text); + return m ? cleanCell(m[1]) : null; +} + +function parseAmount(v) { + if (!v) return null; + const m = /([\d.]+,\d+|\d+)/.exec(v); + if (!m) return null; + const n = parseFloat(m[1].replace(/\./g, '').replace(',', '.')); + return Number.isNaN(n) ? null : n; +} + +function parseIntSafe(v) { + if (!v) return null; + const m = /\d+/.exec(v); + return m ? parseInt(m[0], 10) : null; +} + +/** Sonuç ilanı HTML'inden kazanan firma / bedel / teklif sayısı çıkarır. */ +export function parseResultAnnouncement(veriHtml) { + const text = htmlToPipeText(veriHtml); + const amountRaw = findField(text, 'Bedeli'); + const cur = amountRaw ? (/\b([A-Z]{3})\b/.exec(amountRaw)?.[1] ?? null) : null; + return { + ikn: findField(text, 'İhale kayıt numarası'), + kazananFirma: findField(text, 'Yüklenici(?:si)?'), + kazananUyruk: findField(text, 'Yüklenicinin uyruğu'), + kazananAdres: findField(text, 'Yüklenicinin adresi'), + sozlesmeBedeli: parseAmount(amountRaw), + paraBirimi: cur, + sozlesmeTarihi: findField(text, '4- Sözleşmenin[\\s\\S]{0,200}?Tarihi'), + sozlesmeSuresi: findField(text, '4- Sözleşmenin[\\s\\S]{0,400}?Süresi'), + yaklasikMaliyet: parseAmount(findField(text, '(? TR_FOLD[c] || c) + .toUpperCase() + .replace(/[^0-9A-Z]+/g, ' ') + .split(/\s+/) + .filter((t) => t && !SUFFIX.has(t)) + .join(' '); +} + +/** Bir ihalenin sonuç ilan(lar)ını çeker ve parse eder (kısmi teklifte birden çok olur). */ +export async function fetchResults(ihaleId) { + const ilanlar = await getIlanlar(ihaleId); + return ilanlar + .filter((i) => String(i.ilanTip) === ILAN_TIP_SONUC) + .map((i, idx) => ({ ...parseResultAnnouncement(i.veriHtml), ilanTarihi: i.ilanTarihi, kisimIndex: idx })) + .filter((r) => r.kazananFirma || r.sozlesmeBedeli != null); +} diff --git a/server/history-store.mjs b/server/history-store.mjs new file mode 100644 index 0000000..0121ac2 --- /dev/null +++ b/server/history-store.mjs @@ -0,0 +1,176 @@ +/** + * Geçmiş ihale sonuçları deposu (SQLite). + * ekap-scan.mjs doldurur; server/index.js okur. + * + * Kalıcı anahtar: IKN (EKAP'ın döndürdüğü `id` per-response token'dır, saklanmaz). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +let DatabaseSync = null; +try { + ({ DatabaseSync } = await import('node:sqlite')); +} catch { + console.warn('[history-store] node:sqlite yok — geçmiş veri deposu devre dışı'); +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'history.sqlite'); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS sonuclar ( + ikn TEXT NOT NULL, + kisim INTEGER NOT NULL DEFAULT 0, + ihale_adi TEXT, + idare_adi TEXT, + il TEXT, + ihale_tipi TEXT, + ihale_tarihi TEXT, + ilan_tarihi TEXT, + kazanan_firma TEXT, + kazanan_norm TEXT, + kazanan_adres TEXT, + sozlesme_bedeli REAL, + para_birimi TEXT, + sozlesme_tarihi TEXT, + yaklasik_maliyet REAL, + teklif_sayisi INTEGER, + gecerli_teklif_sayisi INTEGER, + cekilme_zamani TEXT, + PRIMARY KEY (ikn, kisim) +); +CREATE INDEX IF NOT EXISTS idx_kazanan ON sonuclar(kazanan_norm); +CREATE INDEX IF NOT EXISTS idx_idare ON sonuclar(idare_adi); +CREATE INDEX IF NOT EXISTS idx_il ON sonuclar(il); +CREATE INDEX IF NOT EXISTS idx_tarih ON sonuclar(ihale_tarihi); + +CREATE TABLE IF NOT EXISTS tarama_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + baslangic TEXT, bitis TEXT, + aralik_bas TEXT, aralik_son TEXT, + taranan INTEGER, eklenen INTEGER, hata INTEGER, not_ TEXT +); +`; + +export function available() { + return Boolean(DatabaseSync); +} + +export function openDb({ create = false } = {}) { + if (!DatabaseSync) return null; + if (!fs.existsSync(DB_PATH)) { + if (!create) return null; + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + const db = new DatabaseSync(DB_PATH); + db.exec(SCHEMA); + return db; +} + +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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + 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, + cekilme_zamani=excluded.cekilme_zamani + `); + const now = new Date().toISOString(); + let n = 0; + for (const r of results) { + stmt.run( + r.ikn || tender.ikn, r.kisimIndex ?? 0, + tender.ihaleAdi || null, tender.idareAdi || null, tender.ihaleIlAdi || null, + tender.ihaleTipAciklama || null, tender.ihaleTarihSaat || null, r.ilanTarihi || null, + 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, + now + ); + n++; + } + return n; +} + +export function hasIkn(db, ikn) { + const row = db.prepare('SELECT 1 FROM sonuclar WHERE ikn=? LIMIT 1').get(ikn); + return Boolean(row); +} + +// --- Okuma tarafı (API için) -------------------------------------------- + +function rowToTender(r) { + return { + id: `hist-${r.ikn}-${r.kisim}`, + kaynak: 'EKAP Sonuç İlanı', + ikn: r.ikn, + baslik: r.ihale_adi || 'İhale', + kurum: r.idare_adi || '—', + sehir: r.il || '', + tarih: r.ihale_tarihi || '', + alimTuru: r.ihale_tipi || 'İhale', + sektor: r.ihale_tipi || 'Genel', + kazananFirma: r.kazanan_firma, + sozlesmeBedeli: r.sozlesme_bedeli, + paraBirimi: r.para_birimi, + sozlesmeTarihi: r.sozlesme_tarihi, + yaklasikMaliyet: r.yaklasik_maliyet, + teklifSayisi: r.teklif_sayisi, + gecerliTeklifSayisi: r.gecerli_teklif_sayisi, + durum: 'Sonuçlandı', + items: [], + }; +} + +export function readHistory(limit = 500, { firma, idare, il } = {}) { + const db = openDb(); + if (!db) return { tenders: [], totalCount: 0, available: false }; + try { + const where = []; + const args = []; + if (firma) { where.push('kazanan_norm LIKE ?'); args.push(`%${firma}%`); } + if (idare) { where.push('idare_adi LIKE ?'); args.push(`%${idare}%`); } + if (il) { where.push('il LIKE ?'); args.push(`%${il}%`); } + const w = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const total = db.prepare(`SELECT COUNT(*) c FROM sonuclar ${w}`).all(...args)[0]?.c ?? 0; + const rows = db.prepare(`SELECT * FROM sonuclar ${w} ORDER BY cekilme_zamani DESC LIMIT ?`).all(...args, limit); + return { tenders: rows.map(rowToTender), totalCount: total, available: true }; + } catch (e) { + console.warn('[history-store] okuma hatası:', e.message); + return { tenders: [], totalCount: 0, available: false }; + } finally { + db.close(); + } +} + +export function historyStats() { + const db = openDb(); + if (!db) return { available: false }; + try { + const c = (q) => db.prepare(q).all()[0] || {}; + const toplam = c('SELECT COUNT(*) v FROM sonuclar').v ?? 0; + const firma = c('SELECT COUNT(DISTINCT kazanan_norm) v FROM sonuclar WHERE kazanan_norm IS NOT NULL').v ?? 0; + const idare = c('SELECT COUNT(DISTINCT idare_adi) v FROM sonuclar').v ?? 0; + const hacim = c('SELECT SUM(sozlesme_bedeli) v FROM sonuclar').v ?? 0; + const tekTeklif = c('SELECT COUNT(*) v FROM sonuclar WHERE gecerli_teklif_sayisi=1').v ?? 0; + const sonTarama = db.prepare('SELECT * FROM tarama_log ORDER BY id DESC LIMIT 1').all()[0] || null; + return { available: true, toplam, firma, idare, hacim, tekTeklif, sonTarama }; + } catch { + return { available: false }; + } finally { + db.close(); + } +} + +export function logScan(db, row) { + db.prepare(`INSERT INTO tarama_log (baslangic,bitis,aralik_bas,aralik_son,taranan,eklenen,hata,not_) + VALUES (?,?,?,?,?,?,?,?)`) + .run(row.baslangic, row.bitis, row.aralikBas, row.aralikSon, row.taranan, row.eklenen, row.hata, row.not || null); +} diff --git a/server/index.js b/server/index.js index d9df838..b1daed5 100644 --- a/server/index.js +++ b/server/index.js @@ -18,6 +18,12 @@ import { fileURLToPath } from 'node:url'; import { readDtTenders } from './dt-store.mjs'; import { loginWithGoogle, userFromAuthHeader } from './auth.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs'; +import { aiConfigured, availableProviders, analyzeSpec } from './ai.mjs'; +import { billingConfigured, planCatalog, createCheckoutSession } from './billing.mjs'; +import { searchTed } from './ted.mjs'; +import { readHistory, historyStats } from './history-store.mjs'; +import { startScheduler } from './scheduler.mjs'; +import { searchKik, kararMetni } from './kik.mjs'; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. @@ -337,6 +343,111 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { success: true, user }); } + // --- KİK kurul kararları --- + if (url.pathname === '/api/kik/search') { + try { + const q = url.searchParams.get('q') || ''; + const out = await searchKik( + { + metin: url.searchParams.get('metin') || (url.searchParams.get('alan') ? null : q), + idare: url.searchParams.get('idare'), + basvuran: url.searchParams.get('basvuran'), + kararNo: url.searchParams.get('kararNo'), + yil: url.searchParams.get('yil'), + }, + url.searchParams.get('tur') || 'uyusmazlik', + Number(url.searchParams.get('limit')) || 25 + ); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + if (url.pathname === '/api/kik/karar') { + try { + const id = url.searchParams.get('id'); + if (!id) return json(res, 400, { success: false, error: 'id gerekli' }); + const metin = await kararMetni(id); + return json(res, 200, { success: true, id, metin }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + + // --- Geçmiş sonuç ilanları (kazanan firma arşivi) --- + if (url.pathname === '/api/history') { + const out = readHistory(Number(url.searchParams.get('limit')) || 300, { + firma: url.searchParams.get('firma'), + idare: url.searchParams.get('idare'), + il: url.searchParams.get('il'), + }); + return json(res, 200, { success: true, ...out }); + } + if (url.pathname === '/api/history/stats') { + return json(res, 200, { success: true, ...historyStats() }); + } + + // --- TED (AB ihaleleri) --- + if (url.pathname === '/api/ted') { + try { + const out = await searchTed({ + mod: url.searchParams.get('mod') || 'turkiye', + since: url.searchParams.get('since') || '20240101', + page: Number(url.searchParams.get('page')) || 1, + limit: Number(url.searchParams.get('limit')) || 25, + }); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, 502, { success: false, error: e.message }); + } + } + + // --- Abonelik / ödeme --- + if (url.pathname === '/api/billing/plans') { + return json(res, 200, { configured: billingConfigured(), plans: planCatalog() }); + } + if (url.pathname === '/api/billing/checkout' && req.method === 'POST') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + try { + const body = await readBody(req); + const out = await createCheckoutSession({ planId: body.planId, email: user.email }); + return json(res, 200, { success: true, ...out }); + } catch (e) { + return json(res, billingConfigured() ? 400 : 503, { success: false, error: e.message }); + } + } + + // --- Gerçek AI şartname analizi --- + if (url.pathname === '/api/ai/config') { + return json(res, 200, { configured: aiConfigured(), providers: availableProviders() }); + } + if (url.pathname === '/api/ai/analyze' && req.method === 'POST') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + if (!aiConfigured()) { + return json(res, 503, { + success: false, + error: 'AI yapılandırılmamış — sunucuya en az bir sağlayıcı anahtarı ekleyin (ZAI_API_KEY / DEEPSEEK_API_KEY / GEMINI_API_KEY …)', + }); + } + try { + const body = await readBody(req, 2 * 1024 * 1024); + if (!body.text || String(body.text).trim().length < 40) { + return json(res, 400, { success: false, error: 'Analiz için yeterli metin yok (en az 40 karakter).' }); + } + const out = await analyzeSpec({ + text: body.text, + fileName: body.fileName, + tender: body.tender, + chain: body.chain, + }); + return json(res, 200, { success: true, ...out, analyzedAt: new Date().toISOString() }); + } catch (e) { + return json(res, 502, { success: false, error: e.message, attempts: e.attempts || null }); + } + } + // --- E-posta bildirimi (Resend) --- if (url.pathname === '/api/notify/email/config') { return json(res, 200, { configured: emailConfigured() }); @@ -370,8 +481,16 @@ const server = http.createServer(async (req, res) => { } catch (e) { console.warn('[ihaleist-api] DT okuma hatası:', e.message); } + // Geçmiş sonuç ilanları (kazanan firma + bedel + teklif sayısı) + let hist = { tenders: [], totalCount: 0, available: false }; + try { + hist = readHistory(600); + } catch (e) { + console.warn('[ihaleist-api] geçmiş okuma hatası:', e.message); + } const sources = ['ilan.gov.tr (Basın İlan Kurumu) — resmi ihale ilanları']; if (dt.available) sources.push('EKAP Doğrudan Temin (22/d) worker'); + if (hist.available && hist.totalCount) sources.push('EKAP Sonuç İlanları (geçmiş sonuçlar)'); return json(res, 200, { success: true, source: sources.join(' + '), @@ -379,7 +498,8 @@ const server = http.createServer(async (req, res) => { totalCount: c.totalCount + dt.totalCount, todayCount: c.todayCount, dtCount: dt.totalCount, - tenders: [...dt.tenders, ...c.tenders], + historyCount: hist.totalCount, + tenders: [...dt.tenders, ...c.tenders, ...hist.tenders], }); } @@ -428,4 +548,5 @@ const server = http.createServer(async (req, res) => { server.listen(PORT, () => { console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`); getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message)); + startScheduler(); }); diff --git a/server/kik.mjs b/server/kik.mjs new file mode 100644 index 0000000..7f5fdc7 --- /dev/null +++ b/server/kik.mjs @@ -0,0 +1,206 @@ +/** + * KİK (Kamu İhale Kurumu) kurul kararları — EKAP v2 JSON API. + * Zero-dependency: node:crypto + built-in fetch. + * + * Endpoint'ler (anonim, login yok): + * POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlari (uyuşmazlık) + * POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk (düzenleyici) + * POST /b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk (mahkeme) + * + * Güvenlik: X-Ekap-Sec-1..6 (AES-256-CBC). Sec-5 = HTTP metodu, Sec-6 = URL path. + * Başlıklar HER istekte yeniden üretilmeli (GUID nonce + timestamp penceresi). + * + * Karar tam metni: gundemMaddesiId AYRI bir AES anahtarıyla şifrelenip + * KararGoster.aspx'e query param olarak verilir (HTML döner). + * + * ⚠ Her iki anahtar da KİK frontend'inde sabittir; değişirse 401/HataKodu 1200 + * alınır. Env ile geçilebilir: EKAP_R8FACT_KEY, EKAP_RAW_KEY (hex). + */ + +import crypto from 'node:crypto'; + +const ORIGIN = 'https://ekapv2.kik.gov.tr'; +const SIGN_KEY_DEFAULT = 'pfS7Xdn3YVkOzs3V79XUc91SD47mQD0g'; +const ID_KEY_DEFAULT = Buffer.from([ + 236, 193, 164, 43, 12, 135, 121, 170, 4, 244, 123, 219, 82, 158, 124, 174, + 174, 228, 219, 174, 208, 104, 174, 120, 32, 76, 250, 4, 143, 159, 211, 176, +]); + +const signKey = () => Buffer.from(process.env.EKAP_R8FACT_KEY || SIGN_KEY_DEFAULT, 'utf8'); +const idKey = () => (process.env.EKAP_RAW_KEY ? Buffer.from(process.env.EKAP_RAW_KEY, 'hex') : ID_KEY_DEFAULT); + +function secHeaders(method, pathname) { + const iv = crypto.randomBytes(16); + const key = signKey(); + const e = (t) => { + const c = crypto.createCipheriv('aes-256-cbc', key, iv); + return Buffer.concat([c.update(String(t), 'utf8'), c.final()]).toString('base64'); + }; + const g = crypto.randomUUID(); + return { + 'X-Ekap-Sec-1': e(g), + 'X-Ekap-Sec-2': iv.toString('base64'), + 'X-Ekap-Sec-3': g, + 'X-Ekap-Sec-4': e(Date.now()), + 'X-Ekap-Sec-5': e(method.toUpperCase()), + 'X-Ekap-Sec-6': e(pathname), + }; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function kikPost(pathname, body, { retries = 2 } = {}) { + let last; + for (let i = 0; i <= retries; i++) { + try { + const r = await fetch(ORIGIN + pathname, { + method: 'POST', + headers: { + 'api-version': 'v1', + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Accept-Language': 'tr', + Origin: ORIGIN, + Referer: `${ORIGIN}/sorgulamalar/kurul-kararlari`, + 'User-Agent': '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', + ...secHeaders('POST', pathname), + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(40000), + }); + const raw = await r.text(); + if (!r.ok) { + if (i < retries && (r.status === 401 || r.status >= 500)) { await sleep(600 * (i + 1)); continue; } + throw new Error(`KİK sunucusu isteği reddetti (HTTP ${r.status}).`); + } + return JSON.parse(raw); + } catch (e) { + last = e; + if (i >= retries) break; + await sleep(600 * (i + 1)); + } + } + throw last; +} + +// Yanıt anahtarları uyuşmazlıkta camelCase, DK/MK'de PascalCase gelebilir +const pick = (o, ...names) => { + for (const n of names) if (o && o[n] != null) return o[n]; + return undefined; +}; + +const TURLER = { + uyusmazlik: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlari', etiket: 'Uyuşmazlık' }, + duzenleyici: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariDk', etiket: 'Düzenleyici' }, + mahkeme: { path: '/b_ihalearaclari/api/KurulKararlari/GetKurulKararlariMk', etiket: 'Mahkeme' }, +}; + +/** Kararın tam metnini gösteren kalıcı URL (gundemMaddesiId şifrelenir). */ +export function kararUrl(gundemMaddesiId) { + const iv = crypto.randomBytes(16); + const c = crypto.createCipheriv('aes-256-cbc', idKey(), iv); + const id = iv.toString('hex') + Buffer.concat([c.update(String(gundemMaddesiId), 'utf8'), c.final()]).toString('hex'); + return `https://ekap.kik.gov.tr/EKAP/Vatandas/KurulKararGoster.aspx?KararId=${id}`; +} + +/** Kararın tam metnini düz metin olarak getirir. */ +export async function kararMetni(gundemMaddesiId) { + const res = await fetch(kararUrl(gundemMaddesiId), { + headers: { 'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'tr' }, + signal: AbortSignal.timeout(40000), + }); + if (!res.ok) throw new Error(`Karar metni alınamadı (HTTP ${res.status})`); + const html = await res.text(); + return html + .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ').replace(/"/g, '"').replace(/&/g, '&') + .replace(/×/g, ' ').replace(/</g, '<').replace(/>/g, '>') + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&[a-z]+;/gi, ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\s*\n\s*/g, '\n') + .trim(); +} + +const cache = new Map(); +const TTL = 20 * 60 * 1000; + +/** + * Kurul kararı araması. + * @param {object} f { metin, kararNo, idare, basvuran, konu, yil, tarih1, tarih2, sonKararlar } + * @param {'uyusmazlik'|'duzenleyici'|'mahkeme'} tur + */ +export async function searchKik(f = {}, tur = 'uyusmazlik', limit = 25) { + const cfg = TURLER[tur] || TURLER.uyusmazlik; + + const filters = {}; + if (f.metin) filters.KararMetni = f.metin; + if (f.kararNo) filters.KararNo = f.kararNo; + if (f.idare) filters.IhaleyiYapanIdare = f.idare; + if (f.basvuran) filters.Sikayetci = f.basvuran; + if (f.konu) filters.BasvuruKonusuIhale = f.konu; + if (f.yil) filters.Yil = f.yil; + if (f.tarih1) filters.KararTarihi1 = f.tarih1; // "dd.MM.yyyy HH:mm:ss" + if (f.tarih2) filters.KararTarihi2 = f.tarih2; + if (!Object.keys(filters).length) filters.SonKararlar = ''; + + const key = `${cfg.path}|${JSON.stringify(filters)}|${limit}`; + const hit = cache.get(key); + if (hit && Date.now() - hit.at < TTL) return hit.data; + + const j = await kikPost(cfg.path, { + sorgulaKurulKararlari: { + keyValuePairs: { + keyValueOfstringanyType: Object.entries(filters).map(([k, v]) => ({ key: k, value: String(v) })), + }, + }, + }); + + const wrap = pick(j, 'SorgulaKurulKararlariResponse', 'sorgulaKurulKararlariResponse') || {}; + const res = pick(wrap, 'SorgulaKurulKararlariResult', 'sorgulaKurulKararlariResult') || {}; + const hataKodu = String(pick(res, 'hataKodu', 'HataKodu') ?? '0'); + + // 4401 = kayıt bulunamadı (hata değil) + if (hataKodu === '4401') { + const bos = { results: [], totalCount: 0, tur: cfg.etiket, kaynak: 'KİK — EKAP Kurul Kararları', fetchedAt: new Date().toISOString() }; + cache.set(key, { at: Date.now(), data: bos }); + return bos; + } + if (hataKodu !== '0') { + throw new Error(`KİK: ${pick(res, 'hataMesaji', 'HataMesaji') || `hata kodu ${hataKodu}`}`); + } + + const liste = pick(res, 'KurulKararTutanakDetayListesi', 'kurulKararTutanakDetayListesi') || []; + const kayitlar = pick(liste[0] || {}, 'kurulKararTutanakDetayi', 'KurulKararTutanakDetayi') || []; + + const results = (Array.isArray(kayitlar) ? kayitlar : [kayitlar]).slice(0, limit).map((k, i) => { + const gid = pick(k, 'gundemMaddesiId', 'GundemMaddesiId'); + const kararNo = pick(k, 'kararNo', 'KararNo'); + const tarih = pick(k, 'kararTarihi', 'KararTarihi'); + return { + id: String(gid ?? kararNo ?? i), + gundemMaddesiId: gid ?? null, + kararNo: kararNo ?? null, + kararTarihi: tarih ? String(tarih).slice(0, 10).split('-').reverse().join('.') : null, + idare: pick(k, 'idareAdi', 'IdareAdi') ?? null, + basvuran: pick(k, 'basvuran', 'Basvuran') ?? null, + baslik: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null, + konu: pick(k, 'basvuruKonusu', 'BasvuruKonusu') ?? null, + ozet: pick(k, 'kararNitelik', 'KararNitelik') ?? null, + resmiGazete: pick(k, 'resmiGazeteSayisi', 'ResmiGazeteSayisi') ?? null, + tur: cfg.etiket, + link: gid != null ? kararUrl(gid) : null, + }; + }); + + const data = { + results, + totalCount: results.length, + tur: cfg.etiket, + kaynak: 'KİK — EKAP Kurul Kararları', + fetchedAt: new Date().toISOString(), + }; + cache.set(key, { at: Date.now(), data }); + return data; +} diff --git a/server/scheduler.mjs b/server/scheduler.mjs new file mode 100644 index 0000000..4b37eca --- /dev/null +++ b/server/scheduler.mjs @@ -0,0 +1,56 @@ +/** + * Sunucu içi zamanlayıcı — geçmiş sonuç ilanlarını periyodik olarak biriktirir. + * Container'da ayrı cron kurmaya gerek kalmaz; server/index.js başlatır. + * + * Env: + * SCAN_ENABLED=0 → kapat (varsayılan açık) + * SCAN_INTERVAL_SAAT=6 → tarama sıklığı (saat) + * SCAN_GUN=3 → her turda geriye kaç günü tara + * SCAN_LIMIT=300 → tur başına maksimum ihale + */ + +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let calisiyor = false; + +function tara() { + if (calisiyor) { + console.log('[scheduler] önceki tarama sürüyor, bu tur atlandı'); + return; + } + calisiyor = true; + const gun = process.env.SCAN_GUN || '3'; + const limit = process.env.SCAN_LIMIT || '300'; + console.log(`[scheduler] EKAP sonuç taraması başlıyor (son ${gun} gün, limit ${limit})`); + + const p = spawn(process.execPath, [path.join(__dirname, 'ekap-scan.mjs'), '--gun', gun, '--limit', limit], { + 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) => { + calisiyor = false; + console.log(`[scheduler] tarama bitti (çıkış kodu ${code})`); + }); + p.on('error', (e) => { + calisiyor = false; + console.warn('[scheduler] tarama başlatılamadı:', e.message); + }); +} + +export function startScheduler() { + if (process.env.SCAN_ENABLED === '0') { + console.log('[scheduler] devre dışı (SCAN_ENABLED=0)'); + return; + } + const saat = Number(process.env.SCAN_INTERVAL_SAAT || 6); + const ms = Math.max(1, saat) * 3600 * 1000; + console.log(`[scheduler] geçmiş sonuç taraması her ${saat} saatte bir çalışacak`); + // Açılıştan 2 dk sonra ilk tur (sunucu ısınsın), sonra periyodik + setTimeout(tara, 2 * 60 * 1000).unref?.(); + setInterval(tara, ms).unref?.(); +} diff --git a/server/ted.mjs b/server/ted.mjs new file mode 100644 index 0000000..54a3010 --- /dev/null +++ b/server/ted.mjs @@ -0,0 +1,154 @@ +/** + * TED (Tenders Electronic Daily) — AB kamu ihale ilanları, Türkiye odaklı. + * + * Endpoint: POST https://api.ted.europa.eu/v3/notices/search (anonim, API key YOK) + * Doğrulanmış davranış: + * - `fields` boş olamaz (400 verir) + * - limit max 250; page*limit max 15000; alan/sayfa max 10000 ("links" gizli alan da sayılır) + * - Rate limit agresif ve belgelenmemiş: ~1 istek/sn güvenli. 429 gövdesi JSON DEĞİL (nginx HTML) + * - Ülke kodları ISO alpha-3 (TUR); tarih formatı YYYYMMDD + * - Metin alanları çok dilli obje: { eng: [...], tur: [...] } + * + * Bağımlılık yok (Node 18+ fetch). + */ + +const TED_URL = 'https://api.ted.europa.eu/v3/notices/search'; + +const FIELDS = [ + 'publication-number', 'notice-title', 'notice-type', 'publication-date', + 'deadline-receipt-tender-date-lot', 'organisation-name-buyer', 'organisation-city-buyer', + 'buyer-country', 'winner-name', 'winner-country', + 'estimated-value-lot', 'estimated-value-cur-lot', + 'result-value-notice', 'result-value-cur-notice', + 'classification-cpv', 'contract-nature', 'description-lot', +]; + +// Türkiye evreni: TR idarelerin AB ihaleleri + AB'de kazanan TR firmaları + teklif veren TR firmaları +const Q_TURKIYE = '(buyer-country IN (TUR) OR winner-country IN (TUR) OR organisation-country-tenderer IN (TUR))'; +// TR firmalarının AB'de kazandıkları (rakip radar için) +const Q_KAZANAN = 'winner-country IN (TUR)'; + +const cache = new Map(); // key -> {at, data} +const TTL = 30 * 60 * 1000; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Çok dilli alan → tek string +function txt(v) { + if (v == null) return null; + if (typeof v === 'string') return v; + if (Array.isArray(v)) return txt(v[0]); + if (typeof v === 'object') return txt(v.eng ?? v.tur ?? Object.values(v)[0]); + return String(v); +} +const first = (v) => (Array.isArray(v) ? v[0] ?? null : v ?? null); +const uniq = (v) => (Array.isArray(v) ? [...new Set(v)] : v == null ? [] : [v]); +const day = (v) => { const s = first(v); return s ? String(s).slice(0, 10) : null; }; + +const NATURE_TR = { works: 'Yapım İşi', supplies: 'Mal Alımı', services: 'Hizmet Alımı' }; + +function mapNotice(n) { + const pn = n['publication-number']; + const bedel = n['result-value-notice'] ?? first(n['estimated-value-lot']); + const cur = n['result-value-cur-notice'] ?? first(n['estimated-value-cur-lot']); + const nature = first(n['contract-nature']); + return { + id: `ted-${pn}`, + kaynak: 'TED', + ikn: String(pn || ''), + baslik: txt(n['notice-title']) || 'Başlıksız ilan', + kurum: txt(n['organisation-name-buyer']) || '—', + sehir: first(n['organisation-city-buyer'])?.trim() || null, + ulke: first(n['buyer-country']) || null, + tarih: day(n['deadline-receipt-tender-date-lot']) || day(n['publication-date']), + yayimTarihi: day(n['publication-date']), + sonTeklifTarihi: day(n['deadline-receipt-tender-date-lot']), + yaklasikMaliyet: bedel != null && bedel !== '' ? Number(bedel) : null, + paraBirimi: cur || null, + kazananFirma: txt(n['winner-name']), + kazananUlke: first(n['winner-country']), + alimTuru: NATURE_TR[nature] || nature || 'AB İhalesi', + ihaleTuru: n['notice-type'], + sonucIlani: String(n['notice-type'] || '').startsWith('can'), + cpv: uniq(n['classification-cpv']), + aciklama: txt(n['description-lot']), + link: n.links?.html?.ENG || (pn ? `https://ted.europa.eu/en/notice/-/detail/${pn}` : null), + }; +} + +async function tedSearch(body, { retries = 3 } = {}) { + for (let i = 0; ; i++) { + let res; + try { + res = await fetch(TED_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(45000), + }); + } catch (e) { + if (i >= retries) throw new Error(`TED bağlantı hatası: ${e.message}`); + await sleep(1000 * 2 ** i); + continue; + } + // 429 gövdesi HTML gelir; her durumda önce text al + const raw = await res.text(); + if (res.status === 429 || res.status >= 500) { + if (i >= retries) throw new Error(`TED geçici olarak yanıt vermiyor (HTTP ${res.status}).`); + await sleep(1200 * 2 ** i); + continue; + } + if (!res.ok) { + let msg = raw.slice(0, 300); + try { const j = JSON.parse(raw); msg = j.message || msg; } catch { /* HTML yanıt */ } + throw new Error(`TED API: ${msg}`); + } + return JSON.parse(raw); + } +} + +/** + * @param {object} opts + * @param {'turkiye'|'kazanan'|'acik'} [opts.mod] + * @param {string} [opts.query] Ham expert query (verilirse mod yok sayılır) + * @param {string} [opts.since] YYYYMMDD + */ +export async function searchTed({ mod = 'turkiye', query, since = '20240101', page = 1, limit = 25 } = {}) { + let q; + let scope = 'ALL'; + if (query && query.trim()) { + q = query.trim(); + } else if (mod === 'kazanan') { + q = `${Q_KAZANAN} AND publication-date >= ${since} SORT BY publication-date DESC`; + } else if (mod === 'acik') { + q = `buyer-country IN (TUR) SORT BY publication-date DESC`; + scope = 'ACTIVE'; + } else { + q = `${Q_TURKIYE} AND publication-date >= ${since} SORT BY publication-date DESC`; + } + + const key = `${q}|${scope}|${limit}|${page}`; + const hit = cache.get(key); + if (hit && Date.now() - hit.at < TTL) return hit.data; + + const data = await tedSearch({ + query: q, + fields: FIELDS, + page, + limit: Math.min(limit, 250), + scope, + onlyLatestVersions: true, + paginationMode: 'PAGE_NUMBER', + }); + + const out = { + tenders: (data.notices || []).map(mapNotice), + totalCount: data.totalNoticeCount ?? (data.notices || []).length, + mod, + query: q, + timedOut: Boolean(data.timedOut), + fetchedAt: new Date().toISOString(), + }; + cache.set(key, { at: Date.now(), data: out }); + return out; +} diff --git a/src/App.jsx b/src/App.jsx index 089c2db..a068912 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -23,10 +23,15 @@ import { PriceAnalysis } from './components/tools/PriceAnalysis'; import { CompetitorRadar } from './components/tools/CompetitorRadar'; import { DocumentVault } from './components/tools/DocumentVault'; import { AnalyticsDashboard } from './components/tools/AnalyticsDashboard'; +import { RedFlags } from './components/tools/RedFlags'; +import { TedTenders } from './components/tools/TedTenders'; +import { Pricing } from './components/tools/Pricing'; +import { KikDecisions } from './components/tools/KikDecisions'; import { ApiWebhooks } from './components/tools/ApiWebhooks'; import { PwaSettings } from './components/tools/PwaSettings'; import { AiModelSettings } from './components/tools/AiModelSettings'; import { LoginPage } from './components/LoginPage'; +import { LandingPage } from './components/LandingPage'; const MainContent = () => { const { activeTab, isAdmin } = useApp(); @@ -57,6 +62,10 @@ const MainContent = () => { {activeTab === 'rakip_radar' && } {activeTab === 'dokuman_deposu' && } {activeTab === 'analitik_dashboard' && } + {activeTab === 'kirmizi_bayrak' && } + {activeTab === 'ted_eu' && } + {activeTab === 'kik_kararlari' && } + {activeTab === 'planlar' && } {activeTab === 'api_webhooks' && } {activeTab === 'pwa_settings' && } {activeTab === 'ai_ayarlari' && isAdmin && } @@ -72,6 +81,13 @@ const MainContent = () => { const Gate = () => { const { authReady, isAuthenticated, handleLogin } = useApp(); + const [showLogin, setShowLogin] = React.useState(() => window.location.hash === '#giris'); + + React.useEffect(() => { + const onHash = () => setShowLogin(window.location.hash === '#giris'); + window.addEventListener('hashchange', onHash); + return () => window.removeEventListener('hashchange', onHash); + }, []); if (!authReady) { return ( @@ -81,6 +97,9 @@ const Gate = () => { ); } if (!isAuthenticated) { + if (!showLogin) { + return { window.location.hash = '#giris'; setShowLogin(true); }} />; + } return ; } return ( diff --git a/src/components/AiSpecAnalyzerModal.jsx b/src/components/AiSpecAnalyzerModal.jsx index 98289f9..ccd7326 100644 --- a/src/components/AiSpecAnalyzerModal.jsx +++ b/src/components/AiSpecAnalyzerModal.jsx @@ -1,71 +1,92 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useApp } from '../context/AppContext'; -import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, ShieldCheck, DollarSign, X, Loader2, ArrowRight, TrendingUp, Sliders } from 'lucide-react'; +import { Sparkles, Upload, FileText, CheckCircle2, AlertTriangle, X, Loader2, TrendingUp, Sliders, Info } from 'lucide-react'; import confetti from 'canvas-confetti'; +import { getAiConfig } from '../services/ai'; + +const tl = (n) => (typeof n === 'number' && isFinite(n) ? '₺' + n.toLocaleString('tr-TR') : '—'); export const AiSpecAnalyzerModal = () => { - const { isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, analyzeSpecPdfWithGemini, aiAnalysisResults } = useApp(); + const { + isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen, + analyzeSpecPdfWithGemini, aiAnalysisResults, activeAiModel, activeTenderDetail, + } = useApp(); const [isAnalyzing, setIsAnalyzing] = useState(false); const [selectedFile, setSelectedFile] = useState(null); - const [customBidSlider, setCustomBidSlider] = useState(148500); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(''); + const [customBid, setCustomBid] = useState(null); + const [aiCfg, setAiCfg] = useState(null); + + useEffect(() => { + if (isAiSpecAnalyzerOpen && aiCfg === null) getAiConfig().then(setAiCfg); + }, [isAiSpecAnalyzerOpen, aiCfg]); if (!isAiSpecAnalyzerOpen) return null; - const handleUploadAndAnalyze = async (fileName = 'Teknik_Sartname_Kayseri_Cezaevi.pdf') => { + const results = aiAnalysisResults; + const bid = results?.bidAnalysis || {}; + + const runAnalysis = async (text, fileName) => { + setError(null); setIsAnalyzing(true); setSelectedFile(fileName); - await analyzeSpecPdfWithGemini(fileName); - setIsAnalyzing(false); - confetti({ particleCount: 80, spread: 70 }); - }; - - const results = aiAnalysisResults || { - fileName: 'Teknik_Sartname_Örnek.pdf', - model: 'Gemini 3.6 Flash', - analyzedAt: new Date().toLocaleString('tr-TR'), - summary: 'İşbu teknik şartname, idarenin ihtiyacı olan medikal sarf malzemeleri ve cihaz parçalarının teknik özellikleri, garanti şartları, teslimat vadeleri ve muayene kabul kriterlerini düzenlemektedir.', - qualificationCriteria: [ - 'TSE HYB Belgesi ve Yetkili Satıcı Satış Yetki Belgesi', - 'ISO 13485 Medikal Cihaz Kalite Yönetim Sertifikası', - 'ÜTS (Ürün Takip Sistemi) Barkod ve TİUBB Kaydı', - 'En az %50 Oranında Benzer İş Deneyim Belgesi (Kamu/Özel)', - 'Son 3 yıla ait Bilanço ve Gelir Tablosu oranları' - ], - criticalRequirements: [ - 'Ürünler biyo-uyumluluk (ISO 10993) standartlarına uygun olmalıdır.', - 'Teslim edilen paketlerin sterilizasyon son kullanma tarihi en az 18 ay olmalıdır.', - 'Arıza durumunda 24 saat içinde yerinde teknik servis desteği sağlanmalıdır.', - 'Teslimat aşamasında her parti için numune test raporu (CoA) ibraz edilmelidir.' - ], - risksAndPenalties: [ - { level: 'Yüksek', clause: 'Madde 14.2', text: 'Gecikilen her takvim günü için ihale bedelinin %0.5 oranında cezai şart uygulanacaktır.' }, - { level: 'Orta', clause: 'Madde 18.1', text: 'Numune kontrolünde standart dışı bulunan partiler 3 iş günü içinde muadili ile ücretsiz değiştirilmelidir.' }, - { level: 'Düşük', clause: 'Madde 22.4', text: 'Ambalaj üzerindeki Türkçe etiket eksikliğinde kabul işlemi durdurulacaktır.' } - ], - bidAnalysis: { - estimatedCost: 185000, - recommendedBid: 148500, - minBidLimit: 132000, - maxBidLimit: 165000, - winProbability: 89, - competitionLevel: 'Orta Yoğunlukta (3-4 Firma Katılımı Bekleniyor)', - historicalDiscountAvg: '%19.7 EKAP İndirim Oranı' + try { + setProgress('Model analiz ediyor…'); + const r = await analyzeSpecPdfWithGemini({ text, fileName, tender: activeTenderDetail }); + if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid); + confetti({ particleCount: 80, spread: 70 }); + } catch (e) { + setError(e.message); + } finally { + setIsAnalyzing(false); + setProgress(''); } }; - // Win probability calculation based on custom bid slider - const calcProbability = (bid) => { - const rec = results.bidAnalysis.recommendedBid; - const diff = Math.abs(bid - rec); - const prob = Math.max(35, Math.min(96, Math.round(92 - (diff / 3000) * 3))); - return prob; + const handleFile = async (file) => { + setError(null); + setIsAnalyzing(true); + setSelectedFile(file.name); + try { + setProgress('Belge metni çıkarılıyor…'); + const { extractText } = await import('../services/pdfText'); + const { text, usedPages, pageCount } = await extractText(file); + if (!text || text.length < 40) { + throw new Error('Belgeden metin çıkarılamadı. Taranmış (görüntü) PDF olabilir — metin tabanlı bir dosya deneyin.'); + } + setProgress(`${usedPages}/${pageCount} sayfa okundu, model analiz ediyor…`); + const r = await analyzeSpecPdfWithGemini({ text, fileName: file.name, tender: activeTenderDetail }); + if (typeof r?.bidAnalysis?.recommendedBid === 'number') setCustomBid(r.bidAnalysis.recommendedBid); + confetti({ particleCount: 80, spread: 70 }); + } catch (e) { + setError(e.message); + } finally { + setIsAnalyzing(false); + setProgress(''); + } }; + // Açık ihalenin ilan metnini analiz et + const analyzeCurrentTender = () => { + const t = activeTenderDetail; + const text = [t?.baslik, t?.kurum, t?.aciklama, ...(t?.yeterlikKriterleri || [])].filter(Boolean).join('\n'); + if (!text || text.length < 40) { + setError('Açık ihalenin ilan metni analiz için yeterli değil. Bir şartname dosyası yükleyin.'); + return; + } + runAnalysis(text, `${t.ikn || 'ihale'} — ilan metni`); + }; + + const modelEtiketi = aiCfg?.providers?.length + ? aiCfg.providers.map((p) => p.label).join(' → ') + : `${activeAiModel.label} (${activeAiModel.provider})`; + return (
- + {/* Header */}
@@ -73,191 +94,219 @@ export const AiSpecAnalyzerModal = () => {
-
+

Teknik Şartname & Teklif Analiz Asistanı

- Gemini 3.6 Flash Engine + {modelEtiketi}
-

Teknik şartname PDF'inizi yükleyin; yeterlik, riskler ve önerilen teklif fiyat aralığını saniyeler içinde çıkaralım.

+

Şartname dosyanızı yükleyin; yeterlik, riskler ve teklif aralığı gerçek AI ile analiz edilsin.

-
- {/* Modal Content */}
- - {/* PDF Uploader Area */} + + {/* AI yapılandırılmamış uyarısı */} + {aiCfg && !aiCfg.configured && ( +
+ +

+ AI henüz yapılandırılmamış. Analizin çalışması için sunucuya en az bir sağlayıcı anahtarı ekleyin + (ZAI_API_KEY, DEEPSEEK_API_KEY, + {' '}GEMINI_API_KEY …) ve yeniden dağıtın. + Model sırası, Admin → AI Model Ayarları'ndan belirlenir. +

+
+ )} + + {/* Uploader */}
-

Teknik Şartname PDF Dosyası Yükleyin

-

Bilgisayarınızdan `.pdf` dosyanızı buraya sürükleyin veya örnek ihale şartnamesi seçin.

+

Teknik Şartname Dosyası Yükleyin

+

Metin tabanlı .pdf veya .txt dosyası — belge tarayıcınızda okunur, yalnızca metni analiz için gönderilir.

-
-
- {/* Analyzing Loading Progress */} - {isAnalyzing && ( -
-
- - - ⚡ Gemini 3.6 Flash PDF Multimodal Akışı İşleniyor... - - Gemini 3.6 Flash -
- -
-

✓ 📄 {selectedFile} belgesindeki maddeler ayrıştırıldı.

-

✓ 🛡️ Yeterlik sertifikaları ve cezai hükümler tespit ediliyor...

-

✓ 📊 EKAP geçmiş ihale veri tabanından optimal teklif marjı hesaplanıyor...

+ {/* Hata */} + {error && ( +
+ +
+

Analiz tamamlanamadı

+

{error}

)} - {/* AI Specification Report Output */} + {/* Yükleniyor */} + {isAnalyzing && ( +
+
+ + {progress || 'İşleniyor…'} +
+

{selectedFile}

+
+ )} + + {/* Sonuç */} {!isAnalyzing && results && (
- - {/* Top Summary Banner */} +
-
+
- Gemini 3.6 Flash Şartname Özeti + Şartname Özeti - Analiz: {results.analyzedAt} -
-

{results.summary}

-
- - {/* Bidding Recommendation Box (Kritik Fark Yaratacak Özellik) */} -
-
-
- -

Gemini 3.6 Flash Önerilen İhale Teklif Aralığı

-
- - %{calcProbability(customBidSlider)} Kazanma Şansı + + {results.model} · {results.analyzedAt}
- -
-
- İdare Yaklaşık Maliyeti - - ₺{results.bidAnalysis.estimatedCost.toLocaleString('tr-TR')} - -
- -
- Önerilen Optimal Teklif - - ₺{results.bidAnalysis.recommendedBid.toLocaleString('tr-TR')} - -
- -
- Geçmiş EKAP İndirim Ort. - - {results.bidAnalysis.historicalDiscountAvg} - -
-
- - {/* Custom Bid Price Interactive Slider */} -
-
- - Teklif Fiyatınızı Simüle Edin: - - ₺{customBidSlider.toLocaleString('tr-TR')} -
- setCustomBidSlider(Number(e.target.value))} - className="w-full accent-emerald-500 cursor-pointer h-2 bg-slate-800 rounded-lg" - /> -
- Min Limit: ₺{results.bidAnalysis.minBidLimit.toLocaleString('tr-TR')} - Önerilen: ₺{results.bidAnalysis.recommendedBid.toLocaleString('tr-TR')} - Maks Limit: ₺{results.bidAnalysis.maxBidLimit.toLocaleString('tr-TR')} -
-
- +

{results.summary || 'Özet üretilemedi.'}

+ {results.fileName &&

Kaynak: {results.fileName}

}
- {/* 2-Column Details: Qualifications & Risks */} + {/* Teklif analizi — yalnızca model sayı ürettiyse */} + {(bid.estimatedCost != null || bid.recommendedBid != null) && ( +
+
+
+ +

Önerilen Teklif Aralığı

+
+ {bid.winProbability != null && ( + + %{bid.winProbability} Kazanma Tahmini + + )} +
+ +
+
+ İdare Yaklaşık Maliyeti + {tl(bid.estimatedCost)} +
+
+ Önerilen Optimal Teklif + {tl(bid.recommendedBid)} +
+
+ Rekabet Değerlendirmesi + {bid.competitionLevel || '—'} +
+
+ + {bid.minBidLimit != null && bid.maxBidLimit != null && bid.maxBidLimit > bid.minBidLimit && ( +
+
+ + Teklif Fiyatınızı Simüle Edin: + + {tl(customBid ?? bid.recommendedBid)} +
+ setCustomBid(Number(e.target.value))} + className="w-full accent-emerald-500 cursor-pointer h-2 bg-slate-800 rounded-lg" + /> +
+ Min: {tl(bid.minBidLimit)} + Önerilen: {tl(bid.recommendedBid)} + Maks: {tl(bid.maxBidLimit)} +
+
+ )} + {bid.notes &&

{bid.notes}

} +
+ )} +
- - {/* Qualification Criteria */}

Yeterlik Kriterleri & Zorunlu Belgeler

    - {results.qualificationCriteria.map((item, idx) => ( + {(results.qualificationCriteria || []).length === 0 && ( +
  • Belgede yeterlik kriteri tespit edilemedi.
  • + )} + {(results.qualificationCriteria || []).map((item, idx) => (
  • {item}
  • ))}
+ {(results.criticalRequirements || []).length > 0 && ( + <> +

Kritik Şartlar

+
    + {results.criticalRequirements.map((item, idx) => ( +
  • + + {item} +
  • + ))} +
+ + )}
- {/* Risks & Penalty Clauses */}

- Riskler & Şartname Cezai Hükümleri + Riskler & Cezai Hükümler

- {results.risksAndPenalties.map((risk, idx) => ( + {(results.risksAndPenalties || []).length === 0 && ( +

Belgede cezai hüküm/risk tespit edilemedi.

+ )} + {(results.risksAndPenalties || []).map((risk, idx) => (
-
- {risk.clause} - + {risk.clause || '—'} + - {risk.level} Risk + {risk.level || 'Bilgi'} Risk

{risk.text}

@@ -265,15 +314,16 @@ export const AiSpecAnalyzerModal = () => { ))}
-
+

+ Analiz yapay zeka tarafından üretilmiştir; teklif kararı öncesi şartnamenin aslını doğrulayın. +

)}
- {/* Footer */}
+ + + {/* hero */} +
+
+
+
+ + ilan.gov.tr akışı şu anda canlı +
+

+ Kamu ihalelerini +
+ kaçırmayan firma, +
+ kazanan firmadır. +

+

+ İhaleist; canlı ilan akışını, yapay zekâ destekli şartname analizini, rakip takibini ve + fiyat analizini tek panelde toplar. Siz teklife odaklanın, takibi biz yapalım. +

+
+ + + Neler yapabilir? + +
+
+ Google ile 10 saniyede giriş + Kurulum gerekmez + KVKK uyumlu +
+
+ +
+
+ + {/* akış şeridi */} +
+ + +
+ + {/* rakamlar */} +
+
+ {[ + { v: 2400, s: '+', l: 'Günlük yeni ilan taranıyor', i: Zap, r: 'text-cyan-300' }, + { v: 81, s: '', l: 'İl genelinde takip', i: MapPin, r: 'text-amber-300' }, + { v: 15000, s: '+', l: 'Kayıtlı kamu kurumu', i: Building2, r: 'text-violet-300' }, + { v: 30, s: ' sn', l: 'Ortalama şartname özeti', i: Sparkles, r: 'text-emerald-300' }, + ].map((x) => ( +
+ +
+
{x.l}
+
+ ))} +
+
+ + {/* özellikler */} +
+
+ Tek panel, tam kontrol +

İhale masanızın tamamı burada.

+

EKAP'ta, gazete ilanlarında, kurum sitelerinde dağınık duran her şey — tek akışta, süzülmüş ve önceliklendirilmiş.

+
+
+ {OZELLIKLER.map((o, i) => ( +
+
+ ))} +
+
+ + {/* nasıl çalışır */} +
+
+ Nasıl çalışır? +

Üç adımda ihale radarınız hazır.

+
+
+ + + +
+
+ + {/* son çağrı */} +
+
+
+ + {/* footer */} + +
+ ); +}; diff --git a/src/components/TopNavigation.jsx b/src/components/TopNavigation.jsx index f72966d..ae2a80b 100644 --- a/src/components/TopNavigation.jsx +++ b/src/components/TopNavigation.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { useApp } from '../context/AppContext'; -import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu } from 'lucide-react'; +import { Newspaper, Trophy, CalendarDays, FileCheck2, Calculator, TrendingUp, LayoutGrid, Kanban, Radar, FolderKanban, BarChart3, Webhook, Smartphone, Cpu, ShieldAlert, Globe2, Gavel, CreditCard } from 'lucide-react'; export const TopNavigation = () => { const { activeTab, setActiveTab, isAdmin } = useApp(); @@ -17,6 +17,10 @@ export const TopNavigation = () => { { id: 'rakip_radar', label: 'Rakip Radar', icon: Radar, badge: 'Canlı Uyar' }, { id: 'dokuman_deposu', label: 'Doküman Deposu', icon: FolderKanban }, { id: 'analitik_dashboard', label: 'Analitik Dashboard', icon: BarChart3 }, + { id: 'kirmizi_bayrak', label: 'Kırmızı Bayrak', icon: ShieldAlert, badge: 'Şeffaflık' }, + { id: 'kik_kararlari', label: 'KİK Kurul Kararları', icon: Gavel }, + { id: 'ted_eu', label: 'AB İhaleleri (TED)', icon: Globe2 }, + { id: 'planlar', label: 'Planlar', icon: CreditCard }, { id: 'api_webhooks', label: 'API & Webhooks', icon: Webhook }, // Sadece admin: AI Model Ayarları ...(isAdmin ? [{ id: 'ai_ayarlari', label: 'AI Model Ayarları', icon: Cpu, badge: 'Admin' }] : []), diff --git a/src/components/tools/KikDecisions.jsx b/src/components/tools/KikDecisions.jsx new file mode 100644 index 0000000..d92a642 --- /dev/null +++ b/src/components/tools/KikDecisions.jsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import { useApp } from '../../context/AppContext'; +import { Gavel, Search, Loader2, ExternalLink, AlertTriangle, Calendar, Building2, Info } from 'lucide-react'; + +export const KikDecisions = () => { + const { tenders } = useApp(); + const [q, setQ] = useState(''); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const ara = async (terim) => { + const t = (terim ?? q).trim(); + if (!t) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/kik/search?q=${encodeURIComponent(t)}&limit=20`); + const d = await res.json(); + if (d.success) setData(d); + else { setError(d.error || 'Arama başarısız'); setData(null); } + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + // Takipteki ihalelerin idarelerinden hızlı arama önerileri + const oneriler = [...new Set( + tenders.filter((t) => t.takipte || t.kanbanStatus).map((t) => t.kurum).filter(Boolean) + )].slice(0, 4); + + return ( +
+ +
+
+
+ +
+
+

KİK Kurul Kararları

+

+ Kamu İhale Kurulu itirazen şikâyet kararlarında arama yapın; emsal kararları teklif öncesi inceleyin. +

+
+
+
+ + {/* Arama */} +
+
+
+ setQ(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && ara()} + placeholder="Karar metni, idare adı veya başvuran firma…" + className="w-full bg-slate-50 border border-slate-300 rounded-lg pl-9 pr-4 py-2.5 text-xs font-semibold" + /> + +
+ +
+ + {oneriler.length > 0 && ( +
+ Takibinizdeki idareler: + {oneriler.map((k) => ( + + ))} +
+ )} +
+ + {error && ( +
+ +
+

KİK kararlarına ulaşılamadı

+

{error}

+

+ KİK sunucusu zaman zaman otomatik isteklere kapalı olabilir; birkaç dakika sonra tekrar deneyin. +

+
+
+ )} + + {data && ( + <> +
+

+ {data.totalCount?.toLocaleString('tr-TR') ?? data.results.length} karar bulundu +

+ {data.kaynak && Kaynak: {data.kaynak}} +
+ +
+ {data.results.length === 0 && ( +
+

Bu aramada karar bulunamadı

+
+ )} + {data.results.map((k, i) => ( +
+
+
+
+ {k.kararNo && ( + + {k.kararNo} + + )} + {k.kararTarihi && ( + + {k.kararTarihi} + + )} + {k.tur && {k.tur}} +
+

{k.baslik || k.konu || 'Kurul Kararı'}

+
+ {k.link && ( + + + + )} +
+ + {(k.idare || k.basvuran) && ( +
+ {k.idare && {k.idare}} + {k.basvuran && Başvuran: {k.basvuran}} +
+ )} + + {k.ozet &&

{k.ozet}

} +
+ ))} +
+ + )} + + {!data && !loading && !error && ( +
+ +

+ Bir idare adı, firma veya konu yazıp arayın. KİK kurul kararları, benzer ihalelerde + hangi itirazların kabul/ret edildiğini gösterir — teklif ve şikâyet stratejinizi buna göre kurabilirsiniz. +

+
+ )} +
+ ); +}; diff --git a/src/components/tools/Pricing.jsx b/src/components/tools/Pricing.jsx new file mode 100644 index 0000000..992e091 --- /dev/null +++ b/src/components/tools/Pricing.jsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect } from 'react'; +import { useApp } from '../../context/AppContext'; +import { getToken } from '../../services/auth'; +import { Check, Sparkles, CreditCard, Loader2, Info } from 'lucide-react'; + +export const Pricing = () => { + const { authUser } = useApp(); + const [data, setData] = useState(null); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [yillik, setYillik] = useState(false); + + useEffect(() => { + fetch('/api/billing/plans') + .then((r) => r.json()) + .then(setData) + .catch(() => setData({ configured: false, plans: [] })); + }, []); + + const upgrade = async (planId) => { + setBusy(planId); + setError(null); + try { + const res = await fetch('/api/billing/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ planId }), + }); + const d = await res.json(); + if (d.success && d.url) window.location.href = d.url; + else setError(d.error || 'Ödeme başlatılamadı'); + } catch (e) { + setError(e.message); + } finally { + setBusy(null); + } + }; + + const plans = data?.plans || []; + const fiyatla = (p) => (yillik ? Math.round(p.fiyat * 12 * 0.83) : p.fiyat); + + return ( +
+ +
+
+
+ +
+
+

Planlar & Fiyatlandırma

+

İhtiyacınıza uygun planı seçin. İstediğiniz zaman iptal edebilirsiniz.

+
+
+
+ + {/* Aylık / Yıllık */} +
+
+ + +
+
+ + {error && ( +
{error}
+ )} + + {data && !data.configured && ( +
+ +

+ Ödeme altyapısı henüz bağlanmadı. Satın alma için sunucuya STRIPE_SECRET_KEY ve + plan fiyat kimlikleri (STRIPE_PRICE_PRO, STRIPE_PRICE_KURUMSAL) eklenmeli. + Planlar aşağıda görüntülenebilir. +

+
+ )} + +
+ {plans.map((p) => ( +
+
+
+

{p.ad}

+ {p.rozet && ( + + {p.rozet} + + )} +
+

{p.ozet}

+
+ +
+ + {p.fiyat === 0 ? 'Ücretsiz' : `₺${fiyatla(p).toLocaleString('tr-TR')}`} + + {p.fiyat > 0 && /{yillik ? 'yıl' : 'ay'}} + {p.fiyat > 0 &&

KDV hariç

} +
+ +
    + {p.ozellikler.map((o, i) => ( +
  • + + {o} +
  • + ))} +
+ + {p.fiyat === 0 ? ( +
+ Mevcut planınız +
+ ) : ( + + )} +
+ ))} +
+ +

+ Ödemeler Stripe üzerinden güvenli şekilde alınır; kart bilgileriniz İhaleist sunucularında saklanmaz. + Aboneliğinizi istediğiniz zaman iptal edebilirsiniz. +

+
+ ); +}; diff --git a/src/components/tools/RedFlags.jsx b/src/components/tools/RedFlags.jsx new file mode 100644 index 0000000..ae92451 --- /dev/null +++ b/src/components/tools/RedFlags.jsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { useApp } from '../../context/AppContext'; +import { redFlags, marketConcentration, formatTL } from '../../services/analytics'; +import { ShieldAlert, AlertTriangle, TrendingDown, Building2, Users, Info } from 'lucide-react'; + +const SEVIYE = { + yuksek: { etiket: 'Yüksek', kutu: 'bg-red-50 border-red-200', rozet: 'bg-red-100 text-red-800', cizgi: 'bg-red-500' }, + orta: { etiket: 'Orta', kutu: 'bg-amber-50 border-amber-200', rozet: 'bg-amber-100 text-amber-800', cizgi: 'bg-amber-500' }, + dusuk: { etiket: 'Düşük', kutu: 'bg-slate-50 border-slate-200', rozet: 'bg-slate-100 text-slate-700', cizgi: 'bg-slate-400' }, +}; + +const TUR_IKON = { + 'Tek teklif': Users, + 'Düşük kırım': TrendingDown, + 'Tahmin üstü bedel': AlertTriangle, + 'Aşırı düşük teklif': TrendingDown, + 'Tedarikçi yoğunlaşması': Building2, +}; + +export const RedFlags = () => { + const { tenders, setActiveTenderDetail } = useApp(); + const { flags, yuksek, orta, analiz } = redFlags(tenders); + const { hhi, seviye } = marketConcentration(tenders); + + return ( +
+ + {/* Header */} +
+
+
+ +
+
+

Kırmızı Bayrak & Şeffaflık Analizi

+

Tek teklifli ihaleler, olağandışı kırım oranları ve tedarikçi yoğunlaşması otomatik tespit edilir.

+
+
+
+ + {/* Özet kartlar */} +
+
+ Yüksek Öncelik + {yuksek} +
+
+ Orta Öncelik + {orta} +
+
+ Analiz Edilen + {analiz} + sonuç verisi olan ihale +
+
+ Pazar Yoğunlaşması + {hhi != null ? hhi : '—'} + {seviye || 'HHI için sonuç verisi gerekli'} +
+
+ + {/* Yöntem notu */} +
+ +

+ Bu sinyaller kamuya açık ihale verisinden otomatik hesaplanır ve incelenmeye değer durumları işaret eder — + tek başlarına usulsüzlük kanıtı değildir. Tek teklif ve kırım analizleri, ilgili alanların (teklif sayısı, yaklaşık maliyet, + sözleşme bedeli) veride bulunduğu ihaleler için üretilir. +

+
+ + {/* Bayrak listesi */} + {flags.length === 0 ? ( +
+ +

Henüz kırmızı bayrak tespit edilmedi

+

+ Analiz için sonuç verisi (kazanan firma, sözleşme bedeli, teklif sayısı) gerekir. + Doğrudan temin sonuçları ve EKAP sonuç ilanları biriktikçe bu liste otomatik dolar. +

+
+ ) : ( +
+ {flags.map((f) => { + const s = SEVIYE[f.seviye]; + const Icon = TUR_IKON[f.tur] || AlertTriangle; + return ( +
+ + +
+
+ {s.etiket} + {f.tur} + {f.deger && {f.deger}} +
+

{f.aciklama}

+ {f.tender ? ( + + ) : ( +

{f.kurum}

+ )} +
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/components/tools/TedTenders.jsx b/src/components/tools/TedTenders.jsx new file mode 100644 index 0000000..6e29214 --- /dev/null +++ b/src/components/tools/TedTenders.jsx @@ -0,0 +1,153 @@ +import React, { useState, useEffect } from 'react'; +import { Globe2, Loader2, ExternalLink, Building2, Calendar, Trophy, AlertTriangle, RefreshCw } from 'lucide-react'; + +const MODLAR = [ + { id: 'acik', label: 'Açık AB İhaleleri', aciklama: 'Türkiye’deki idarelerin AB’de yayımladığı, hâlen teklif verilebilir ihaleler.' }, + { id: 'turkiye', label: 'Tüm Türkiye Kayıtları', aciklama: 'TR idareler + TR kazananlar + TR teklif verenler.' }, + { id: 'kazanan', label: 'AB’de Kazanan TR Firmaları', aciklama: 'Türk firmalarının AB ihalelerinde kazandığı sözleşmeler.' }, +]; + +export const TedTenders = () => { + const [mod, setMod] = useState('acik'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const yukle = (m) => { + setLoading(true); + setError(null); + fetch(`/api/ted?mod=${m}&limit=30`) + .then((r) => r.json()) + .then((d) => { if (d.success) setData(d); else setError(d.error); }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { yukle(mod); }, [mod]); + + const aktifMod = MODLAR.find((m) => m.id === mod); + + return ( +
+ +
+
+
+ +
+
+

AB İhaleleri — TED

+

+ Tenders Electronic Daily (AB resmi ihale yayını) üzerinden Türkiye ile ilgili ihaleler. +

+
+
+
+ + {/* Mod seçici */} +
+ {MODLAR.map((m) => ( + + ))} + +
+ +
+

{aktifMod?.aciklama}

+ {data && ( + + {data.totalCount.toLocaleString('tr-TR')} kayıt + + )} +
+ + {error && ( +
+ +
+

TED verisi alınamadı

+

{error}

+
+
+ )} + + {loading && !data && ( +
+ +

TED'den çekiliyor…

+
+ )} + + {data && ( +
+ {data.tenders.length === 0 && ( +
+

Bu filtrede kayıt bulunamadı

+
+ )} + {data.tenders.map((t) => ( +
+
+

{t.baslik}

+ {t.link && ( + + + + )} +
+ +
+ + {t.kurum} + + {t.sehir && {t.sehir}} + {t.ulke && {t.ulke}} + + + {t.sonTeklifTarihi ? `Son teklif: ${t.sonTeklifTarihi}` : t.yayimTarihi} + + {t.alimTuru} + {t.sonucIlani && Sonuç İlanı} +
+ + {(t.kazananFirma || t.yaklasikMaliyet != null) && ( +
+ {t.kazananFirma && ( + + {t.kazananFirma} + {t.kazananUlke && ({t.kazananUlke})} + + )} + {t.yaklasikMaliyet != null && ( + + {t.yaklasikMaliyet.toLocaleString('tr-TR')} {t.paraBirimi || ''} + + )} +
+ )} +
+ ))} +
+ )} + +

+ Kaynak: Tenders Electronic Daily (ted.europa.eu) — Avrupa Birliği resmi ihale yayını. +

+
+ ); +}; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index dce14f6..3132c69 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -215,49 +215,22 @@ export const AppProvider = ({ children }) => { const getModelById = (id) => AI_MODEL_CATALOG.find(m => m.id === id); const activeAiModel = getModelById(aiModelConfig.chain[0]) || AI_MODEL_CATALOG[0]; - const analyzeSpecPdfWithGemini = (fileName = 'Teknik_Sartname.pdf') => { - return new Promise((resolve) => { - setTimeout(() => { - const result = { - fileName, - model: `${activeAiModel.label} (${activeAiModel.provider})`, - modelId: activeAiModel.model, - fallbackChain: aiModelConfig.chain.map(id => getModelById(id)?.model).filter(Boolean), - analyzedAt: new Date().toLocaleString('tr-TR'), - summary: 'İşbu teknik şartname, idarenin ihtiyacı olan medikal sarf malzemeleri ve cihaz parçalarının teknik özellikleri, garanti şartları, teslimat vadeleri ve muayene kabul kriterlerini düzenlemektedir.', - qualificationCriteria: [ - 'TSE HYB Belgesi ve Yetkili Satıcı Satış Yetki Belgesi', - 'ISO 13485 Medikal Cihaz Kalite Yönetim Sertifikası', - 'ÜTS (Ürün Takip Sistemi) Barkod ve TİUBB Kaydı', - 'En az %50 Oranında Benzer İş Deneyim Belgesi (Kamu/Özel)', - 'Son 3 yıla ait Bilanço ve Gelir Tablosu oranları' - ], - criticalRequirements: [ - 'Ürünler biyo-uyumluluk (ISO 10993) standartlarına uygun olmalıdır.', - 'Teslim edilen paketlerin sterilizasyon son kullanma tarihi en az 18 ay olmalıdır.', - 'Arıza durumunda 24 saat içinde yerinde teknik servis desteği sağlanmalıdır.', - 'Teslimat aşamasında her parti için numune test raporu (CoA) ibraz edilmelidir.' - ], - risksAndPenalties: [ - { level: 'Yüksek', clause: 'Madde 14.2', text: 'Gecikilen her takvim günü için ihale bedelinin %0.5 oranında cezai şart uygulanacaktır.' }, - { level: 'Orta', clause: 'Madde 18.1', text: 'Numune kontrolünde standart dışı bulunan partiler 3 iş günü içinde muadili ile ücretsiz değiştirilmelidir.' }, - { level: 'Düşük', clause: 'Madde 22 border', text: 'Ambalaj üzerindeki Türkçe etiket eksikliğinde kabul işlemi durdurulacaktır.' } - ], - bidAnalysis: { - estimatedCost: 185000, - recommendedBid: 148500, - minBidLimit: 132000, - maxBidLimit: 165000, - winProbability: 89, - competitionLevel: 'Orta Yoğunlukta (3-4 Firma Katılımı Bekleniyor)', - historicalDiscountAvg: '%19.7 EKAP İndirim Oranı' - } - }; - - setAiAnalysisResults(result); - resolve(result); - }, 2200); - }); + // Gerçek AI analizi — backend /api/ai/analyze üzerinden, admin panelde + // seçilen model zinciri (fallback) ile. + const analyzeSpecPdfWithGemini = async ({ text, fileName, tender } = {}) => { + const { analyzeSpecText } = await import('../services/ai'); + const data = await analyzeSpecText({ text, fileName, tender, chain: aiModelConfig.chain }); + const result = { + ...data.result, + fileName, + model: `${data.model.label} (${data.model.provider})`, + modelId: data.model.model, + fallbackChain: aiModelConfig.chain.map((id) => getModelById(id)?.model).filter(Boolean), + attempts: data.attempts, + analyzedAt: new Date(data.analyzedAt).toLocaleString('tr-TR'), + }; + setAiAnalysisResults(result); + return result; }; const [userProfile, setUserProfile] = useState({ diff --git a/src/index.css b/src/index.css index 71f2c56..5c7e884 100644 --- a/src/index.css +++ b/src/index.css @@ -43,3 +43,113 @@ .pulse-glow { animation: pulseGlow 2s infinite ease-in-out; } + +/* ============ Landing page (lp-) ============ */ + +.lp-root { color-scheme: dark; } + +.lp-grid { + background-image: + linear-gradient(rgba(148, 163, 184, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.05) 1px, transparent 1px); + background-size: 56px 56px; + mask-image: radial-gradient(ellipse 90% 60% at 50% 0%, #000 55%, transparent 100%); +} + +@keyframes lpBlob { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(50px, -40px) scale(1.12); } + 66% { transform: translate(-35px, 30px) scale(0.94); } +} +.lp-blob { animation: lpBlob 18s ease-in-out infinite; } + +@keyframes lpHeroIn { + from { opacity: 0; transform: translateY(22px); } + to { opacity: 1; transform: translateY(0); } +} +.lp-hero-in { opacity: 0; animation: lpHeroIn 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; } + +@keyframes lpGradientMove { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } +} +.lp-gradient-text { + background: linear-gradient(90deg, #22d3ee, #a78bfa, #fbbf24, #22d3ee); + background-size: 280% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: lpGradientMove 7s ease-in-out infinite; +} + +.lp-underline { + position: relative; + white-space: nowrap; +} +.lp-underline::after { + content: ''; + position: absolute; + left: -2px; right: -2px; bottom: 4px; + height: 10px; + background: linear-gradient(90deg, rgba(34, 211, 238, 0.45), rgba(251, 191, 36, 0.45)); + border-radius: 4px; + z-index: -1; + transform: skewX(-8deg); +} + +@keyframes lpFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-12px); } +} +.lp-float { animation: lpFloat 7s ease-in-out infinite; } + +@keyframes lpCardIn { + from { opacity: 0; transform: translateX(26px); } + to { opacity: 1; transform: translateX(0); } +} +.lp-card-in { opacity: 0; animation: lpCardIn 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; } + +@keyframes lpBar { + from { height: 0; } + to { height: var(--h); } +} +.lp-bar { height: 0; animation: lpBar 0.8s cubic-bezier(0.2, 0.7, 0.2, 1) forwards; } + +@keyframes lpSticker { + from { opacity: 0; transform: scale(0.5) rotate(14deg); } + 60% { transform: scale(1.08) rotate(5deg); } + to { opacity: 1; transform: scale(1) rotate(6deg); } +} +.lp-sticker { opacity: 0; animation: lpSticker 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) 1.1s forwards; } + +@keyframes lpBlink { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.55); } + 50% { opacity: 0.6; box-shadow: 0 0 0 5px rgba(52, 211, 153, 0); } +} +.lp-blink { animation: lpBlink 1.6s ease-in-out infinite; } + +@keyframes lpMarquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +.lp-marquee { animation: lpMarquee 42s linear infinite; } +.lp-marquee-rev { animation: lpMarquee 48s linear infinite reverse; } +.lp-marquee:hover, .lp-marquee-rev:hover { animation-play-state: paused; } + +.lp-ticker-mask { + mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); +} + +.lp-reveal { + opacity: 0; + transform: translateY(28px); + transition: opacity 0.7s cubic-bezier(0.2, 0.7, 0.2, 1), transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1); +} +.lp-reveal.lp-in { opacity: 1; transform: translateY(0); } + +@media (prefers-reduced-motion: reduce) { + .lp-blob, .lp-float, .lp-marquee, .lp-marquee-rev, .lp-gradient-text, .lp-blink { animation: none; } + .lp-hero-in, .lp-card-in, .lp-sticker { animation: none; opacity: 1; } + .lp-bar { animation: none; height: var(--h); } + .lp-reveal { opacity: 1; transform: none; transition: none; } +} diff --git a/src/services/ai.js b/src/services/ai.js new file mode 100644 index 0000000..51a79a3 --- /dev/null +++ b/src/services/ai.js @@ -0,0 +1,25 @@ +/** + * Gerçek AI şartname analizi istemcisi (backend /api/ai üzerinden). + */ +import { getToken } from './auth'; + +export async function getAiConfig() { + try { + const res = await fetch('/api/ai/config'); + if (!res.ok) return { configured: false, providers: [] }; + return await res.json(); + } catch { + return { configured: false, providers: [] }; + } +} + +export async function analyzeSpecText({ text, fileName, tender, chain }) { + const res = await fetch('/api/ai/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, + body: JSON.stringify({ text, fileName, tender, chain }), + }); + const data = await res.json().catch(() => ({})); + if (!data.success) throw new Error(data.error || `Analiz başarısız (HTTP ${res.status})`); + return data; +} diff --git a/src/services/analytics.js b/src/services/analytics.js index 8cdbe5f..ff05760 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -164,6 +164,98 @@ export function monthlyStats(tenders) { .map((x) => ({ month: AY_KISA[x.m], kazanim: x.kazanim, katilim: x.katilim })); } +// --- Kırmızı bayrak / şeffaflık analizi --- +// Kaynak veride bulunan alanlardan hesaplanır; alan yoksa o bayrak üretilmez. +// (Uyarı niteliğindedir — usulsüzlük iddiası değil, incelenmeye değer sinyaldir.) +export function redFlags(tenders) { + const flags = []; + + // Firma-kurum yoğunlaşması için sayaçlar + const firmKurum = new Map(); // "firma||kurum" -> sayı + const kurumToplam = new Map(); // kurum -> sonuçlanan ihale sayısı + + for (const t of tenders) { + const bedel = parseTL(t.sozlesmeBedeli); + const yaklasik = parseTL(t.yaklasikMaliyet); + const teklif = t.teklifSayisi != null ? Number(t.teklifSayisi) : null; + + // 1) Tek teklifli ihale + if (teklif === 1) { + flags.push({ + id: `tek-${t.id}`, seviye: 'yuksek', tur: 'Tek teklif', + tender: t, + aciklama: 'İhaleye yalnızca bir geçerli teklif verilmiş — rekabet oluşmamış.', + }); + } + + // 2) Yaklaşık maliyete çok yakın sözleşme (düşük kırım) + if (bedel != null && yaklasik != null && yaklasik > 0) { + const kirim = 1 - bedel / yaklasik; + if (kirim >= 0 && kirim < 0.03) { + flags.push({ + id: `kirim-${t.id}`, seviye: 'orta', tur: 'Düşük kırım', + tender: t, deger: `%${(kirim * 100).toFixed(1)} kırım`, + aciklama: 'Sözleşme bedeli yaklaşık maliyete neredeyse eşit — fiyat rekabeti sınırlı.', + }); + } + // 3) Yaklaşık maliyetin üzerinde sözleşme + if (bedel > yaklasik) { + flags.push({ + id: `ustu-${t.id}`, seviye: 'yuksek', tur: 'Tahmin üstü bedel', + tender: t, deger: `%${(((bedel - yaklasik) / yaklasik) * 100).toFixed(1)} üzerinde`, + aciklama: 'Sözleşme bedeli idarenin yaklaşık maliyetini aşıyor.', + }); + } + // 4) Aşırı düşük teklif riski + if (kirim > 0.4) { + flags.push({ + id: `asiri-${t.id}`, seviye: 'orta', tur: 'Aşırı düşük teklif', + tender: t, deger: `%${(kirim * 100).toFixed(1)} kırım`, + aciklama: 'Yaklaşık maliyete göre çok yüksek kırım — sürdürülebilirlik/ifa riski.', + }); + } + } + + if (t.kazananFirma && t.kurum) { + const key = `${t.kazananFirma.trim()}||${t.kurum.trim()}`; + firmKurum.set(key, (firmKurum.get(key) || 0) + 1); + kurumToplam.set(t.kurum.trim(), (kurumToplam.get(t.kurum.trim()) || 0) + 1); + } + } + + // 5) Kurum-firma yoğunlaşması: aynı firma bir kurumun ihalelerinin çoğunu almış + for (const [key, adet] of firmKurum) { + const [firma, kurum] = key.split('||'); + const toplam = kurumToplam.get(kurum) || 0; + if (toplam >= 3 && adet / toplam >= 0.6) { + flags.push({ + id: `yogun-${key}`, seviye: 'yuksek', tur: 'Tedarikçi yoğunlaşması', + firma, kurum, deger: `${adet}/${toplam} ihale`, + aciklama: `${firma}, bu idarenin sonuçlanan ihalelerinin %${Math.round((adet / toplam) * 100)}'ini almış.`, + }); + } + } + + const siraDeger = { yuksek: 0, orta: 1, dusuk: 2 }; + flags.sort((a, b) => siraDeger[a.seviye] - siraDeger[b.seviye]); + return { + flags, + yuksek: flags.filter((f) => f.seviye === 'yuksek').length, + orta: flags.filter((f) => f.seviye === 'orta').length, + // Analiz edilebilen (sonuç verisi olan) ihale sayısı — şeffaflık için + analiz: tenders.filter((t) => t.kazananFirma || t.sozlesmeBedeli != null || t.teklifSayisi != null).length, + }; +} + +// --- Pazar yoğunlaşması (HHI) --- +export function marketConcentration(tenders) { + const { firms, totalMarket } = firmStats(tenders); + if (!firms.length || !totalMarket) return { hhi: null, seviye: null, firms: [] }; + const hhi = firms.reduce((s, f) => s + Math.pow((f.total / totalMarket) * 100, 2), 0); + const seviye = hhi < 1500 ? 'Rekabetçi' : hhi < 2500 ? 'Orta yoğunlaşma' : 'Yüksek yoğunlaşma'; + return { hhi: Math.round(hhi), seviye, firms }; +} + // --- Alım türü dağılımı --- export function alimTuruStats(tenders) { const map = new Map(); diff --git a/src/services/pdfText.js b/src/services/pdfText.js new file mode 100644 index 0000000..f825cd2 --- /dev/null +++ b/src/services/pdfText.js @@ -0,0 +1,35 @@ +/** + * PDF'ten metin çıkarma (tarayıcı içinde, pdfjs-dist ile). + * Dinamik import edilir — ana bundle'a girmez. + */ + +export async function extractPdfText(file, { maxPages = 40 } = {}) { + const pdfjs = await import('pdfjs-dist'); + // Worker'ı Vite'ın bundle ettiği dosyadan al (CDN'e çıkmaz) + const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; + pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + + const buf = await file.arrayBuffer(); + const doc = await pdfjs.getDocument({ data: buf }).promise; + const pages = Math.min(doc.numPages, maxPages); + const parts = []; + for (let i = 1; i <= pages; i++) { + const page = await doc.getPage(i); + const content = await page.getTextContent(); + parts.push(content.items.map((it) => it.str).join(' ')); + } + const text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim(); + return { text, pageCount: doc.numPages, usedPages: pages }; +} + +// Metin dosyaları için basit okuyucu +export async function extractPlainText(file) { + const text = await file.text(); + return { text: text.trim(), pageCount: 1, usedPages: 1 }; +} + +export async function extractText(file) { + const name = (file.name || '').toLowerCase(); + if (name.endsWith('.pdf') || file.type === 'application/pdf') return extractPdfText(file); + return extractPlainText(file); +}