From df89eaac332f007a5f6eb03f821790a86f3114bc Mon Sep 17 00:00:00 2001 From: ht Date: Thu, 3 Sep 2026 22:58:13 +0300 Subject: [PATCH] feat: takip izleme (zeyilname + sonuc bildirimi), hazirlik gorevi, belge sure uyarisi, Telegram, OCR, yasakli sorgu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28 depo taramasindan alinan alti ozellik. Kod kopyalanmadi (depolar lisanssiz), fikirler uygulandi. 1) Zeyilname / duzeltme ilani takibi (server/zeyilname.mjs): takipteki IKN'ler sunucuya yazilir; 3 saatte bir EKAP detayindan ilan sayisi ve ihale tarihi karsilastirilir, degisince "ne degisti" bildirimi (uygulama ici + e-posta + Telegram). Test: anlik kaydi eskitilince tur "yeni ilan yayimlandi; ihale tarihi 01.01.2026 → 08.10.2026" bildirimi uretti. 2) Sonuc bildirimi: takipteki ihale sonuclaninca "X firmasi Y'ye aldi, %Z kirim". Yalniz takiptekiler; kismi ihalede oran yazilmaz. 3) Ihaleden goreve (MOBIT-DURT): detayda "Hazirlik Gorevi Ac" → teslim tarihi ihale saati, pano "Teklif Hazirlaniyor", derin analizden gelen belge kontrol listesi gorevde; Kanban kartinda geri sayim rozeti. 4) Sirket belgesi sure takibi: Belge Kasasi'nda son kullanma tarihi girisi, dolmus / 30 gun icinde dolacak uyarisi; girise gunde bir bildirim. PATCH /api/me/documents/meta. 5) Telegram (server/telegram.mjs): TELEGRAM_BOT_TOKEN sunucuda; kullanici 6 haneli kodu bota yazar, "Dogrula" → getUpdates ile chat id baglanir. Arayuzde adim adim kurulum rehberi (BotFather → env → Redeploy → kod). Ihale ozeti, zeyilname ve sonuc bildirimleri Telegram'a da gider. 6) OCR (tesseract.js, tur): PDF metin katmani bossa (taranmis) ilk 8 sayfa OCR. Olcum: sentetik cetvelde guven 87, ~0,3 sn/sayfa; IKN ve kalemler dogru okundu. Kalem havuzu ve derin analiz artik taranmis PDF'ten de besleniyor. 7) Rakip Radari'nda KIK yasakli sorgusu baglantisi (firma adi panoya kopyalanir; EKAP sorguyu API'den vermiyor). Sinir deger hesaplayici KURULMADI: Kamu Ihale Genel Tebligi'ne ulasilamadi (mevzuat.gov.tr / ihale.gov.tr bu makineden erisilemiyor); formul kaynaksiz yazilmadi. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 114 +++++++++++- package.json | 3 +- server/history-store.mjs | 8 + server/index.js | 62 +++++++ server/notifier.mjs | 10 + server/scheduler.mjs | 11 ++ server/telegram.mjs | 64 +++++++ server/user-store.mjs | 18 ++ server/zeyilname.mjs | 186 +++++++++++++++++++ src/components/KanbanBoard.jsx | 10 + src/components/TenderDetailModal.jsx | 35 +++- src/components/tools/CompetitorRadar.jsx | 6 + src/components/tools/DocumentVault.jsx | 42 ++++- src/components/tools/NotifySubscriptions.jsx | 4 + src/components/tools/TelegramBaglanti.jsx | 96 ++++++++++ src/context/AppContext.jsx | 86 ++++++++- src/services/account.js | 14 ++ src/services/pdfText.js | 43 ++++- 18 files changed, 802 insertions(+), 10 deletions(-) create mode 100644 server/telegram.mjs create mode 100644 server/zeyilname.mjs create mode 100644 src/components/tools/TelegramBaglanti.jsx diff --git a/package-lock.json b/package-lock.json index 4d8a6ac..d79bb81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "pdfjs-dist": "^6.2.108", "react": "^18.3.1", "react-dom": "^18.3.1", - "recharts": "^2.15.0" + "recharts": "^2.15.0", + "tesseract.js": "^6.0.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -1927,6 +1928,12 @@ "node": ">=6.0.0" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -2403,6 +2410,12 @@ "node": ">=8.0.0" } }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2418,6 +2431,12 @@ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "license": "MIT" }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2811,6 +2830,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -2830,6 +2869,15 @@ "node": ">=0.10.0" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/pako": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", @@ -3097,8 +3145,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/rgbcolor": { "version": "1.0.1", @@ -3226,6 +3273,30 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tesseract.js": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz", + "integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^6.0.0", + "wasm-feature-detect": "^1.2.11", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.1.2.tgz", + "integrity": "sha512-pv4GjmramjdObhDyR1q85Td8X60Puu/lGQn7Kw2id05LLgHhAcWgnz6xSdMCSxBMWjQDmMyDXPTC2aqADdpiow==", + "license": "Apache-2.0" + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -3259,6 +3330,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", @@ -3397,12 +3474,43 @@ } } }, + "node_modules/wasm-feature-detect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index faa0218..10a1b64 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "pdfjs-dist": "^6.2.108", "react": "^18.3.1", "react-dom": "^18.3.1", - "recharts": "^2.15.0" + "recharts": "^2.15.0", + "tesseract.js": "^6.0.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/server/history-store.mjs b/server/history-store.mjs index a256bbb..c156802 100644 --- a/server/history-store.mjs +++ b/server/history-store.mjs @@ -240,6 +240,14 @@ function kalemAdlari(ham) { } } +/** Bir İKN'nin sonuç satırları (kısım başına) — takipteki ihale sonuçlandı mı? */ +export function resultsByIkn(ikn) { + const db = openDb(); + if (!db || !ikn) return []; + try { + return db.prepare('SELECT * FROM sonuclar WHERE ikn=? ORDER BY kisim').all(ikn).map(rowToTender); + } catch { return []; } finally { db.close(); } +} export function readHistory(limit = 500, { firma, idare, il } = {}) { const db = openDb(); if (!db) return { tenders: [], totalCount: 0, available: false }; diff --git a/server/index.js b/server/index.js index 7cbf8c0..ae81a91 100644 --- a/server/index.js +++ b/server/index.js @@ -26,6 +26,8 @@ import { kalemleriCikar } from './kalem-cikar.mjs'; import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs'; import { iknCikar } from './kalem-cikar.mjs'; import { bonusHesapla, gommeDurumu, arkaPlandaIsit } from './uyum-gomme.mjs'; +import { telegramConfigured, botAdi, kodIleBagla, sendTelegram } from './telegram.mjs'; +import { bildirimleriOku, okunduIsaretle, zeyilnameDurumu, zeyilnameTara, sonucTara } from './zeyilname.mjs'; // Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma. const urunAramaCache = new Map(); @@ -818,6 +820,52 @@ const server = http.createServer(async (req, res) => { if (url.pathname === '/api/health/uyum') { return json(res, 200, { success: true, ...gommeDurumu() }); } + // --- Telegram bağlantısı (kullanıcı kodu bota yazar, biz getUpdates'te görürüz) --- + if (url.pathname.startsWith('/api/me/telegram')) { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + const tg = store.getData(user.email, 'telegram'); + if (req.method === 'GET') { + return json(res, 200, { success: true, yapilandirilmis: telegramConfigured(), bot: await botAdi(), bagli: !!tg?.chatId, ad: tg?.ad || null, baglanma: tg?.baglanma || null }); + } + if (req.method === 'DELETE') { store.setData(user.email, 'telegram', null); return json(res, 200, { success: true }); } + if (req.method === 'POST') { + try { + const body = await readBody(req); + if (body.islem === 'test') { + if (!tg?.chatId) return json(res, 400, { success: false, error: 'Telegram bağlı değil' }); + await sendTelegram(tg.chatId, '✅ İhaleist bağlantı testi — bildirimler bu sohbete gelecek.'); + return json(res, 200, { success: true }); + } + const kod = String(body.kod || '').replace(/\D/g, ''); + if (kod.length !== 6) return json(res, 400, { success: false, error: '6 haneli kod gerekli' }); + const bulunan = await kodIleBagla(kod); + if (!bulunan) return json(res, 404, { success: false, error: 'Kod bota gönderilmiş görünmüyor. Önce bota yazın, sonra doğrulayın.' }); + store.setData(user.email, 'telegram', { ...bulunan, baglanma: new Date().toISOString() }); + await sendTelegram(bulunan.chatId, `✅ İhaleist hesabınız (${user.email}) bu sohbete bağlandı.`).catch(() => {}); + return json(res, 200, { success: true, ad: bulunan.ad }); + } catch (e) { return json(res, 502, { success: false, error: e.message }); } + } + } + // --- Takip bildirimleri (zeyilname / sonuç) --- + if (url.pathname === '/api/me/bildirimler') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + if (req.method === 'POST') { + const body = await readBody(req); + return json(res, 200, { success: true, okundu: okunduIsaretle(user.email, body.idler || []) }); + } + return json(res, 200, { success: true, bildirimler: bildirimleriOku(user.email) }); + } + if (url.pathname === '/api/health/takip') { + return json(res, 200, { success: true, ...zeyilnameDurumu(), telegram: telegramConfigured() }); + } + if (url.pathname === '/api/admin/takip/tara' && req.method === 'POST') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user?.isAdmin) return json(res, 403, { success: false, error: 'yönetici gerekli' }); + const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]); + return json(res, 200, { success: true, zeyilname: z, sonuc: so }); + } if (url.pathname === '/api/health/kalem') { return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() }); } @@ -971,6 +1019,20 @@ const server = http.createServer(async (req, res) => { return json(res, 500, { success: false, error: 'dosya okunamadı' }); } } + // Belge üst verisi: son kullanma tarihi (süre takibi) / kategori + if (url.pathname === '/api/me/documents/meta' && req.method === 'PATCH') { + const user = userFromAuthHeader(req.headers['authorization']); + if (!user) return json(res, 401, { success: false, error: 'oturum yok' }); + try { + const body = await readBody(req); + if (!body.id) return json(res, 400, { success: false, error: 'id gerekli' }); + const sonKullanma = body.sonKullanma ? String(body.sonKullanma).slice(0, 10) : null; + if (sonKullanma && !/^\d{4}-\d{2}-\d{2}$/.test(sonKullanma)) return json(res, 400, { success: false, error: 'tarih YYYY-AA-GG olmalı' }); + const ok = store.updateDocument(user.email, String(body.id), { sonKullanma, kategori: body.kategori ? String(body.kategori).slice(0, 60) : null }); + if (!ok) return json(res, 404, { success: false, error: 'belge bulunamadı' }); + return json(res, 200, { success: true, belgeler: store.listDocuments(user.email) }); + } catch (e) { return json(res, 400, { success: false, error: e.message }); } + } // Belge metni — docx/xlsx/zip için. PDF tarayıcıda (pdfjs) okunuyor; // bu türler için tarayıcıda çözücü yok, baytlar zaten burada duruyor. if (url.pathname === '/api/me/documents/text') { diff --git a/server/notifier.mjs b/server/notifier.mjs index 8a40089..f2a6d02 100644 --- a/server/notifier.mjs +++ b/server/notifier.mjs @@ -10,6 +10,8 @@ import { list, markSent, available } from './notify-store.mjs'; import { emailConfigured, sendEmail, tenderDigestHtml } from './email.mjs'; +import { telegramConfigured, sendTelegram, tgKacir } from './telegram.mjs'; +import { getData } from './user-store.mjs'; const norm = (v) => String(v || '').trim().toLocaleUpperCase('tr-TR'); @@ -96,6 +98,14 @@ export async function bildirimleriIsle(tenderGetir, { zorla = false } = {}) { subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`, html, }); + // Telegram bağlıysa aynı özet oraya da (kısa biçim) + try { + const tg = telegramConfigured() ? getData(a.email, 'telegram') : null; + if (tg?.chatId) { + const satirlar = gonderilecek.slice(0, 10).map((t) => `• ${tgKacir(t.baslik)}\n ${tgKacir(t.kurum || '')} · ${tgKacir(t.tarih || '')} · ${tgKacir(t.ikn || '')}`); + await sendTelegram(tg.chatId, `İhaleist — ${gonderilecek.length} yeni ihale (${tgKacir(kriterMetni)})\n\n${satirlar.join('\n')}${gonderilecek.length > 10 ? `\n… +${gonderilecek.length - 10} ihale` : ''}`); + } + } catch (e) { console.warn(`[notifier] telegram ${a.email}: ${e.message}`); } markSent(a.id, eslesen.map((t) => t.id)); gonderilen++; console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`); diff --git a/server/scheduler.mjs b/server/scheduler.mjs index 95ee388..fe28faa 100644 --- a/server/scheduler.mjs +++ b/server/scheduler.mjs @@ -15,6 +15,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { historyStats } from './history-store.mjs'; import { bildirimleriIsle } from './notifier.mjs'; +import { zeyilnameTara, sonucTara } from './zeyilname.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -179,6 +180,16 @@ export function startScheduler({ tenderGetir } = {}) { setInterval(dtTara, Math.max(1, dtSaat) * 3600 * 1000).unref?.(); } + // Takipteki ihaleler: zeyilname/düzeltme ilanı + sonuç bildirimi + if (process.env.TAKIP_ENABLED === '0') { + console.log('[scheduler] takip izleme devre dışı (TAKIP_ENABLED=0)'); + } else { + const tSaat = Number(process.env.TAKIP_INTERVAL_SAAT || 3); + const takipTuru = () => Promise.all([zeyilnameTara(), sonucTara()]).catch((e) => console.warn('[scheduler] takip:', e.message)); + console.log(`[scheduler] takip izleme (zeyilname/sonuç) her ${tSaat} saatte bir`); + setTimeout(takipTuru, 8 * 60 * 1000).unref?.(); + setInterval(takipTuru, Math.max(1, tSaat) * 3600 * 1000).unref?.(); + } // Otomatik e-posta bildirimi if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) { console.log('[scheduler] otomatik e-posta bildirimi devre dışı'); diff --git a/server/telegram.mjs b/server/telegram.mjs new file mode 100644 index 0000000..b12fdbe --- /dev/null +++ b/server/telegram.mjs @@ -0,0 +1,64 @@ +/** + * Telegram bildirim kanalı — Bot API, bağımlılık yok. + * + * KURULUM (kullanıcı/yönetici): BotFather'da bot oluşturulur, tokenı + * TELEGRAM_BOT_TOKEN env'ine girilir. Her kullanıcı uygulamadan aldığı 6 haneli + * kodu bota yazar; sunucu getUpdates ile kodu görüp chat id'yi kullanıcıya + * bağlar. Token bizde tutulmaz, sohbet kimliği kullanıcı verisinde durur. + */ + +const API = () => `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`; + +export function telegramConfigured() { + return Boolean(process.env.TELEGRAM_BOT_TOKEN); +} + +let botOnbellek = { ad: null, zaman: 0 }; +/** Bot kullanıcı adı (t.me/) — arayüzde bağlantı vermek için. */ +export async function botAdi() { + if (!telegramConfigured()) return null; + if (botOnbellek.ad && Date.now() - botOnbellek.zaman < 3600e3) return botOnbellek.ad; + try { + const r = await fetch(`${API()}/getMe`, { signal: AbortSignal.timeout(10000) }); + const d = await r.json(); + if (d.ok) { botOnbellek = { ad: d.result.username, zaman: Date.now() }; return d.result.username; } + } catch { /* aşağıda null */ } + return null; +} + +export async function sendTelegram(chatId, text) { + if (!telegramConfigured() || !chatId) return false; + const r = await fetch(`${API()}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, text: String(text).slice(0, 4000), parse_mode: 'HTML', disable_web_page_preview: true }), + signal: AbortSignal.timeout(15000), + }); + const d = await r.json().catch(() => ({})); + if (!d.ok) throw new Error(d.description || `Telegram HTTP ${r.status}`); + return true; +} + +/** + * Kullanıcının bota yazdığı 6 haneli kodu son güncellemelerde arar. + * Bulursa { chatId, ad } döner. Long-polling yok; kullanıcı "doğrula" dediğinde + * tek getUpdates çağrısı yeterli (Telegram son 24 saati tutar). + */ +export async function kodIleBagla(kod) { + if (!telegramConfigured()) throw new Error('Sunucuda TELEGRAM_BOT_TOKEN tanımlı değil'); + const r = await fetch(`${API()}/getUpdates?limit=100`, { signal: AbortSignal.timeout(15000) }); + const d = await r.json().catch(() => ({})); + if (!d.ok) throw new Error(d.description || 'getUpdates başarısız'); + const aranan = String(kod).trim(); + for (const u of [...(d.result || [])].reverse()) { + const m = u.message || u.edited_message; + if (!m?.text) continue; + if (m.text.replace(/\D/g, '').includes(aranan)) { + return { chatId: String(m.chat.id), ad: [m.chat.first_name, m.chat.last_name].filter(Boolean).join(' ') || m.chat.username || null }; + } + } + return null; +} + +/** HTML kaçışı — ihale adları '<' '&' içerebiliyor. */ +export const tgKacir = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); diff --git a/server/user-store.mjs b/server/user-store.mjs index 5ddc4a5..6dccc93 100644 --- a/server/user-store.mjs +++ b/server/user-store.mjs @@ -155,6 +155,10 @@ const IZINLI_ANAHTARLAR = new Set([ 'favori_kurumlar', 'ekip', 'saved_filters', 'reminder', 'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban', 'iptal_geri_bildirim', + 'firma_profil', // yetkinlikler + uyum eşiği + 'takip', // takipteki İKN'ler — zeyilname/sonuç izleme sunucuda çalışır + 'gorevler', // ihaleden açılan hazırlık görevleri + 'telegram', // { chatId, ad, baglanma } ]); export function getData(email, anahtar) { @@ -244,6 +248,20 @@ export function getDocument(email, id) { return withDb((db) => db.prepare('SELECT * FROM belgeler WHERE id=? AND email=?').get(id, email) || null); } +/** Belge üst verisi: son kullanma / kategori (süre takibi için). */ +export function updateDocument(email, id, { sonKullanma, kategori } = {}) { + return withDb((db) => { + const r = db.prepare(`UPDATE belgeler SET + son_kullanma = COALESCE(?, son_kullanma), + kategori = COALESCE(?, kategori) + WHERE id=? AND email=?`).run(sonKullanma ?? null, kategori ?? null, id, email); + return r.changes > 0; + }, false); +} +/** Arka plan işleri için tüm kullanıcılar (zeyilname/sonuç izleme). */ +export function listUsers() { + return withDb((db) => db.prepare('SELECT email, ad FROM kullanicilar').all(), []); +} export function deleteDocument(email, id) { const rec = getDocument(email, id); if (!rec) return false; diff --git a/server/zeyilname.mjs b/server/zeyilname.mjs new file mode 100644 index 0000000..ff1e8a7 --- /dev/null +++ b/server/zeyilname.mjs @@ -0,0 +1,186 @@ +/** + * Takipteki ihaleler için sunucu tarafı izleme: + * 1) Zeyilname / düzeltme ilanı: EKAP'ta ilan sayısı ya da ihale tarihi + * değişince "ne değişti" bildirimi (ilan tipi 6 = Düzeltme). + * 2) Sonuç: takipteki ihale sonuçlanınca "X firması Y'ye aldı, %Z kırım". + * Yalnız takipteki ihaleler bildirilir — bülteni ikinci kez göndermek + * kimseye yaramaz (fikir: erenulutas0/MOBIT-DURT). + * + * Bildirimler takip.sqlite'a yazılır; istemci 5 dk'da bir çeker, ayrıca + * e-posta ve (bağlıysa) Telegram'a gider. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import * as store from './user-store.mjs'; +import { findByIkn } from './documents.mjs'; +import { getIhaleDetay } from './ekap-v2.mjs'; +import { resultsByIkn } from './history-store.mjs'; +import { emailConfigured, sendEmail } from './email.mjs'; +import { sendTelegram, telegramConfigured, tgKacir } from './telegram.mjs'; + +let DatabaseSync = null; +try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* devre dışı */ } + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = path.join(__dirname, 'data', 'takip.sqlite'); +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS takip_anlik ( + ikn TEXT PRIMARY KEY, ilan_sayisi INTEGER, son_ilan_tarihi TEXT, ihale_tarih TEXT, + ihale_adi TEXT, idare TEXT, zaman TEXT +); +CREATE TABLE IF NOT EXISTS bildirimler ( + id TEXT PRIMARY KEY, email TEXT NOT NULL, tur TEXT NOT NULL, ikn TEXT, baslik TEXT, + metin TEXT, zaman TEXT NOT NULL, okundu INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_bild_email ON bildirimler(email, okundu); +CREATE TABLE IF NOT EXISTS sonuc_bildirildi (ikn TEXT, email TEXT, PRIMARY KEY (ikn, email)); +`; +let db = null; +function open() { + if (db) return db; + if (!DatabaseSync) return null; + fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + db = new DatabaseSync(DB_PATH); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec(SCHEMA); + return db; +} +export function available() { return !!DatabaseSync; } + +const TAKIP_LIMIT = Number(process.env.ZEYILNAME_TUR_LIMIT || 150); +let calisiyor = false; +let sonTur = null; +export function zeyilnameDurumu() { return { calisiyor, sonTur }; } + +/** Tüm kullanıcıların takip listelerinden İKN → [email] haritası. */ +function takipHaritasi() { + const harita = new Map(); + for (const u of store.listUsers()) { + const liste = store.getData(u.email, 'takip'); + if (!Array.isArray(liste)) continue; + for (const ikn of liste) { + const k = String(ikn); + if (!/^\d{4}\/\d+$/.test(k)) continue; // yalnızca EKAP İKN'leri + if (!harita.has(k)) harita.set(k, new Set()); + harita.get(k).add(u.email); + } + } + return harita; +} + +export function bildirimYaz({ email, tur, ikn, baslik, metin }) { + const d = open(); if (!d) return null; + const id = crypto.randomUUID(); + d.prepare('INSERT INTO bildirimler (id,email,tur,ikn,baslik,metin,zaman) VALUES (?,?,?,?,?,?,?)') + .run(id, email, tur, ikn || null, baslik, metin, new Date().toISOString()); + return id; +} +export function bildirimleriOku(email, { limit = 50 } = {}) { + const d = open(); if (!d) return []; + return d.prepare('SELECT * FROM bildirimler WHERE email=? ORDER BY zaman DESC LIMIT ?').all(email, limit); +} +export function okunduIsaretle(email, idler) { + const d = open(); if (!d || !idler?.length) return 0; + const st = d.prepare('UPDATE bildirimler SET okundu=1 WHERE email=? AND id=?'); + let n = 0; for (const id of idler) n += st.run(email, id).changes; return n; +} + +async function gonder(email, baslik, metinHtml, metinDuz) { + try { + const tg = store.getData(email, 'telegram'); + if (telegramConfigured() && tg?.chatId) await sendTelegram(tg.chatId, `${tgKacir(baslik)}\n${tgKacir(metinDuz)}`); + } catch (e) { console.warn('[takip] telegram:', e.message); } + try { + if (emailConfigured()) await sendEmail({ to: email, subject: `İhaleist — ${baslik}`, html: `

${metinHtml}

` }); + } catch (e) { console.warn('[takip] e-posta:', e.message); } +} + +/** Zeyilname/düzeltme ilanı ve ihale tarihi değişikliği. */ +export async function zeyilnameTara() { + const d = open(); if (!d || calisiyor) return { atlandi: true }; + calisiyor = true; + const bas = Date.now(); + let bakilan = 0, degisen = 0, hata = 0; + try { + const harita = takipHaritasi(); + const iknler = [...harita.keys()].slice(0, TAKIP_LIMIT); + const oku = d.prepare('SELECT * FROM takip_anlik WHERE ikn=?'); + const yaz = d.prepare(`INSERT INTO takip_anlik (ikn,ilan_sayisi,son_ilan_tarihi,ihale_tarih,ihale_adi,idare,zaman) + VALUES (?,?,?,?,?,?,?) ON CONFLICT(ikn) DO UPDATE SET ilan_sayisi=excluded.ilan_sayisi, + son_ilan_tarihi=excluded.son_ilan_tarihi, ihale_tarih=excluded.ihale_tarih, zaman=excluded.zaman`); + for (const ikn of iknler) { + try { + const t = await findByIkn(ikn); + const det = await getIhaleDetay(t.id); + const ilanlar = det?.ilanList || []; + const yeni = { + ilan_sayisi: ilanlar.length, + son_ilan_tarihi: ilanlar.map((i) => i.ilanTarihi).filter(Boolean).sort().pop() || null, + ihale_tarih: det?.ihaleBilgi?.ihaleTarihSaat || t.ihaleTarihSaat || null, + }; + const eski = oku.get(ikn); + bakilan++; + if (eski) { + const farklar = []; + if (yeni.ilan_sayisi > eski.ilan_sayisi) { + const sonIlan = ilanlar[ilanlar.length - 1]; + farklar.push(`yeni ilan yayımlandı (${sonIlan?.ilanTipAciklama || 'ilan'}${sonIlan?.ilanTarihi ? ', ' + sonIlan.ilanTarihi : ''})`); + } + if (yeni.ihale_tarih && eski.ihale_tarih && yeni.ihale_tarih !== eski.ihale_tarih) { + farklar.push(`ihale tarihi ${eski.ihale_tarih} → ${yeni.ihale_tarih}`); + } + if (farklar.length) { + degisen++; + const baslik = `Zeyilname/düzeltme: ${t.ihaleAdi?.slice(0, 80) || ikn}`; + const metin = `${ikn} · ${t.idareAdi || ''}\n${farklar.join('; ')}`; + for (const email of harita.get(ikn)) { + bildirimYaz({ email, tur: 'zeyilname', ikn, baslik, metin }); + await gonder(email, baslik, metin.replace(/\n/g, '
'), metin); + } + } + } + yaz.run(ikn, yeni.ilan_sayisi, yeni.son_ilan_tarihi, yeni.ihale_tarih, t.ihaleAdi || null, t.idareAdi || null, new Date().toISOString()); + await new Promise((r) => setTimeout(r, 300)); + } catch (e) { hata++; } + } + } finally { + calisiyor = false; + sonTur = { zaman: new Date().toISOString(), bakilan, degisen, hata, sureSn: Math.round((Date.now() - bas) / 1000) }; + console.log(`[takip] zeyilname turu: ${bakilan} ihale, ${degisen} değişiklik, ${hata} hata`); + } + return sonTur; +} + +/** Takipteki ihale sonuçlandıysa (geçmiş taraması sonuç yazdıysa) bildir. */ +export async function sonucTara() { + const d = open(); if (!d) return { atlandi: true }; + const harita = takipHaritasi(); + const bak = d.prepare('SELECT 1 FROM sonuc_bildirildi WHERE ikn=? AND email=?'); + const yaz = d.prepare('INSERT OR IGNORE INTO sonuc_bildirildi (ikn,email) VALUES (?,?)'); + let bildirilen = 0; + for (const [ikn, emailler] of harita) { + const sonuclar = resultsByIkn(ikn).filter((r) => r.kazananFirma); + if (!sonuclar.length) continue; + const r = sonuclar[0]; + const bedel = Number(String(r.sozlesmeBedeli || '').replace(/[^\d,]/g, '').replace(',', '.')); + const maliyet = Number(String(r.yaklasikMaliyet || '').replace(/[^\d,]/g, '').replace(',', '.')); + // Kısmi ihalede maliyet tüm ihale, bedel kısım — oran yazılmaz + const kirim = sonuclar.length === 1 && bedel > 0 && maliyet > 0 && bedel <= maliyet * 1.5 + ? Math.round((1 - bedel / maliyet) * 100) : null; + const baslik = `Takip ettiğiniz ihale sonuçlandı: ${(r.baslik || ikn).slice(0, 70)}`; + const metin = `${r.kazananFirma} ${r.sozlesmeBedeli ? r.sozlesmeBedeli + ' bedelle' : ''} aldı` + + (kirim != null ? `, %${kirim} kırım` : '') + (sonuclar.length > 1 ? ` (${sonuclar.length} kısım)` : '') + ` · ${ikn}`; + for (const email of emailler) { + if (bak.get(ikn, email)) continue; + bildirimYaz({ email, tur: 'sonuc', ikn, baslik, metin }); + await gonder(email, baslik, metin, metin); + yaz.run(ikn, email); + bildirilen++; + } + } + if (bildirilen) console.log(`[takip] ${bildirilen} sonuç bildirimi`); + return { bildirilen }; +} diff --git a/src/components/KanbanBoard.jsx b/src/components/KanbanBoard.jsx index e838a1d..c3b7c95 100644 --- a/src/components/KanbanBoard.jsx +++ b/src/components/KanbanBoard.jsx @@ -127,6 +127,16 @@ export const KanbanBoard = () => { #{t.ikn} + {t.gorev && (() => { + // Teslim = ihale saati; kalan gün geri sayım (gecikme eskalasyonu kendiliğinden) + const m = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?/.exec(t.gorev.teslim || ''); + if (!m) return null; + const kalan = Math.ceil((new Date(+m[3], +m[2] - 1, +m[1], +(m[4] || 23), +(m[5] || 59)) - Date.now()) / 86400000); + const renk = kalan < 0 ? 'bg-slate-200 text-slate-600' : kalan <= 2 ? 'bg-red-100 text-red-800' : kalan <= 7 ? 'bg-amber-100 text-amber-800' : 'bg-emerald-100 text-emerald-800'; + return + {kalan < 0 ? 'süresi geçti' : kalan === 0 ? 'BUGÜN' : `${kalan} gün`} + ; + })()} + {/* İhaleden göreve: teslim tarihi ihale saati; sonuç yayımlanınca bildirim */} + {tender.gorev ? ( + + ✓ Görev açık{tender.gorev.teslim ? ` · ${tender.gorev.teslim}` : ''} + + ) : ( + + )} + + ))} + + + )} + {/* Ortak havuzdan teklif cetveli kalemleri */} diff --git a/src/components/tools/CompetitorRadar.jsx b/src/components/tools/CompetitorRadar.jsx index 375ed44..7b905df 100644 --- a/src/components/tools/CompetitorRadar.jsx +++ b/src/components/tools/CompetitorRadar.jsx @@ -118,6 +118,12 @@ export const CompetitorRadar = () => { ⚔ ort. {f.ortTeklif} teklif )} + { try { navigator.clipboard?.writeText(f.name); } catch { /* yoksay */ } }} + title="KİK yasaklılar sorgusunu açar; firma adı panoya kopyalanır (EKAP sorguyu API'den vermiyor)" + className="bg-slate-100 text-slate-700 border border-slate-200 px-1.5 py-0.5 rounded font-bold hover:bg-slate-200"> + ⛔ yasaklı sorgula + {f.tekTeklifOrani != null && f.tekTeklifOrani > 0 && ( diff --git a/src/components/tools/DocumentVault.jsx b/src/components/tools/DocumentVault.jsx index 6168ffc..bbc19b9 100644 --- a/src/components/tools/DocumentVault.jsx +++ b/src/components/tools/DocumentVault.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useApp } from '../../context/AppContext'; -import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account'; -import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle } from 'lucide-react'; +import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt, updateDocumentMeta } from '../../services/account'; +import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle, CalendarClock } from 'lucide-react'; const KATEGORILER = ['Yasal Belgeler', 'Kalite Belgeleri', 'Mali Belgeler', 'Resmi Kayıtlar', 'Teknik Belgeler', 'Diğer']; @@ -82,6 +82,17 @@ export const DocumentVault = () => { if (topluRef.current) topluRef.current.value = ''; }; + // Şirket belgesi süre takibi: imza sirküleri, oda kayıt, borcu yoktur — + // hepsinin süresi dolar, kimse teklif hazırlanana kadar fark etmez. + const sureYaz = async (id, tarih) => { + setHata(null); + try { setDurum(await updateDocumentMeta(id, { sonKullanma: tarih || null })); } + catch (e) { setHata(e.message); } + }; + const kalanGun = (b) => (b.son_kullanma ? Math.ceil((new Date(b.son_kullanma).getTime() - Date.now()) / 86400000) : null); + const suresiDolan = (durum?.belgeler || []).filter((b) => kalanGun(b) != null && kalanGun(b) < 0); + const yakinda = (durum?.belgeler || []).filter((b) => kalanGun(b) != null && kalanGun(b) >= 0 && kalanGun(b) <= 30); + const sil = async (id) => { setBusy(true); setHata(null); try { setDurum(await deleteDocument(id)); } @@ -154,6 +165,22 @@ export const DocumentVault = () => { + {/* Süre uyarısı */} + {(suresiDolan.length > 0 || yakinda.length > 0) && ( +
+ +
+ {suresiDolan.length > 0 && ( +

Süresi dolmuş: {suresiDolan.map((b) => b.ad).join(', ')}

+ )} + {yakinda.length > 0 && ( +

30 gün içinde doluyor: {yakinda.map((b) => `${b.ad} (${kalanGun(b)} gün)`).join(', ')}

+ )} +

Teklif zarfına süresi dolmuş belge girerse teklif değerlendirme dışı kalır. Yenileyip yeni tarihi girin.

+
+
+ )} + {/* Toplu ihale dokümanı yükleme */}
@@ -226,6 +253,17 @@ export const DocumentVault = () => {

{new Date(d.olusturma).toLocaleString('tr-TR')}

+
+ +
+ ) : ( +
+

Bağlamak için 3 adım:

+
    +
  1. + Telegram'da botu açın:{' '} + {d.bot + ? @{d.bot} + : (bot adı alınamadı — yöneticiye sorun)} + {' '}ve Başlat'a basın. +
  2. +
  3. Bota şu kodu yazıp gönderin: {kod}
  4. +
  5. Sonra buraya dönüp Doğrula'ya basın.
  6. +
+ +
+ )} + + {mesaj && ( +
+ {mesaj.tur === 'ok' ? : } + {mesaj.metin} +
+ )} +
+ ); +}; diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index f7ad0ba..15824b4 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -4,7 +4,7 @@ import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum'; import { getToken } from '../services/auth'; import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi'; import { restoreSession, clearSession, getStoredUser } from '../services/auth'; -import { getAllUserData, setUserData, getPlan } from '../services/account'; +import { getAllUserData, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account'; import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants'; const AppContext = createContext(); @@ -563,6 +563,88 @@ export const AppProvider = ({ children }) => { })); }; + // Takipteki İKN'ler sunucuya yazılır: zeyilname/düzeltme ilanı ve sonuç + // bildirimi sunucudaki zamanlayıcıda üretiliyor, tarayıcı kapalıyken de. + const takipIknler = useMemo( + () => tenders.filter((t) => t.takipte || t.gorev).map((t) => t.ikn).filter(Boolean).sort(), + [tenders], + ); + const takipImza = takipIknler.join('|'); + useEffect(() => { + if (veriSenkron) sunucuyaYaz('takip', takipIknler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [takipImza, veriSenkron]); + + /** + * İhaleden hazırlık görevi: teslim tarihi ihale saatidir, panoya "Teklif + * Hazırlanıyor" kolonunda düşer, takibe alınır — sonuç yayımlanınca bildirim + * gelir. (Fikir: MOBIT-DURT "ilandan göreve, görevden sonuca".) + */ + const gorevAc = (tenderId, { kontrolListesi = [] } = {}) => { + setTenders((prev) => prev.map((t) => { + if (t.id !== tenderId) return t; + return { + ...t, + takipte: true, + kanbanStatus: t.kanbanStatus && t.kanbanStatus !== 'incelenecek' ? t.kanbanStatus : 'teklif_hazirlaniyor', + gorev: { + acildi: new Date().toISOString(), + teslim: t.tarih ? `${t.tarih}${t.saat ? ' ' + t.saat : ''}` : null, + kontrolListesi: kontrolListesi.map((k) => ({ ad: typeof k === 'string' ? k : k.document, tamam: false })), + }, + }; + })); + notify('Hazırlık görevi açıldı', 'Teslim tarihi ihale saati; pano "Teklif Hazırlanıyor" kolonunda.'); + }; + const gorevKontrol = (tenderId, idx) => { + setTenders((prev) => prev.map((t) => { + if (t.id !== tenderId || !t.gorev) return t; + const liste = t.gorev.kontrolListesi.map((k, i) => (i === idx ? { ...k, tamam: !k.tamam } : k)); + return { ...t, gorev: { ...t.gorev, kontrolListesi: liste } }; + })); + }; + + // Sunucu bildirimleri (zeyilname / sonuç): 5 dk'da bir çek, yenileri göster + useEffect(() => { + if (!veriSenkron) return; + let iptal = false; + const cek = async () => { + try { + const liste = await bildirimleriGetir(); + const yeni = liste.filter((b) => !b.okundu); + if (iptal || !yeni.length) return; + for (const b of yeni.slice(0, 5)) notify(b.baslik, b.metin); + await bildirimOkundu(yeni.map((b) => b.id)); + } catch { /* sessiz */ } + }; + cek(); + const z = setInterval(cek, 5 * 60 * 1000); + return () => { iptal = true; clearInterval(z); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [veriSenkron]); + + // Şirket belgesi süre uyarısı: süresi dolmuş / 30 gün içinde dolacak + // belgeler için günde bir kez bildirim (kimse teklif hazırlanana kadar + // fark etmiyor — fikir: MOBIT-DURT). + useEffect(() => { + if (!veriSenkron) return; + const bugun = new Date().toISOString().slice(0, 10); + try { if (localStorage.getItem('ihaleist_belge_uyari_gunu') === bugun) return; } catch { /* yoksay */ } + listDocuments().then((d) => { + const simdi = Date.now(); + const dolan = [], yakin = []; + for (const b of d?.belgeler || []) { + if (!b.son_kullanma) continue; + const kalan = Math.ceil((new Date(b.son_kullanma).getTime() - simdi) / 86400000); + if (kalan < 0) dolan.push(b.ad); else if (kalan <= 30) yakin.push(`${b.ad} (${kalan} gün)`); + } + if (dolan.length) notify(`${dolan.length} belgenin süresi dolmuş`, dolan.slice(0, 4).join(', ')); + if (yakin.length) notify(`${yakin.length} belgenin süresi 30 gün içinde doluyor`, yakin.slice(0, 4).join(', ')); + try { localStorage.setItem('ihaleist_belge_uyari_gunu', bugun); } catch { /* yoksay */ } + }).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [veriSenkron]); + const updateKanbanStatus = (tenderId, newStatus, assignedUser = null) => { setTenders(prev => prev.map(t => { if (t.id === tenderId) { @@ -695,6 +777,8 @@ export const AppProvider = ({ children }) => { removeTeamMember, toggleTrackTender, updateKanbanStatus, + gorevAc, + gorevKontrol, clearAllFilters, plan, planYenile, diff --git a/src/services/account.js b/src/services/account.js index 555d737..cdeae5e 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -88,3 +88,17 @@ export function formatBayt(b) { // --- Abonelik iptali --- export const cancelSubscription = (neden, aciklama) => iste('/api/billing/cancel', { method: 'POST', body: JSON.stringify({ neden, aciklama }) }); + +// --- Belge süre takibi --- +export const updateDocumentMeta = (id, { sonKullanma, kategori } = {}) => + iste('/api/me/documents/meta', { method: 'PATCH', body: JSON.stringify({ id, sonKullanma, kategori }) }); + +// --- Telegram bağlantısı --- +export const telegramDurum = () => iste('/api/me/telegram'); +export const telegramBagla = (kod) => iste('/api/me/telegram', { method: 'POST', body: JSON.stringify({ kod }) }); +export const telegramTest = () => iste('/api/me/telegram', { method: 'POST', body: JSON.stringify({ islem: 'test' }) }); +export const telegramSil = () => iste('/api/me/telegram', { method: 'DELETE' }); + +// --- Takip bildirimleri (zeyilname / sonuç) --- +export const bildirimleriGetir = () => iste('/api/me/bildirimler').then((d) => d.bildirimler || []); +export const bildirimOkundu = (idler) => iste('/api/me/bildirimler', { method: 'POST', body: JSON.stringify({ idler }) }); diff --git a/src/services/pdfText.js b/src/services/pdfText.js index f825cd2..d58a1a7 100644 --- a/src/services/pdfText.js +++ b/src/services/pdfText.js @@ -28,8 +28,47 @@ export async function extractPlainText(file) { return { text: text.trim(), pageCount: 1, usedPages: 1 }; } -export async function extractText(file) { +/** + * Taranmış (görüntü) PDF için OCR — tesseract.js, Türkçe dil paketi, + * tarayıcıda. Ölçüldü: sentetik teklif cetvelinde güven 87, sayfa başı + * ~0,3 sn (Node). Yalnızca metin katmanı boşsa devreye girer; ilk 8 sayfa. + */ +export async function ocrPdfText(file, { maxPages = 8, onProgress } = {}) { + const pdfjs = await import('pdfjs-dist'); + const workerUrl = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default; + pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + const { createWorker } = await import('tesseract.js'); + const doc = await pdfjs.getDocument({ data: await file.arrayBuffer() }).promise; + const pages = Math.min(doc.numPages, maxPages); + const worker = await createWorker('tur'); + const parts = []; + try { + for (let i = 1; i <= pages; i++) { + onProgress?.({ sayfa: i, toplam: pages }); + const page = await doc.getPage(i); + const viewport = page.getViewport({ scale: 2 }); // ~150 dpi: OCR için yeterli, hızlı + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; canvas.height = viewport.height; + await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; + const { data } = await worker.recognize(canvas); + parts.push(data.text); + } + } finally { + await worker.terminate(); + } + const text = parts.join('\n\n').replace(/[ \t]+/g, ' ').trim(); + return { text, pageCount: doc.numPages, usedPages: pages, ocr: true }; +} + +export async function extractText(file, { ocr = true, onProgress } = {}) { const name = (file.name || '').toLowerCase(); - if (name.endsWith('.pdf') || file.type === 'application/pdf') return extractPdfText(file); + if (name.endsWith('.pdf') || file.type === 'application/pdf') { + const r = await extractPdfText(file); + // Metin katmanı yoksa taranmış PDF'tir — OCR'a düş + if (ocr && (r.text || '').replace(/\s/g, '').length < 100) { + try { return await ocrPdfText(file, { onProgress }); } catch { return r; } + } + return r; + } return extractPlainText(file); }