Compare commits
3
Commits
17c781a9ba
...
df89eaac33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df89eaac33 | ||
|
|
1716b6365c | ||
|
|
2c70ed9d71 |
+1
-1
@@ -21,7 +21,7 @@ WORKDIR /app
|
|||||||
# Playwright + Chromium (yalnızca chromium; sistem bağımlılıklarıyla)
|
# Playwright + Chromium (yalnızca chromium; sistem bağımlılıklarıyla)
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
RUN npm init -y >/dev/null \
|
RUN npm init -y >/dev/null \
|
||||||
&& npm i --omit=dev playwright@1.62.1 \
|
&& npm i --omit=dev playwright@1.62.1 @huggingface/transformers@3 \
|
||||||
&& npx playwright install --with-deps chromium \
|
&& npx playwright install --with-deps chromium \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
|||||||
Generated
+111
-3
@@ -14,7 +14,8 @@
|
|||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.0"
|
"recharts": "^2.15.0",
|
||||||
|
"tesseract.js": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -1927,6 +1928,12 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.7",
|
"version": "4.28.7",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
|
||||||
@@ -2403,6 +2410,12 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
@@ -2418,6 +2431,12 @@
|
|||||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"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": "^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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.53",
|
"version": "2.0.53",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
|
||||||
@@ -2830,6 +2869,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/pako": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||||
@@ -3097,8 +3145,7 @@
|
|||||||
"version": "0.13.11",
|
"version": "0.13.11",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/rgbcolor": {
|
"node_modules/rgbcolor": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -3226,6 +3273,30 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -3259,6 +3330,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz",
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": "*"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.15.0"
|
"recharts": "^2.15.0",
|
||||||
|
"tesseract.js": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|||||||
@@ -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 } = {}) {
|
export function readHistory(limit = 500, { firma, idare, il } = {}) {
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
if (!db) return { tenders: [], totalCount: 0, available: false };
|
if (!db) return { tenders: [], totalCount: 0, available: false };
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { arsivMetni, sunucuOkuyabilir } from './office-text.mjs';
|
|||||||
import { kalemleriCikar } from './kalem-cikar.mjs';
|
import { kalemleriCikar } from './kalem-cikar.mjs';
|
||||||
import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs';
|
import { searchInDocuments, searchByOkas, okasAra } from './ekap-v2.mjs';
|
||||||
import { iknCikar } from './kalem-cikar.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.
|
// Ürün araması EKAP'a uzak çağrı yapar; aynı sorgu tekrarlanırsa kaynağı yorma.
|
||||||
const urunAramaCache = new Map();
|
const urunAramaCache = new Map();
|
||||||
@@ -799,6 +802,70 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return json(res, 502, { success: false, error: e.message });
|
return json(res, 502, { success: false, error: e.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
|
||||||
|
if (url.pathname === '/api/uyum/gomme' && 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, 64 * 1024);
|
||||||
|
const metin = String(body.profilMetni || '').trim();
|
||||||
|
if (metin.length < 10) return json(res, 400, { success: false, error: 'profil metni yok' });
|
||||||
|
const c = await getTenders(false);
|
||||||
|
const out = await bonusHesapla(metin, c.tenders || []);
|
||||||
|
return json(res, 200, { success: true, ...out });
|
||||||
|
} catch (e) {
|
||||||
|
return json(res, 500, { success: false, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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') {
|
if (url.pathname === '/api/health/kalem') {
|
||||||
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
|
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
|
||||||
}
|
}
|
||||||
@@ -952,6 +1019,20 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return json(res, 500, { success: false, error: 'dosya okunamadı' });
|
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;
|
// 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.
|
// bu türler için tarayıcıda çözücü yok, baytlar zaten burada duruyor.
|
||||||
if (url.pathname === '/api/me/documents/text') {
|
if (url.pathname === '/api/me/documents/text') {
|
||||||
@@ -1343,6 +1424,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
|
// Gömme modeli soğuk yüklemesi ~45 sn; ilk kullanıcı beklemesin diye arka planda ısıt.
|
||||||
|
arkaPlandaIsit();
|
||||||
console.log(`[ihaleist-api] http://localhost:${PORT} — canlı veri: ilan.gov.tr`);
|
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));
|
getTenders().catch((e) => console.error('[ihaleist-api] ilk yükleme hatası:', e.message));
|
||||||
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
|
// Bildirim işi tender verisine ihtiyaç duyar; enjekte ederek başlat
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
import { list, markSent, available } from './notify-store.mjs';
|
import { list, markSent, available } from './notify-store.mjs';
|
||||||
import { emailConfigured, sendEmail, tenderDigestHtml } from './email.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');
|
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})`,
|
subject: `İhaleist — ${gonderilecek.length} ihale (${kriterMetni})`,
|
||||||
html,
|
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, `<b>İhaleist — ${gonderilecek.length} yeni ihale</b> (${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));
|
markSent(a.id, eslesen.map((t) => t.id));
|
||||||
gonderilen++;
|
gonderilen++;
|
||||||
console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`);
|
console.log(`[notifier] ${a.email} → ${gonderilecek.length} ihale gönderildi (${kriterMetni})`);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import path from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { historyStats } from './history-store.mjs';
|
import { historyStats } from './history-store.mjs';
|
||||||
import { bildirimleriIsle } from './notifier.mjs';
|
import { bildirimleriIsle } from './notifier.mjs';
|
||||||
|
import { zeyilnameTara, sonucTara } from './zeyilname.mjs';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
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?.();
|
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
|
// Otomatik e-posta bildirimi
|
||||||
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
|
if (process.env.NOTIFY_ENABLED === '0' || !tenderGetir) {
|
||||||
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
|
console.log('[scheduler] otomatik e-posta bildirimi devre dışı');
|
||||||
|
|||||||
@@ -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/<ad>) — 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]));
|
||||||
@@ -155,6 +155,10 @@ const IZINLI_ANAHTARLAR = new Set([
|
|||||||
'favori_kurumlar', 'ekip', 'saved_filters', 'reminder',
|
'favori_kurumlar', 'ekip', 'saved_filters', 'reminder',
|
||||||
'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban',
|
'rakip_izleme', 'ciro_hedefi', 'ai_model_config', 'kanban',
|
||||||
'iptal_geri_bildirim',
|
'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) {
|
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);
|
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) {
|
export function deleteDocument(email, id) {
|
||||||
const rec = getDocument(email, id);
|
const rec = getDocument(email, id);
|
||||||
if (!rec) return false;
|
if (!rec) return false;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Anlamsal uyum bonusu — çok dilli gömme (multilingual-e5-small, ONNX q8).
|
||||||
|
*
|
||||||
|
* NEDEN BONUS, NEDEN EŞİK DEĞİL: ölçtük — e5 kosinüsleri 0,79-0,86 bandına
|
||||||
|
* sıkışıyor; kırtasiye ile röntgen arasında 0,06 var. Mutlak eşik olarak
|
||||||
|
* (kaanvp/kobi-ihale-eslestirme'nin 0,65'i) trafo bakımını da "eşleşti"
|
||||||
|
* sayar. Ama sıralama doğru ve sözlükselin kaçırdığını yakalıyor ("GENEL
|
||||||
|
* TIBBİ SARF" profilde birebir terim yokken kırtasiyenin üstünde). O yüzden
|
||||||
|
* havuz içinde z-skorlanıp sözlüksel skora +0..20 puan eklenir.
|
||||||
|
*
|
||||||
|
* ÇALIŞMA: sunucuda, Python/GPU yok (@huggingface/transformers). Model ilk
|
||||||
|
* seferde indirilip data/modeller'e (volume) yazılır; ~118 MB, soğuk yükleme
|
||||||
|
* ~45 sn — o yüzden açılışta arka planda ısıtılır. Paket yoksa ya da yükleme
|
||||||
|
* başarısızsa özellik sessizce kapanır, sözlüksel skor tek başına kalır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MODEL_DIR = path.join(__dirname, 'data', 'modeller');
|
||||||
|
const MODEL = process.env.UYUM_GOMME_MODEL || 'Xenova/multilingual-e5-small';
|
||||||
|
const ACIK = process.env.UYUM_GOMME !== '0';
|
||||||
|
const MAX_BONUS = 20;
|
||||||
|
|
||||||
|
let durum = { hazir: false, yukleniyor: false, hata: null, model: MODEL };
|
||||||
|
let enc = null;
|
||||||
|
// ihale id → { imza, vek } — havuz her 10 dk yenileniyor, yalnızca metni
|
||||||
|
// değişen/yeni ihale yeniden gömülür.
|
||||||
|
const onbellek = new Map();
|
||||||
|
|
||||||
|
export function gommeDurumu() {
|
||||||
|
return { ...durum, acik: ACIK, onbellek: onbellek.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function modeliYukle() {
|
||||||
|
if (!ACIK || enc || durum.yukleniyor) return enc;
|
||||||
|
durum.yukleniyor = true;
|
||||||
|
try {
|
||||||
|
const tf = await import('@huggingface/transformers');
|
||||||
|
tf.env.cacheDir = MODEL_DIR;
|
||||||
|
enc = await tf.pipeline('feature-extraction', MODEL, { dtype: 'q8' });
|
||||||
|
durum = { ...durum, hazir: true, hata: null };
|
||||||
|
console.log(`[uyum-gomme] model hazır: ${MODEL}`);
|
||||||
|
} catch (e) {
|
||||||
|
durum = { ...durum, hazir: false, hata: e.message };
|
||||||
|
console.warn('[uyum-gomme] kapalı:', e.message.slice(0, 160));
|
||||||
|
} finally {
|
||||||
|
durum.yukleniyor = false;
|
||||||
|
}
|
||||||
|
return enc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vektor(metin) {
|
||||||
|
const out = await enc(metin, { pooling: 'mean', normalize: true });
|
||||||
|
return Float32Array.from(out.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ihaleMetni = (t) =>
|
||||||
|
['query:', t.baslik, t.cpvLabel, t.sektor, ...(t.kalemler || []).slice(0, 6)].filter(Boolean).join(' ').slice(0, 600);
|
||||||
|
|
||||||
|
const imza = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil metni ile havuzdaki her ihale arasında kosinüs; havuz içi z-skor →
|
||||||
|
* 0..MAX_BONUS. Dönüş: { hazir, bonus: { [id]: puan } }.
|
||||||
|
*/
|
||||||
|
export async function bonusHesapla(profilMetni, tenders) {
|
||||||
|
if (!ACIK) return { hazir: false, bonus: {} };
|
||||||
|
if (!enc) await modeliYukle();
|
||||||
|
if (!enc || !profilMetni || !tenders?.length) return { hazir: !!enc, bonus: {} };
|
||||||
|
|
||||||
|
const p = await vektor('passage: ' + String(profilMetni).slice(0, 1000));
|
||||||
|
|
||||||
|
// Eksik/eskimiş gömmeleri tamamla (toplu, sırayla — CPU'da 5 ms/ihale)
|
||||||
|
for (const t of tenders) {
|
||||||
|
const metin = ihaleMetni(t);
|
||||||
|
const iz = imza(metin);
|
||||||
|
const mevcut = onbellek.get(t.id);
|
||||||
|
if (mevcut && mevcut.imza === iz) continue;
|
||||||
|
onbellek.set(t.id, { imza: iz, vek: await vektor(metin) });
|
||||||
|
}
|
||||||
|
// Havuzdan düşenleri at
|
||||||
|
if (onbellek.size > tenders.length * 2) {
|
||||||
|
const canli = new Set(tenders.map((t) => t.id));
|
||||||
|
for (const k of onbellek.keys()) if (!canli.has(k)) onbellek.delete(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kos = tenders.map((t) => {
|
||||||
|
const v = onbellek.get(t.id)?.vek;
|
||||||
|
let s = 0;
|
||||||
|
if (v) for (let i = 0; i < v.length; i++) s += v[i] * p[i];
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
const n = kos.length;
|
||||||
|
const ort = kos.reduce((a, b) => a + b, 0) / n;
|
||||||
|
const sd = Math.sqrt(kos.reduce((a, b) => a + (b - ort) ** 2, 0) / n) || 1e-6;
|
||||||
|
|
||||||
|
const bonus = {};
|
||||||
|
tenders.forEach((t, i) => {
|
||||||
|
const z = (kos[i] - ort) / sd;
|
||||||
|
// z ≈ -0,5 → 0, z ≈ 2 → MAX: yalnızca havuz ortalamasının belirgin
|
||||||
|
// üstündekiler puan alır
|
||||||
|
const b = Math.round(MAX_BONUS * Math.max(0, Math.min(1, (z + 0.5) / 2.5)));
|
||||||
|
if (b > 0) bonus[t.id] = b;
|
||||||
|
});
|
||||||
|
return { hazir: true, bonus, ort: Number(ort.toFixed(3)), sd: Number(sd.toFixed(3)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Açılışta arka planda ısıt — ilk kullanıcı 45 sn beklemesin. */
|
||||||
|
export function arkaPlandaIsit(gecikmeMs = 15000) {
|
||||||
|
if (!ACIK) return;
|
||||||
|
setTimeout(() => { modeliYukle().catch(() => {}); }, gecikmeMs);
|
||||||
|
}
|
||||||
@@ -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, `<b>${tgKacir(baslik)}</b>\n${tgKacir(metinDuz)}`);
|
||||||
|
} catch (e) { console.warn('[takip] telegram:', e.message); }
|
||||||
|
try {
|
||||||
|
if (emailConfigured()) await sendEmail({ to: email, subject: `İhaleist — ${baslik}`, html: `<p style="font-family:Arial,sans-serif">${metinHtml}</p>` });
|
||||||
|
} 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, '<br>'), 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 };
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Globe, Bot, Sparkles, CheckCircle2, ShieldCheck, Tag, X, Loader2, Datab
|
|||||||
import confetti from 'canvas-confetti';
|
import confetti from 'canvas-confetti';
|
||||||
|
|
||||||
export const CompanyScannerModal = () => {
|
export const CompanyScannerModal = () => {
|
||||||
const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities } = useApp();
|
const { isCompanyScannerOpen, setIsCompanyScannerOpen, userProfile, scanCompanyWebsite, updateCompanyCapabilities, uyumEsigi, setUyumEsigi, gommeDurum } = useApp();
|
||||||
|
|
||||||
const [urlInput, setUrlInput] = useState(userProfile.website || 'https://www.tanriverdimedikal.com.tr');
|
const [urlInput, setUrlInput] = useState(userProfile.website || 'https://www.tanriverdimedikal.com.tr');
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
@@ -274,10 +274,30 @@ export const CompanyScannerModal = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren.
|
||||||
|
(Önceki "%98+ doğruluk" cümlesi ölçülmemiş bir iddiaydı, kaldırıldı.) */}
|
||||||
|
<div className="bg-white px-6 py-4 border-t border-slate-200 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<span className="text-xs font-bold text-slate-800">Uyum eşiği — "Uyumlu" filtresi bu skordan itibaren gösterir</span>
|
||||||
|
<span className="text-sm font-black text-blue-700">%{uyumEsigi}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min={30} max={95} step={5} value={uyumEsigi}
|
||||||
|
onChange={(e) => setUyumEsigi(e.target.value)}
|
||||||
|
className="w-full accent-blue-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500 leading-relaxed">
|
||||||
|
Skor = profil terimlerinin ihalede geçmesi (başlık, teklif cetveli, OKAS) + anlamsal yakınlık bonusu.
|
||||||
|
{gommeDurum === 'hazir' && <> Anlamsal bonus <strong className="text-emerald-700">etkin</strong>.</>}
|
||||||
|
{gommeDurum === 'bekliyor' && <> Anlamsal bonus hesaplanıyor…</>}
|
||||||
|
{(gommeDurum === 'kapali' || gommeDurum === 'hata') && <> Anlamsal bonus şu an kapalı; yalnızca terim eşleşmesi.</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-between items-center">
|
<div className="bg-slate-50 p-4 border-t border-slate-200 flex justify-between items-center">
|
||||||
<p className="text-[11px] text-slate-500 font-medium">
|
<p className="text-[11px] text-slate-500 font-medium">
|
||||||
💡 Sayfalarınızdaki detaylı ürün kataloğu sayesinde AI ihale eşleşme doğruluk skorunuz <strong className="text-emerald-600">%98+</strong> seviyesine çıkarılır.
|
Terim ekledikçe skor keskinleşir; kalem havuzu doldukça teklif cetveli eşleşmeleri devreye girer.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCompanyScannerOpen(false)}
|
onClick={() => setIsCompanyScannerOpen(false)}
|
||||||
|
|||||||
@@ -127,6 +127,16 @@ export const KanbanBoard = () => {
|
|||||||
<span className="text-[10px] font-extrabold bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
|
<span className="text-[10px] font-extrabold bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
|
||||||
#{t.ikn}
|
#{t.ikn}
|
||||||
</span>
|
</span>
|
||||||
|
{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 <span className={`text-[10px] font-extrabold px-2 py-0.5 rounded ${renk}`} title={`Teslim: ${t.gorev.teslim}`}>
|
||||||
|
{kalan < 0 ? 'süresi geçti' : kalan === 0 ? 'BUGÜN' : `${kalan} gün`}
|
||||||
|
</span>;
|
||||||
|
})()}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTenderDetail(t)}
|
onClick={() => setActiveTenderDetail(t)}
|
||||||
className="text-slate-400 hover:text-blue-600 p-0.5"
|
className="text-slate-400 hover:text-blue-600 p-0.5"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import confetti from 'canvas-confetti';
|
import confetti from 'canvas-confetti';
|
||||||
|
|
||||||
export const TenderDetailModal = () => {
|
export const TenderDetailModal = () => {
|
||||||
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen } = useApp();
|
const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults } = useApp();
|
||||||
const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders'
|
const [activeTab, setActiveTab] = useState('details'); // 'details', 'pdf_stamp', 'ai_assistant', 'checklist', 'kurum_tenders'
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
const [savedNotes, setSavedNotes] = useState([
|
const [savedNotes, setSavedNotes] = useState([
|
||||||
@@ -99,6 +99,22 @@ END:VCALENDAR`;
|
|||||||
<Star className={`w-4 h-4 ${tender.takipte ? 'fill-slate-900' : ''}`} />
|
<Star className={`w-4 h-4 ${tender.takipte ? 'fill-slate-900' : ''}`} />
|
||||||
<span>{tender.takipte ? 'Takipte' : 'Takip Et'}</span>
|
<span>{tender.takipte ? 'Takipte' : 'Takip Et'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* İhaleden göreve: teslim tarihi ihale saati; sonuç yayımlanınca bildirim */}
|
||||||
|
{tender.gorev ? (
|
||||||
|
<span className="flex items-center gap-1.5 font-bold px-3 py-1.5 rounded-lg text-xs bg-emerald-500/20 text-emerald-200 border border-emerald-400/40"
|
||||||
|
title={`Hazırlık görevi açık · teslim: ${tender.gorev.teslim || '—'}`}>
|
||||||
|
✓ Görev açık{tender.gorev.teslim ? ` · ${tender.gorev.teslim}` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => gorevAc(tender.id, { kontrolListesi: aiAnalysisResults?.documentChecklist || [] })}
|
||||||
|
title="Hazırlık görevi aç: teslim tarihi ihale saati olur, pano 'Teklif Hazırlanıyor' kolonuna düşer, sonuç yayımlanınca bildirim gelir"
|
||||||
|
className="flex items-center gap-1.5 font-bold px-3 py-1.5 rounded-lg text-xs transition-colors bg-amber-400 text-slate-950 hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
🗂 Hazırlık Görevi Aç
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTenderDetail(null)}
|
onClick={() => setActiveTenderDetail(null)}
|
||||||
className="text-slate-400 hover:text-white p-1"
|
className="text-slate-400 hover:text-white p-1"
|
||||||
@@ -214,6 +230,23 @@ END:VCALENDAR`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tender.gorev?.kontrolListesi?.length > 0 && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 space-y-2">
|
||||||
|
<h3 className="font-bold text-sm text-slate-900">Hazırlık görevi — belge kontrol listesi</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{tender.gorev.kontrolListesi.map((k, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<button type="button" onClick={() => gorevKontrol(tender.id, i)}
|
||||||
|
className={`text-xs text-left flex items-center gap-2 ${k.tamam ? 'line-through text-slate-400' : 'text-slate-800'}`}>
|
||||||
|
<span className={`w-3.5 h-3.5 rounded border ${k.tamam ? 'bg-emerald-500 border-emerald-500' : 'border-slate-400'}`} />
|
||||||
|
{k.ad}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ortak havuzdan teklif cetveli kalemleri */}
|
{/* Ortak havuzdan teklif cetveli kalemleri */}
|
||||||
<TeklifCetveliKalemleri ikn={tender.ikn} />
|
<TeklifCetveliKalemleri ikn={tender.ikn} />
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const TenderList = () => {
|
|||||||
tenders,
|
tenders,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
kalemEslesen,
|
kalemEslesen,
|
||||||
|
uyumEsigi,
|
||||||
selectedCity,
|
selectedCity,
|
||||||
selectedAlimTuru,
|
selectedAlimTuru,
|
||||||
selectedSektorler,
|
selectedSektorler,
|
||||||
@@ -59,7 +60,7 @@ export const TenderList = () => {
|
|||||||
if (onlyValidOnly && !GECMIS_FILTRELER.has(quickFilter) && !acikIhale(tender, bugun)) return false;
|
if (onlyValidOnly && !GECMIS_FILTRELER.has(quickFilter) && !acikIhale(tender, bugun)) return false;
|
||||||
|
|
||||||
// Hızlı filtre (üst kartlar + sol menü) — ölçütler ihaleTarih.js'de tek yerde
|
// Hızlı filtre (üst kartlar + sol menü) — ölçütler ihaleTarih.js'de tek yerde
|
||||||
if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris })) return false;
|
if (!hizliFiltreGecer(tender, quickFilter, { favoriteKurumlar: favoriteKurumlar || [], oncekiGirisMs: oncekiGiris, uyumEsigi })) return false;
|
||||||
|
|
||||||
// Dropdown Filters
|
// Dropdown Filters
|
||||||
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
|
if (selectedCity && normTr(tender.sehir) !== normTr(selectedCity)) return false;
|
||||||
@@ -76,7 +77,7 @@ export const TenderList = () => {
|
|||||||
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
if (sortBy === 'distant') return b.tarih.localeCompare(a.tarih);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [tenders, searchQuery, kalemEslesen, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]);
|
}, [tenders, searchQuery, kalemEslesen, uyumEsigi, selectedCity, selectedAlimTuru, selectedSektorler, selectedCpvler, selectedKurumTipi, quickFilter, sortBy, onlyValidOnly, bugun, favoriteKurumlar, oncekiGiris]);
|
||||||
|
|
||||||
const gorunenTenders = filteredTenders.slice(0, gosterilen);
|
const gorunenTenders = filteredTenders.slice(0, gosterilen);
|
||||||
const dahaVar = gosterilen < filteredTenders.length;
|
const dahaVar = gosterilen < filteredTenders.length;
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export const CompetitorRadar = () => {
|
|||||||
⚔ ort. {f.ortTeklif} teklif
|
⚔ ort. {f.ortTeklif} teklif
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<a href="https://ekapv2.kik.gov.tr/sorgulamalar/yasak-sorgulama" target="_blank" rel="noopener noreferrer"
|
||||||
|
onClick={() => { 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
|
||||||
|
</a>
|
||||||
{f.tekTeklifOrani != null && f.tekTeklifOrani > 0 && (
|
{f.tekTeklifOrani != null && f.tekTeklifOrani > 0 && (
|
||||||
<span className="bg-amber-50 text-amber-800 border border-amber-200 px-1.5 py-0.5 rounded font-bold"
|
<span className="bg-amber-50 text-amber-800 border border-amber-200 px-1.5 py-0.5 rounded font-bold"
|
||||||
title="Tek teklifle (rakipsiz) kazandığı ihalelerin oranı">
|
title="Tek teklifle (rakipsiz) kazandığı ihalelerin oranı">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt } from '../../services/account';
|
import { listDocuments, uploadDocument, deleteDocument, downloadDocument, formatBayt, updateDocumentMeta } from '../../services/account';
|
||||||
import { FolderKanban, Upload, Trash2, Download, Loader2, AlertTriangle, FileText, HardDrive, Layers, Package, CheckCircle2, XCircle } from 'lucide-react';
|
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'];
|
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 = '';
|
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) => {
|
const sil = async (id) => {
|
||||||
setBusy(true); setHata(null);
|
setBusy(true); setHata(null);
|
||||||
try { setDurum(await deleteDocument(id)); }
|
try { setDurum(await deleteDocument(id)); }
|
||||||
@@ -154,6 +165,22 @@ export const DocumentVault = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Süre uyarısı */}
|
||||||
|
{(suresiDolan.length > 0 || yakinda.length > 0) && (
|
||||||
|
<div className={`rounded-2xl border-2 p-4 flex gap-3 ${suresiDolan.length ? 'bg-red-50 border-red-300' : 'bg-amber-50 border-amber-300'}`}>
|
||||||
|
<CalendarClock className={`w-5 h-5 shrink-0 mt-0.5 ${suresiDolan.length ? 'text-red-600' : 'text-amber-600'}`} />
|
||||||
|
<div className="text-xs leading-relaxed">
|
||||||
|
{suresiDolan.length > 0 && (
|
||||||
|
<p className="font-bold text-red-900">Süresi dolmuş: {suresiDolan.map((b) => b.ad).join(', ')}</p>
|
||||||
|
)}
|
||||||
|
{yakinda.length > 0 && (
|
||||||
|
<p className="font-semibold text-amber-900">30 gün içinde doluyor: {yakinda.map((b) => `${b.ad} (${kalanGun(b)} gün)`).join(', ')}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-slate-600 mt-1">Teklif zarfına süresi dolmuş belge girerse teklif değerlendirme dışı kalır. Yenileyip yeni tarihi girin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Toplu ihale dokümanı yükleme */}
|
{/* Toplu ihale dokümanı yükleme */}
|
||||||
<div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-2xl p-5 space-y-3">
|
<div className="bg-linear-to-r from-slate-900 via-indigo-950 to-slate-900 rounded-2xl p-5 space-y-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -226,6 +253,17 @@ export const DocumentVault = () => {
|
|||||||
<p className="text-[10px] text-slate-400 mt-0.5">
|
<p className="text-[10px] text-slate-400 mt-0.5">
|
||||||
{new Date(d.olusturma).toLocaleString('tr-TR')}
|
{new Date(d.olusturma).toLocaleString('tr-TR')}
|
||||||
</p>
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 mt-1.5 text-[10px] text-slate-500">
|
||||||
|
<CalendarClock className={`w-3 h-3 ${kalanGun(d) != null && kalanGun(d) < 0 ? 'text-red-600' : kalanGun(d) != null && kalanGun(d) <= 30 ? 'text-amber-600' : 'text-slate-400'}`} />
|
||||||
|
<span>Son kullanma</span>
|
||||||
|
<input type="date" value={d.son_kullanma || ''} onChange={(e) => sureYaz(d.id, e.target.value)}
|
||||||
|
className="border border-slate-200 rounded px-1 py-0.5 text-[10px]" />
|
||||||
|
{kalanGun(d) != null && (
|
||||||
|
<span className={`font-bold ${kalanGun(d) < 0 ? 'text-red-700' : kalanGun(d) <= 30 ? 'text-amber-700' : 'text-emerald-700'}`}>
|
||||||
|
{kalanGun(d) < 0 ? `${-kalanGun(d)} gün önce doldu` : `${kalanGun(d)} gün kaldı`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button onClick={() => downloadDocument(d.id, d.ad).catch((e) => setHata(e.message))}
|
<button onClick={() => downloadDocument(d.id, d.ad).catch((e) => setHata(e.message))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useApp } from '../../context/AppContext';
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { TelegramBaglanti } from './TelegramBaglanti';
|
||||||
import { getToken } from '../../services/auth';
|
import { getToken } from '../../services/auth';
|
||||||
|
|
||||||
import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles, ChevronDown, ChevronUp, X } from 'lucide-react';
|
import { BellRing, Plus, Trash2, Loader2, AlertTriangle, Info, Globe, Sparkles, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||||
@@ -106,6 +107,8 @@ export const NotifySubscriptions = () => {
|
|||||||
const oneriler = tarama?.sonuc?.ihaleKonulari || [];
|
const oneriler = tarama?.sonuc?.ihaleKonulari || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<TelegramBaglanti />
|
||||||
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-5">
|
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-5">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||||
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||||
@@ -266,5 +269,6 @@ export const NotifySubscriptions = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Send, CheckCircle2, AlertTriangle, Loader2, Unplug } from 'lucide-react';
|
||||||
|
import { telegramDurum, telegramBagla, telegramTest, telegramSil } from '../../services/account';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram bildirim bağlantısı — kullanıcı kendi kurar.
|
||||||
|
* Bot tokenı sunucuda (TELEGRAM_BOT_TOKEN); kullanıcı 6 haneli kodu bota yazar,
|
||||||
|
* "Doğrula" deyince sunucu kodu görüp sohbeti hesaba bağlar. Token bizden geçmez.
|
||||||
|
*/
|
||||||
|
const kodUret = () => String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
|
||||||
|
export const TelegramBaglanti = () => {
|
||||||
|
const [d, setD] = useState(null);
|
||||||
|
const [kod] = useState(kodUret);
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
const [mesaj, setMesaj] = useState(null);
|
||||||
|
|
||||||
|
const yenile = () => telegramDurum().then(setD).catch((e) => setMesaj({ tur: 'hata', metin: e.message }));
|
||||||
|
useEffect(() => { yenile(); }, []);
|
||||||
|
|
||||||
|
const calistir = async (ad, fn, basari) => {
|
||||||
|
setBusy(ad); setMesaj(null);
|
||||||
|
try { await fn(); setMesaj({ tur: 'ok', metin: basari }); await yenile(); }
|
||||||
|
catch (e) { setMesaj({ tur: 'hata', metin: e.message }); }
|
||||||
|
finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!d) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||||
|
<h3 className="font-extrabold text-sm text-slate-900 flex items-center gap-2">
|
||||||
|
<Send className="w-4 h-4 text-sky-600" /> Telegram Bildirimi
|
||||||
|
</h3>
|
||||||
|
{d.bagli
|
||||||
|
? <span className="bg-emerald-100 text-emerald-800 font-extrabold text-[10px] px-2 py-0.5 rounded-full">bağlı{d.ad ? ` · ${d.ad}` : ''}</span>
|
||||||
|
: <span className="bg-slate-100 text-slate-600 font-extrabold text-[10px] px-2 py-0.5 rounded-full">bağlı değil</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-600 leading-relaxed">
|
||||||
|
Yeni ihale özetleri, takipteki ihalede <strong>zeyilname/düzeltme ilanı</strong> ve <strong>sonuç</strong>
|
||||||
|
("X firması Y'ye aldı, %Z kırım") e-postanın yanında Telegram'a da gelir.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!d.yapilandirilmis ? (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 space-y-2 text-xs text-amber-900 leading-relaxed">
|
||||||
|
<p className="font-bold">Sunucuda Telegram botu henüz tanımlı değil. Kurulum (yönetici, bir kez):</p>
|
||||||
|
<ol className="list-decimal pl-5 space-y-1">
|
||||||
|
<li>Telegram'da <strong>@BotFather</strong>'a yazın: <code className="bg-amber-100 px-1 rounded">/newbot</code> → bir ad ve <code className="bg-amber-100 px-1 rounded">…bot</code> ile biten kullanıcı adı verin.</li>
|
||||||
|
<li>BotFather'ın verdiği tokenı Coolify → ihaleist → Environment'a <code className="bg-amber-100 px-1 rounded">TELEGRAM_BOT_TOKEN</code> olarak ekleyin, Redeploy.</li>
|
||||||
|
<li>Bu sayfa yeniden açıldığında aşağıdaki bağlama adımları görünür.</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-[11px] text-amber-800">Token yalnızca sunucuda durur; uygulamaya girilmez.</p>
|
||||||
|
</div>
|
||||||
|
) : d.bagli ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button onClick={() => calistir('test', telegramTest, 'Test mesajı gönderildi — Telegram\'ı kontrol edin.')} disabled={!!busy}
|
||||||
|
className="bg-sky-600 hover:bg-sky-500 disabled:opacity-40 text-white font-bold text-xs px-4 py-2 rounded-lg flex items-center gap-1.5">
|
||||||
|
{busy === 'test' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Send className="w-3.5 h-3.5" />} Test mesajı gönder
|
||||||
|
</button>
|
||||||
|
<button onClick={() => calistir('sil', telegramSil, 'Telegram bağlantısı kaldırıldı.')} disabled={!!busy}
|
||||||
|
className="bg-white hover:bg-slate-50 disabled:opacity-40 text-slate-700 font-bold text-xs px-4 py-2 rounded-lg border border-slate-300 flex items-center gap-1.5">
|
||||||
|
<Unplug className="w-3.5 h-3.5" /> Bağlantıyı kaldır
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4 space-y-3 text-xs text-slate-800">
|
||||||
|
<p className="font-bold">Bağlamak için 3 adım:</p>
|
||||||
|
<ol className="list-decimal pl-5 space-y-1.5 leading-relaxed">
|
||||||
|
<li>
|
||||||
|
Telegram'da botu açın:{' '}
|
||||||
|
{d.bot
|
||||||
|
? <a href={`https://t.me/${d.bot}`} target="_blank" rel="noopener noreferrer" className="font-bold text-sky-700 underline">@{d.bot}</a>
|
||||||
|
: <span className="font-bold">(bot adı alınamadı — yöneticiye sorun)</span>}
|
||||||
|
{' '}ve <strong>Başlat</strong>'a basın.
|
||||||
|
</li>
|
||||||
|
<li>Bota şu kodu yazıp gönderin: <code className="bg-white border border-sky-300 px-2 py-0.5 rounded font-mono text-sm font-black tracking-widest">{kod}</code></li>
|
||||||
|
<li>Sonra buraya dönüp <strong>Doğrula</strong>'ya basın.</li>
|
||||||
|
</ol>
|
||||||
|
<button onClick={() => calistir('bagla', () => telegramBagla(kod), 'Bağlandı! Telegram\'a onay mesajı gönderildi.')} disabled={!!busy}
|
||||||
|
className="bg-sky-600 hover:bg-sky-500 disabled:opacity-40 text-white font-bold text-xs px-5 py-2 rounded-lg flex items-center gap-1.5">
|
||||||
|
{busy === 'bagla' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle2 className="w-3.5 h-3.5" />} Doğrula
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mesaj && (
|
||||||
|
<div className={`rounded-lg p-3 flex gap-2 text-xs ${mesaj.tur === 'ok' ? 'bg-emerald-50 border border-emerald-200 text-emerald-900' : 'bg-red-50 border border-red-200 text-red-800'}`}>
|
||||||
|
{mesaj.tur === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> : <AlertTriangle className="w-4 h-4 shrink-0" />}
|
||||||
|
<span>{mesaj.metin}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+130
-7
@@ -1,10 +1,10 @@
|
|||||||
import React, {createContext, useContext, useState, useEffect, useMemo } from 'react';
|
import React, {createContext, useContext, useState, useEffect, useMemo } from 'react';
|
||||||
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
import { parseWebsiteCapabilitiesDynamically } from '../services/webScraperService';
|
||||||
import { havuzuPuanla } from '../services/uyum';
|
import { havuzuPuanla, profilMetni, profilTerimleri } from '../services/uyum';
|
||||||
import { getToken } from '../services/auth';
|
import { getToken } from '../services/auth';
|
||||||
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi';
|
||||||
import { restoreSession, clearSession, getStoredUser } from '../services/auth';
|
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';
|
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
@@ -424,17 +424,53 @@ export const AppProvider = ({ children }) => {
|
|||||||
trialDaysLeft: 7,
|
trialDaysLeft: 7,
|
||||||
role: 'Yönetici Tedarikçi',
|
role: 'Yönetici Tedarikçi',
|
||||||
scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) },
|
scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) },
|
||||||
|
// Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren
|
||||||
|
uyumEsigi: Number.isFinite(kayitli?.uyumEsigi) ? kayitli.uyumEsigi : 60,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const veri = { company: userProfile.company, website: userProfile.website, scrapedCapabilities: userProfile.scrapedCapabilities };
|
const veri = {
|
||||||
|
company: userProfile.company, website: userProfile.website,
|
||||||
|
scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi,
|
||||||
|
};
|
||||||
yerelKaydet('ihaleist_firma_profil', veri);
|
yerelKaydet('ihaleist_firma_profil', veri);
|
||||||
if (veriSenkron) sunucuyaYaz('firma_profil', veri);
|
if (veriSenkron) sunucuyaYaz('firma_profil', veri);
|
||||||
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, veriSenkron]);
|
}, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, userProfile.uyumEsigi, veriSenkron]);
|
||||||
|
const setUyumEsigi = (v) => setUserProfile((p) => ({ ...p, uyumEsigi: Math.max(0, Math.min(100, Number(v) || 0)) }));
|
||||||
|
|
||||||
// Uyum skoru: havuz ya da profil değişince tüm ihaleler yeniden puanlanır.
|
// Anlamsal bonus: profil metni sunucuda gömülür, havuzla kosinüs alınır,
|
||||||
// Sözlüksel, dış servis yok — 8 bin ihalede anında.
|
// z-skor → 0..20. Profil terimi yoksa ya da oturum yoksa istenmez. Havuz
|
||||||
const tendersSkorlu = useMemo(() => havuzuPuanla(tenders, userProfile), [tenders, userProfile.scrapedCapabilities]);
|
// 10 dk'da bir yenilendiği için ihale sayısı değişince tazelenir.
|
||||||
|
const [gommeBonus, setGommeBonus] = useState(null);
|
||||||
|
const [gommeDurum, setGommeDurum] = useState('kapali'); // kapali | bekliyor | hazir | hata
|
||||||
|
const profilMetin = useMemo(() => profilMetni(userProfile), [userProfile.scrapedCapabilities]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profilTerimleri(userProfile).length || !getToken() || !tenders.length) { setGommeBonus(null); return; }
|
||||||
|
let iptal = false;
|
||||||
|
setGommeDurum('bekliyor');
|
||||||
|
const z = setTimeout(() => {
|
||||||
|
fetch('/api/uyum/gomme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: JSON.stringify({ profilMetni: profilMetin }),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
if (iptal) return;
|
||||||
|
if (d.success && d.hazir) { setGommeBonus(d.bonus || {}); setGommeDurum('hazir'); }
|
||||||
|
else { setGommeBonus(null); setGommeDurum(d.success ? 'kapali' : 'hata'); }
|
||||||
|
})
|
||||||
|
.catch(() => { if (!iptal) { setGommeBonus(null); setGommeDurum('hata'); } });
|
||||||
|
}, 800);
|
||||||
|
return () => { iptal = true; clearTimeout(z); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [profilMetin, tenders.length]);
|
||||||
|
|
||||||
|
// Uyum skoru: sözlüksel (anında, açıklanabilir) + anlamsal bonus (sunucu).
|
||||||
|
const tendersSkorlu = useMemo(
|
||||||
|
() => havuzuPuanla(tenders, userProfile, gommeBonus),
|
||||||
|
[tenders, userProfile.scrapedCapabilities, gommeBonus],
|
||||||
|
);
|
||||||
|
|
||||||
// Önce gerçek tarama (/api/company/scan: site metni + model), başarısızsa
|
// Önce gerçek tarama (/api/company/scan: site metni + model), başarısızsa
|
||||||
// alan adından tahmin eden eski yol. Sonuç uyum skorunun girdisi olur.
|
// alan adından tahmin eden eski yol. Sonuç uyum skorunun girdisi olur.
|
||||||
@@ -527,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) => {
|
const updateKanbanStatus = (tenderId, newStatus, assignedUser = null) => {
|
||||||
setTenders(prev => prev.map(t => {
|
setTenders(prev => prev.map(t => {
|
||||||
if (t.id === tenderId) {
|
if (t.id === tenderId) {
|
||||||
@@ -569,6 +687,9 @@ export const AppProvider = ({ children }) => {
|
|||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
tenders: tendersSkorlu,
|
tenders: tendersSkorlu,
|
||||||
|
uyumEsigi: userProfile.uyumEsigi,
|
||||||
|
setUyumEsigi,
|
||||||
|
gommeDurum,
|
||||||
setTenders,
|
setTenders,
|
||||||
isLiveMode,
|
isLiveMode,
|
||||||
setIsLiveMode,
|
setIsLiveMode,
|
||||||
@@ -656,6 +777,8 @@ export const AppProvider = ({ children }) => {
|
|||||||
removeTeamMember,
|
removeTeamMember,
|
||||||
toggleTrackTender,
|
toggleTrackTender,
|
||||||
updateKanbanStatus,
|
updateKanbanStatus,
|
||||||
|
gorevAc,
|
||||||
|
gorevKontrol,
|
||||||
clearAllFilters,
|
clearAllFilters,
|
||||||
plan,
|
plan,
|
||||||
planYenile,
|
planYenile,
|
||||||
|
|||||||
@@ -88,3 +88,17 @@ export function formatBayt(b) {
|
|||||||
// --- Abonelik iptali ---
|
// --- Abonelik iptali ---
|
||||||
export const cancelSubscription = (neden, aciklama) =>
|
export const cancelSubscription = (neden, aciklama) =>
|
||||||
iste('/api/billing/cancel', { method: 'POST', body: JSON.stringify({ 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 }) });
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export function normProvince(name) {
|
|||||||
export function firmStats(tenders) {
|
export function firmStats(tenders) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
let awardCount = 0;
|
let awardCount = 0;
|
||||||
|
// Kısmi ihale tuzağı: yaklaşık maliyet TÜM ihale için okunuyor, sözleşme
|
||||||
|
// bedeli ise kısım başına. 11 kısımlık ilaç ihalesinin ilk kısmı 1,6 milyon
|
||||||
|
// tahmine karşı 25 bine verilmişse bu %98 kırım değil, aritmetik kurgudur.
|
||||||
|
// Kırım yalnızca tek sonuç satırı olan (kısımsız) ihalelerde hesaplanır;
|
||||||
|
// yanlış bir oran, hiç oran olmamasından kötüdür.
|
||||||
|
const iknSatir = new Map();
|
||||||
|
for (const t of tenders) if (t.kazananFirma && t.ikn) iknSatir.set(t.ikn, (iknSatir.get(t.ikn) || 0) + 1);
|
||||||
for (const t of tenders) {
|
for (const t of tenders) {
|
||||||
if (!t.kazananFirma) continue;
|
if (!t.kazananFirma) continue;
|
||||||
awardCount++;
|
awardCount++;
|
||||||
@@ -43,7 +50,8 @@ export function firmStats(tenders) {
|
|||||||
cur.wins++;
|
cur.wins++;
|
||||||
if (val != null) cur.total += val;
|
if (val != null) cur.total += val;
|
||||||
const maliyet = parseTL(t.yaklasikMaliyet);
|
const maliyet = parseTL(t.yaklasikMaliyet);
|
||||||
if (val != null && maliyet > 0 && val <= maliyet * 1.5) {
|
const kisimsiz = (iknSatir.get(t.ikn) || 1) === 1;
|
||||||
|
if (kisimsiz && val != null && maliyet > 0 && val <= maliyet * 1.5) {
|
||||||
cur.kirimToplam += 1 - val / maliyet;
|
cur.kirimToplam += 1 - val / maliyet;
|
||||||
cur.kirimAdet++;
|
cur.kirimAdet++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ export const acikIhale = (t, bugun) => {
|
|||||||
export const GECMIS_FILTRELER = new Set(['arsiv', 'sonuclananlar', 'sozlesmeler', 'iptal']);
|
export const GECMIS_FILTRELER = new Set(['arsiv', 'sonuclananlar', 'sozlesmeler', 'iptal']);
|
||||||
|
|
||||||
// Hızlı filtre (üst kartlar + sol menü) ölçütü. Bilinmeyen id → her şey geçer.
|
// Hızlı filtre (üst kartlar + sol menü) ölçütü. Bilinmeyen id → her şey geçer.
|
||||||
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null } = {}) => {
|
export const hizliFiltreGecer = (t, id, { favoriteKurumlar = [], oncekiGirisMs = null, uyumEsigi = 60 } = {}) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'uyumlu': return (t.aiMatchScore ?? 0) >= 60; // profil terimleri ihalede geçiyor
|
case 'uyumlu': return (t.aiMatchScore ?? 0) >= uyumEsigi; // firma eşiği (profil ayarı)
|
||||||
case 'favori_kurumlar': return favoriteKurumlar.includes((t.kurum || '').trim());
|
case 'favori_kurumlar': return favoriteKurumlar.includes((t.kurum || '').trim());
|
||||||
case 'dogrudan_temin': return t.alimTuru === 'Doğrudan Temin';
|
case 'dogrudan_temin': return t.alimTuru === 'Doğrudan Temin';
|
||||||
case 'ihaleler': return t.alimTuru !== 'Doğrudan Temin' && t.durum !== 'Sonuçlandı';
|
case 'ihaleler': return t.alimTuru !== 'Doğrudan Temin' && t.durum !== 'Sonuçlandı';
|
||||||
|
|||||||
+41
-2
@@ -28,8 +28,47 @@ export async function extractPlainText(file) {
|
|||||||
return { text: text.trim(), pageCount: 1, usedPages: 1 };
|
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();
|
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);
|
return extractPlainText(file);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -75,12 +75,36 @@ export function uyumSkoru(tender, terimler) {
|
|||||||
return { skor: Math.max(0, Math.min(100, skor)), nedenler: nedenler.slice(0, 5) };
|
return { skor: Math.max(0, Math.min(100, skor)), nedenler: nedenler.slice(0, 5) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Havuzun tamamını puanlar; alan adları kartın ve detayın okuduğu adlar. */
|
/** Sunucudaki gömme modeline gidecek profil metni (passage). */
|
||||||
export function havuzuPuanla(tenders, userProfile) {
|
export function profilMetni(userProfile) {
|
||||||
|
const c = userProfile?.scrapedCapabilities || {};
|
||||||
|
const parca = [
|
||||||
|
c.ozet,
|
||||||
|
c.extractedSectors?.length ? 'Sektör: ' + c.extractedSectors.join(', ') : '',
|
||||||
|
c.extractedProducts?.length ? 'Ürünler: ' + c.extractedProducts.join(', ') : '',
|
||||||
|
c.ihaleKonulari?.length ? 'İhale konuları: ' + c.ihaleKonulari.join(', ') : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return parca.join('. ').slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Havuzun tamamını puanlar; alan adları kartın ve detayın okuduğu adlar.
|
||||||
|
* bonus: sunucudan gelen anlamsal bonus { [id]: 0..20 } — sözlüksel skora
|
||||||
|
* eklenir. Sözlüksel 0 ise bonus tek başına yalnızca ≥10'sa görünür
|
||||||
|
* (havuz ortalamasının belirgin üstü), "anlamsal yakınlık" nedeniyle.
|
||||||
|
*/
|
||||||
|
export function havuzuPuanla(tenders, userProfile, bonus = null) {
|
||||||
const terimler = profilTerimleri(userProfile);
|
const terimler = profilTerimleri(userProfile);
|
||||||
if (!terimler.length) return tenders.map((t) => (t.aiMatchScore == null && !t.uyumNedenleri ? t : { ...t, aiMatchScore: null, uyumNedenleri: [] }));
|
if (!terimler.length) return tenders.map((t) => (t.aiMatchScore == null && !t.uyumNedenleri ? t : { ...t, aiMatchScore: null, uyumNedenleri: [] }));
|
||||||
return tenders.map((t) => {
|
return tenders.map((t) => {
|
||||||
const { skor, nedenler } = uyumSkoru(t, terimler);
|
const { skor, nedenler } = uyumSkoru(t, terimler);
|
||||||
return { ...t, aiMatchScore: skor, uyumNedenleri: nedenler };
|
const b = bonus?.[t.id] || 0;
|
||||||
|
let toplam = skor;
|
||||||
|
const ned = [...nedenler];
|
||||||
|
if (b > 0 && (skor > 0 || b >= 10)) {
|
||||||
|
toplam = Math.min(100, skor + b);
|
||||||
|
ned.push({ terim: 'anlamsal yakınlık', kaynak: 'gömme', alan: `+${b} puan`, katki: b / 20 });
|
||||||
|
}
|
||||||
|
return { ...t, aiMatchScore: toplam, uyumNedenleri: ned, uyumSozluksel: skor, uyumBonus: b };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user