diff --git a/server/history-store.mjs b/server/history-store.mjs index 0eb866a..8962709 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -253,11 +253,17 @@ export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) { // 7 günlük referans — tek bir ölçüme değil, ortalamaya kıyaslarız ki // havuz yavaş yavaş erirse de yakalayalım. - const haftaBas = new Date(Date.now() - 7 * 864e5).toISOString(); - const ref = db - .prepare(`SELECT AVG(kaynak_toplam) ort, COUNT(kaynak_toplam) adet - FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`) - .get(haftaBas) || {}; + const ortalamaAl = (gun) => { + const bas = new Date(Date.now() - gun * 864e5).toISOString(); + return db + .prepare(`SELECT AVG(kaynak_toplam) ort, COUNT(kaynak_toplam) adet + FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`) + .get(bas) || {}; + }; + // 7 gün: günlük dalgalanma. 30 gün: kalıcı erime — 7 günlük ortalama + // düşüşe ayak uydurup alarmı susturduğunda bunu ancak 30 gün yakalar. + const ref = ortalamaAl(7); + const ref30 = ortalamaAl(30); db.prepare( `INSERT INTO havuz_olcum (zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv) @@ -274,7 +280,7 @@ export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) { const sinir = new Date(Date.now() - 90 * 864e5).toISOString(); db.prepare('DELETE FROM havuz_olcum WHERE zaman < ?').run(sinir); - return degerlendir(onceki, ref, { kaynakToplam, yuklenen }); + return degerlendir(onceki, ref, ref30, { kaynakToplam, yuklenen }); } finally { db.close(); } @@ -284,19 +290,26 @@ export function havuzOlc({ kaynakToplam, yuklenen, dt = null, arsiv = null }) { const MIN_OLCUM = 5; // Bu oranın altına düşerse uyarılır (7 günlük ortalamanın %60'ı). const DUSUS_ESIGI = 0.6; +// 30 günlük referans için: daha çok ölçüm ister, eşiği daha toleranslıdır — +// amaç ani dalgalanma değil, kalıcı erimeyi görmek. +const MIN_OLCUM_30 = 20; +const DUSUS_ESIGI_30 = 0.7; /** * Ölçümü 7 GÜNLÜK ORTALAMAYA kıyaslar; yeterli geçmiş yoksa bir önceki * ölçüme düşer. Uyarı gerekiyorsa açıklamasını döndürür. */ -function degerlendir(onceki, ref, { kaynakToplam, yuklenen }) { +function degerlendir(onceki, ref, ref30, { kaynakToplam, yuklenen }) { const ortalama = ref?.adet >= MIN_OLCUM && ref.ort ? Math.round(ref.ort) : null; + const ortalama30 = ref30?.adet >= MIN_OLCUM_30 && ref30.ort ? Math.round(ref30.ort) : null; const sonuc = { uyari: null, onceki: onceki?.kaynak_toplam ?? null, simdi: kaynakToplam ?? null, haftalikOrtalama: ortalama, + aylikOrtalama: ortalama30, olcumAdedi: ref?.adet ?? 0, + olcumAdedi30: ref30?.adet ?? 0, }; // 1) Kaynak düştü mü? (bizim hatamız değil ama bilmek isteriz) @@ -321,6 +334,21 @@ function degerlendir(onceki, ref, { kaynakToplam, yuklenen }) { } } + // 1b) Kalıcı erime: 7 günlük ortalama düşüşe ayak uydurduğu için sustuysa + // bile, 30 günlük referans hâlâ çok yukarıdaysa bunu ayrıca bildiririz. + if (kaynakToplam != null && ortalama30 > 0) { + const oran30 = kaynakToplam / ortalama30; + sonuc.aylikOrtalamayaOran = Number(oran30.toFixed(3)); + if (oran30 < DUSUS_ESIGI_30 && !sonuc.uyari) { + sonuc.uyari = `Kaynak havuzu 30 günlük ortalamanın %${Math.round((1 - oran30) * 100)} altında ` + + `(kalıcı düşüş): ortalama ${ortalama30}, şimdi ${kaynakToplam} ilan.`; + sonuc.tur = 'kalici_dusus'; + } else if (oran30 < DUSUS_ESIGI_30 && sonuc.uyari) { + // Zaten haftalık uyarı var; 30 günlük bağlamı mesaja ekle. + sonuc.uyari += ` (30 günlük ortalama ${ortalama30}, oran %${Math.round(oran30 * 100)})`; + } + } + // 2) Biz kaynağın gerisinde mi kaldık? (bu BİZİM hatamız olur) if (!sonuc.uyari && kaynakToplam > 0 && yuklenen != null && yuklenen < kaynakToplam * 0.9) { sonuc.uyari = `Havuz eksik yüklendi: kaynakta ${kaynakToplam}, bizde ${yuklenen} ilan.`; @@ -342,3 +370,35 @@ export function havuzGecmisi(limit = 60) { db.close(); } } + + +/** + * Havuz özeti — 7 ve 30 günlük ortalamalar SQL'de hesaplanır; döndürülen + * satır sayısından bağımsızdır (60 kayıtlık pencereye sıkışmaz). + */ +export function havuzOzet() { + const db = openDb(); + if (!db) return null; + try { + const pencere = (gun) => { + const bas = new Date(Date.now() - gun * 864e5).toISOString(); + const r = db + .prepare(`SELECT AVG(kaynak_toplam) ort, MAX(kaynak_toplam) zirve, MIN(kaynak_toplam) dip, + COUNT(kaynak_toplam) adet + FROM havuz_olcum WHERE zaman >= ? AND kaynak_toplam IS NOT NULL`) + .get(bas) || {}; + return { + ortalama: r.ort ? Math.round(r.ort) : null, + zirve: r.zirve ?? null, + dip: r.dip ?? null, + adet: r.adet ?? 0, + }; + }; + const son = db + .prepare('SELECT zaman, kaynak_toplam, yuklenen, eksik, dt, arsiv FROM havuz_olcum ORDER BY id DESC LIMIT 1') + .get() || null; + return { son, hafta: pencere(7), ay: pencere(30) }; + } finally { + db.close(); + } +} diff --git a/server/index.js b/server/index.js index 8430797..7ff1838 100644 --- a/server/index.js +++ b/server/index.js @@ -24,7 +24,7 @@ import { billingConfigured, webhookConfigured, planCatalog, createCheckoutSessio import * as store from './user-store.mjs'; import { planOf, haklar, kullanim, kotaKontrol, ozellikVar } from './entitlements.mjs'; import { searchTed } from './ted.mjs'; -import { readHistory, historyStats, havuzOlc, havuzGecmisi } from './history-store.mjs'; +import { readHistory, historyStats, havuzOlc, havuzGecmisi, havuzOzet } from './history-store.mjs'; import { startScheduler, schedulerStatus } from './scheduler.mjs'; import { searchKik, kararMetni } from './kik.mjs'; import { documentUrl, ilanMetni, documentText, browserAvailable } from './documents.mjs'; @@ -79,18 +79,40 @@ for (const [region, cities] of Object.entries(REGIONS)) { // --------------------------------------------------------------------------- // Basit sektör / kurum tipi sınıflandırması (başlık ve kurum adına göre) // --------------------------------------------------------------------------- +// Sektör sınıflandırması — sıra ÖNEMLİ: ilk eşleşen kazanır, bu yüzden +// dar/özel kalıplar geniş olanlardan önce gelir (ör. "tıbbi gaz" → Sağlık, +// "doğalgaz" → Elektrik & Enerji). +// UYARI: buradaki etiketler src/data/constants.js SECTORS_LIST ile birebir +// aynı olmalı. Sürüklenmeyi önlemek için liste /api/meta/sektorler ile sunulur. const SECTOR_RULES = [ - [/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş/i, 'Sağlık / Tıbbi Malzeme'], - [/yazılım|bilişim|sunucu|donanım|kamera|network|bilgisayar/i, 'Bilişim & Yazılım'], - [/gıda|yemek|catering|ekmek|et |sebze|meyve|kumanya/i, 'Gıda & Catering'], - [/temizlik|hijyen|dezenfek|deterjan/i, 'Temizlik & Hijyen'], - [/inşaat|yapım|onarım|tadilat|bakım|asfalt|beton|sulama|yol /i, 'İnşaat & Bakım'], - [/kırtasiye|matbaa|baskı|kağıt|toner/i, 'Kırtasiye & Büro'], - [/giyim|tekstil|elbise|üniforma|kıyafet/i, 'Tekstil & Giyim'], - [/araç|otomotiv|lastik|akaryakıt|yakıt|kiralama/i, 'Otomotiv & Yedek Parça'], - [/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş/i, 'Elektrik & Enerji'], + [/tıbbi|medikal|sağlık|hastane|ilaç|cerrahi|laboratuvar|diş |protez|ortez|görüntüleme|röntgen|tomografi|steril|enjektör|eldiven|maske|reaktif|kit alım/i, 'Sağlık / Tıbbi Malzeme'], + [/yazılım|bilişim|sunucu|donanım|network|bilgisayar|lisans|veri merkezi|siber|yazıcı alım|tablet|projeksiyon/i, 'Bilişim & Yazılım'], + [/güvenlik hizmet|özel güvenlik|kamera|cctv|alarm|turnike|x-ray|güvenlik sistem/i, 'Güvenlik Sistemleri'], + [/gıda|yemek|catering|ekmek|et alım|sebze|meyve|kumanya|süt|tavuk|bakliyat|mutfak/i, 'Gıda & Catering'], + [/temizlik|hijyen|dezenfek|deterjan|çöp|atık toplama|haşere|ilaçlama/i, 'Temizlik & Hijyen'], + [/asfalt|beton|yol yapım|köprü|altyapı|kanalizasyon|içme suyu|sulama|menfez|istinat/i, 'Altyapı & Yol'], + [/inşaat|yapım işi|onarım|tadilat|güçlendirme|çatı|cephe|restorasyon|bakım onarım/i, 'İnşaat & Bakım'], + [/kırtasiye|matbaa|baskı|kağıt|toner|kartuş|fotokopi|büro malzeme/i, 'Kırtasiye & Büro'], + [/mobilya|masa|sandalye|dolap|okul sırası|büro mobilya|raf /i, 'Mobilya & Donatı'], + [/giyim|tekstil|elbise|üniforma|kıyafet|ayakkabı|çamaşır|kumaş|battaniye/i, 'Tekstil & Giyim'], + [/akaryakıt|motorin|benzin|lpg|yakıt alım/i, 'Akaryakıt & Yakıt'], + [/araç kirala|taşıt kirala|servis hizmet|personel taşıma|öğrenci taşıma|nakliye|lojistik/i, 'Taşımacılık & Kiralama'], + [/araç|otomotiv|lastik|yedek parça|otobüs|kamyon|iş makinesi|greyder|ekskavatör/i, 'Otomotiv & İş Makinesi'], + [/elektrik|trafo|enerji|aydınlatma|jeneratör|güneş|solar|kablo|pano |ups /i, 'Elektrik & Enerji'], + [/doğalgaz|kalorifer|kazan|ısıtma|soğutma|klima|havalandırma|iklimlendirme|mekanik tesisat/i, 'Isıtma & İklimlendirme'], + [/asansör|yürüyen merdiven/i, 'Asansör & Yürüyen Sistem'], + [/su arıtma|arıtma tesis|pompa|sondaj|kuyu |su deposu/i, 'Su & Arıtma'], + [/tarım|tohum|gübre|fide|zirai|hayvan|yem |veteriner|sera /i, 'Tarım & Hayvancılık'], + [/peyzaj|park bahçe|ağaç|çim |fidan|oyun grubu|kent mobilya/i, 'Peyzaj & Park'], + [/eğitim hizmet|kurs|seminer|danışmanlık|müşavirlik|etüt proje|mühendislik hizmet|kontrollük/i, 'Danışmanlık & Proje'], + [/reklam|tanıtım|organizasyon|fuar|basın|medya|prodüksiyon/i, 'Reklam & Organizasyon'], + [/sigorta|zorunlu trafik|kasko/i, 'Sigorta'], + [/kitap|yayın|kütüphane|arşiv|dijitalleştirme/i, 'Yayın & Arşiv'], + [/spor|halı saha|tribün|fitness|kondisyon/i, 'Spor Tesisleri'], + [/laboratuvar cihaz|analiz cihaz|ölçüm cihaz|test cihaz/i, 'Laboratuvar & Ölçüm'], ]; + const INSTITUTION_RULES = [ [/büyükşehir belediye/i, 'Büyükşehir Belediyeleri'], [/belediye/i, 'Belediyeler'], @@ -102,6 +124,13 @@ const INSTITUTION_RULES = [ [/a\.ş\.|genel müdürlüğü|işletme/i, 'Kamu Şirketleri'], ]; +// Arayüzün kullandığı sektör listesi buradan türetilir — elle tutulan ikinci +// bir liste tutmayız, böylece iki taraf birbirinden ayrı düşemez. +const SEKTOR_ETIKETLERI = [...new Set(SECTOR_RULES.map(([, ad]) => ad))].sort((a, b) => + a.localeCompare(b, 'tr')); +const KURUM_ETIKETLERI = [...new Set(INSTITUTION_RULES.map(([, ad]) => ad))].sort((a, b) => + a.localeCompare(b, 'tr')); + function classify(rules, text, fallback) { for (const [re, label] of rules) if (re.test(text)) return label; return fallback; @@ -965,29 +994,39 @@ const server = http.createServer(async (req, res) => { } if (url.pathname === '/api/health') return json(res, 200, { ok: true }); + if (url.pathname === '/api/meta/filtreler') { + return json(res, 200, { + success: true, + sektorler: SEKTOR_ETIKETLERI, + kurumTipleri: KURUM_ETIKETLERI, + }); + } + if (url.pathname === '/api/health/havuz') { - const gecmis = havuzGecmisi(Number(url.searchParams.get('limit')) || 60); - const son = gecmis[0] || null; - // Son 7 günün en yükseği — bugünkü sayı buna göre kıyaslanır. - const haftaBas = new Date(Date.now() - 7 * 864e5).toISOString(); - const hafta = gecmis.filter((r) => r.zaman >= haftaBas && r.kaynak_toplam != null); - const zirve = hafta.reduce((m, r) => Math.max(m, r.kaynak_toplam || 0), 0); - // Referans ölçüt ORTALAMA; zirve yalnızca bağlam için gösterilir. - const ortalama = hafta.length ? Math.round(hafta.reduce((t, r) => t + r.kaynak_toplam, 0) / hafta.length) : null; - const yeterliVeri = hafta.length >= 5; + const ozet = havuzOzet(); + if (!ozet) return json(res, 200, { success: true, son: null, olcumSayisi: 0, gecmis: [] }); + const { son, hafta, ay } = ozet; + const oran = (ort, yeterli) => + yeterli && son?.kaynak_toplam && ort ? Number((son.kaynak_toplam / ort).toFixed(3)) : null; + const yeterliVeri = hafta.adet >= 5; + const yeterliVeri30 = ay.adet >= 20; return json(res, 200, { success: true, son, - haftalikOrtalama: ortalama, - haftalikZirve: zirve || null, - olcumAdedi: hafta.length, + // 7 gün: günlük dalgalanma + haftalikOrtalama: hafta.ortalama, + haftalikZirve: hafta.zirve, + olcumAdedi: hafta.adet, yeterliVeri, - ortalamayaOran: - yeterliVeri && son?.kaynak_toplam && ortalama - ? Number((son.kaynak_toplam / ortalama).toFixed(3)) - : null, - olcumSayisi: gecmis.length, - gecmis, + ortalamayaOran: oran(hafta.ortalama, yeterliVeri), + // 30 gün: kalıcı erime + aylikOrtalama: ay.ortalama, + aylikZirve: ay.zirve, + aylikDip: ay.dip, + olcumAdedi30: ay.adet, + yeterliVeri30, + aylikOrtalamayaOran: oran(ay.ortalama, yeterliVeri30), + gecmis: havuzGecmisi(Number(url.searchParams.get('limit')) || 60), }); } diff --git a/src/components/DetailedSearchModal.jsx b/src/components/DetailedSearchModal.jsx index e1aa40d..3b051e7 100644 --- a/src/components/DetailedSearchModal.jsx +++ b/src/components/DetailedSearchModal.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useApp } from '../context/AppContext'; -import { SECTORS_LIST, CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants'; +import { CITIES_LIST, INSTITUTION_TYPES_LIST } from '../data/constants'; import { SlidersHorizontal, Search, RotateCcw, X } from 'lucide-react'; export const DetailedSearchModal = () => { @@ -13,8 +13,10 @@ export const DetailedSearchModal = () => { setSelectedCity, selectedAlimTuru, setSelectedAlimTuru, - selectedSektor, - setSelectedSektor, + selectedSektorler, + setSelectedSektorler, + toggleSektor, + sektorListesi, selectedKurumTipi, setSelectedKurumTipi, onlyValidOnly, @@ -40,7 +42,7 @@ export const DetailedSearchModal = () => { setSearchQuery(''); setSelectedCity(''); setSelectedAlimTuru(''); - setSelectedSektor(''); + setSelectedSektorler([]); setSelectedKurumTipi(''); setIknQuery(''); setKurumQuery(''); @@ -116,16 +118,32 @@ export const DetailedSearchModal = () => { /> -
Hazır sektörler: