diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index b994026..245e8bb 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -162,3 +162,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Not alanına erişilebilir ad eklendi, kayıt beklerken/boş metinde gönderim düğmesi devre dışı bırakıldı. Kullanılmayan eski kaşe sürükleme durumu kaldırıldı. - Doğrulama: 48/48 otomatik test ve üretim derlemesi geçti. İki yeni test gecikmiş yanıt, yeni düzenleme, aynı metne geri dönme, başka ihale ve hesap/oturum ayrımını kapsıyor. Gerçek tarayıcıda geciktirilmiş kayıt senaryosu bu adımda çalıştırılmadı. Mevcut paket boyutu uyarıları sürüyor. - Tam plan, resmi beş dosya kabulü ve canlı yayın hâlâ tamamlanmış sayılmıyor. + +### D02 / D09 — Sunucu tarih hesabı ve takip tüketicileri + +- RSS/MCP açık ihale filtresi ve yaklaşan ihale API’si arayüzle aynı `deadlineInfo` hesabını kullanıyor. Bugün saati geçmiş kayıt yaklaşan listeden çıkarılıyor; bilinmeyen saat `saat_dogrulanmali` ile ayrılıyor. Geçersiz tarih yaklaşan listeye alınmıyor; genel aramada kaydı gizlemek yerine korunuyor. +- Yaklaşan liste, haftalık özet, zeyilname takip haritası ve plan takip sayacı güncel tender_state kayıtlarını okuyor. Eski takip listesi uyumluluk için okunuyor ancak güncel açık takipten çıkarma kararı eski kopyaya üstün geliyor. Havuz dışında kalan takipli kayıtlar saklanan anlık görüntüden yaklaşan listeye katılıyor. +- Haftalık özetin HTML/metin çıktısında saat ve Türkiye saati etiketi veya saat doğrulama uyarısı var. Pazartesi 08.00 zamanlama hesabı sunucu yerel saatinden bağımsız. İlan aciliyet günü de ortak tarih hesabını kullanıyor. +- Kanıt: 51/51 otomatik test geçti. Yeni testler üç sunucu saat dilimi, İstanbul/UTC gün sınırı, geçersiz tarih, geçmiş saat, eksik saat, takipten çıkarma, havuz dışı kayıt, hesap ayrımı ve haftalık çalışma sınırını doğruluyor. Gerçek e-posta/Telegram/push gönderilmedi; teslim ve yeniden deneme senaryoları hâlâ açık. Resmi takvim içe aktarımı ve tüm zeyilname akışı tamamlanmış sayılmıyor. diff --git a/server/brief.mjs b/server/brief.mjs index 9f10bdd..f451c49 100644 --- a/server/brief.mjs +++ b/server/brief.mjs @@ -25,7 +25,7 @@ const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-T /** Bir kullanıcı için brief içeriği. */ export function briefOlustur(email, tenders) { - const takip = (store.getData(email, 'takip') || []).map(String); + const takip = store.getTrackedTenderKeys(email); const yaklasan = yaklasanIhaleler(email, tenders, 7); const birHafta = Date.now() - 7 * 86400000; const sonuclar = []; @@ -40,11 +40,15 @@ export function briefOlustur(email, tenders) { return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length }; } +const deadlineText = t => t.son_teklif_zamani + ? new Date(t.son_teklif_zamani).toLocaleTimeString('tr-TR', { timeZone: 'Europe/Istanbul', hour: '2-digit', minute: '2-digit' }) + ' (Türkiye saati)' + : 'saat doğrulanmalı'; + export function briefMetni(b) { const s = [`📋 Haftalık İhale Brief — ${b.hafta}`]; if (b.yaklasan.length) { s.push('', `⏰ Bu hafta yapılacak takipli ihaleler (${b.yaklasan.length}):`); - for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih}${t.saat ? ' ' + t.saat : ''})`); + for (const t of b.yaklasan) s.push(`• ${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${t.baslik} (${t.kurum}, ${t.tarih} · ${deadlineText(t)})`); } if (b.sonuclar.length) { s.push('', `🏁 Geçen hafta sonuçlanan takipliler (${b.sonuclar.length}):`); @@ -60,7 +64,7 @@ export function briefHtml(b) { ? `

${baslik}

` : ''; return `

📋 Haftalık İhale Brief

${esc(b.hafta)}
- ${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)})`))} + ${bolum('⏰ Bu hafta yapılacak takipli ihaleler', b.yaklasan.map((t) => `${t.kalan_gun === 0 ? 'BUGÜN' : t.kalan_gun + ' gün'} — ${esc(t.baslik)} (${esc(t.kurum)}, ${esc(t.tarih)} · ${esc(deadlineText(t))})`))} ${bolum('🏁 Geçen hafta sonuçlanan takipliler', b.sonuclar.map((r) => `${esc(r.baslik)} → ${esc(r.kazanan || 'kazanan açıklanmadı')}${r.bedel != null ? ' · ' + esc(paraTR(r.bedel, r.birim)) : ''}`))}

Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.

`; } @@ -88,13 +92,18 @@ export async function haftalikBriefGonder(tenderGetir, { zorla = false } = {}) { return r; } -/** Saatlik kontrol: Pazartesi ≥08:00 ve bu hafta gönderilmediyse gönder. */ +export function briefScheduleKey(now = new Date()) { + const tr = new Date(now.getTime() + 3 * 3600000); + if (tr.getUTCDay() !== 1 || tr.getUTCHours() < 8) return null; + return `${tr.getUTCFullYear()}-${tr.getUTCMonth()}-${tr.getUTCDate()}`; +} + +/** Saatlik kontrol: Türkiye saatiyle Pazartesi ≥08:00. */ export function briefZamanlayici(tenderGetir) { if (process.env.BRIEF_ENABLED === '0') { console.log('[brief] haftalık brief devre dışı (BRIEF_ENABLED=0)'); return; } const kontrol = async () => { - const simdi = new Date(); - if (simdi.getDay() !== 1 || simdi.getHours() < 8) return; - const anahtar = `${simdi.getFullYear()}-${simdi.getMonth()}-${simdi.getDate()}`; + const anahtar = briefScheduleKey(); + if (!anahtar) return; const d = sonGonderim(); if (d.son === anahtar) return; sonGonderimYaz({ son: anahtar }); diff --git a/server/entitlements.mjs b/server/entitlements.mjs index ddbb8d3..aa2da27 100644 --- a/server/entitlements.mjs +++ b/server/entitlements.mjs @@ -4,7 +4,7 @@ * edilmiyordu — herkes her şeyi kullanabiliyordu. Burada gerçekten uygulanır. */ -import { getUser, documentUsage, listApiKeys, getData } from './user-store.mjs'; +import { getUser, documentUsage, listApiKeys, getData, getTrackedTenderKeys } from './user-store.mjs'; export const PLAN_HAKLARI = { ucretsiz: { @@ -61,7 +61,7 @@ export function kullanim(email, opts) { const h = haklar(email, opts); const belge = documentUsage(email); const ekip = (getData(email, 'ekip') || []).length; - const takip = (getData(email, 'kanban') || []).filter?.((t) => t?.takipte)?.length ?? null; + const takip = getTrackedTenderKeys(email).length; // Yönetici hesapları her zaman tam erişimlidir; bu, satın alınan aboneliği // GİZLEMEMELİ — arayüz ikisini ayrı gösterebilsin diye ikisini de döndürüyoruz. const u = getUser(email); diff --git a/server/feeds-mcp.mjs b/server/feeds-mcp.mjs index 6f5ee70..83ac794 100644 --- a/server/feeds-mcp.mjs +++ b/server/feeds-mcp.mjs @@ -6,6 +6,8 @@ // Claude Desktop (mcp-remote) araçları: search_tenders, get_tender, // search_results, search_items, upcoming_deadlines. Bağımlılık yok. import * as store from './user-store.mjs'; +import { deadlineInfo, tenderDateKey } from '../src/services/tenderCalendar.js'; +import { applyTenderStates, tenderStateKey } from '../src/services/tenderState.js'; import { readHistory, resultsByIkn } from './history-store.mjs'; import * as kalemStore from './kalem-store.mjs'; @@ -25,17 +27,17 @@ export function rssUret({ title, link, description, items }) { /** dd.mm.yyyy → Date (ihaleist tarih biçimi). */ export function trTarih(s) { - const m = /(\d{1,2})[./](\d{1,2})[./](\d{4})/.exec(String(s ?? '')); - return m ? new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : null; + const m = /^(\d{1,2})[./](\d{1,2})[./](\d{4})$/.exec(String(s ?? '').trim()); + const key = m && tenderDateKey({ tarih: `${m[1].padStart(2, '0')}.${m[2].padStart(2, '0')}.${m[3]}` }); + return key ? new Date(key + 'T00:00:00+03:00') : null; } /** Liste filtresi — RSS ve MCP ortak. */ -export function ihaleFiltrele(tenders, { q, il, sektor, sadeceAcik = true, limit = 50 } = {}) { +export function ihaleFiltrele(tenders, { q, il, sektor, sadeceAcik = true, limit = 50 } = {}, now = new Date()) { const qq = norm(q); const ill = norm(il); const sek = norm(sektor); - const bugun = new Date(); bugun.setHours(0, 0, 0, 0); return tenders .filter((t) => !sadeceAcik || !t.kazananFirma) - .filter((t) => !sadeceAcik || !trTarih(t.tarih) || trTarih(t.tarih) >= bugun) + .filter((t) => !sadeceAcik || deadlineInfo(t, now).expired !== true) .filter((t) => !ill || norm(t.sehir).includes(ill)) .filter((t) => !sek || norm(t.sektor).includes(sek)) .filter((t) => !qq || norm(t.baslik).includes(qq) || norm(t.kurum).includes(qq) || String(t.ikn ?? '').includes(qq) || @@ -99,16 +101,20 @@ export const TOOLS = [ const hata = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } }); const sonuc = (id, result) => ({ jsonrpc: '2.0', id: id ?? null, result }); -export function yaklasanIhaleler(email, tenders, gun = 14) { - const takip = new Set((store.getData(email, 'takip') || []).map(String)); - const bugun = new Date(); bugun.setHours(0, 0, 0, 0); - const sinir = new Date(bugun.getTime() + gun * 86400000); - return tenders - .filter((t) => t.ikn && takip.has(String(t.ikn))) - .map((t) => ({ t, d: trTarih(t.tarih) })) - .filter(({ d }) => d && d >= bugun && d <= sinir) - .sort((a, b) => a.d - b.d) - .map(({ t, d }) => ({ ikn: t.ikn, baslik: t.baslik, kurum: t.kurum, sehir: t.sehir, tarih: t.tarih, saat: t.saat, kalan_gun: Math.round((d - bugun) / 86400000), kaynakUrl: t.kaynakUrl })); +export function yaklasanIhaleler(email, tenders, gun = 14, now = new Date()) { + const states = store.getTenderStates(email); + const takip = new Set(store.getTrackedTenderKeys(email, states)); + const days = Math.min(90, Math.max(1, Math.floor(Number(gun) || 14))); + return applyTenderStates(tenders, states) + .filter(t => !t.kazananFirma && takip.has(tenderStateKey(t))) + .map(t => ({ t, info: deadlineInfo(t, now) })) + .filter(({ info }) => info.days !== null && info.days >= 0 && info.days <= days && info.expired !== true) + .sort((a, b) => a.info.days - b.info.days || + (a.info.deadline?.getTime() ?? Infinity) - (b.info.deadline?.getTime() ?? Infinity)) + .map(({ t, info }) => ({ ikn: t.ikn, baslik: t.baslik, kurum: t.kurum, sehir: t.sehir, + tarih: t.tarih, saat: t.saat, kalan_gun: info.days, + son_teklif_zamani: info.deadline?.toISOString() ?? null, + saat_dogrulanmali: !info.deadline, kaynakUrl: t.kaynakUrl })); } export async function aracCalistir(name, args, user, ctx) { diff --git a/server/index.js b/server/index.js index 8993525..ea57e57 100644 --- a/server/index.js +++ b/server/index.js @@ -1,3 +1,4 @@ +import { deadlineInfo } from '../src/services/tenderCalendar.js'; import { exchangeRates } from './exchange-rates.mjs'; import { uniqueTenders, announcementFields } from './tender-quality.mjs'; /** @@ -152,13 +153,6 @@ const bugunEklenenSayisi = (liste) => { return (liste || []).filter((t) => t.kaynak === 'ilan.gov.tr' && t.ilanTarihi === bugun).length; }; -function daysUntil(ddmmyyyy) { - if (!ddmmyyyy) return null; - const [d, m, y] = ddmmyyyy.split('.').map(Number); - if (!d || !m || !y) return null; - return Math.ceil((new Date(y, m - 1, d) - new Date()) / 86400000); -} - // ilan.gov.tr kaydını ihaleist tender modeline çevir function mapAd(ad) { const cityUpper = (ad.addressCityName || '').toLocaleUpperCase('tr-TR'); @@ -171,7 +165,7 @@ function mapAd(ad) { const usul = getFilter(ad, 'İhale Usulü') || 'Belirtilmemiş'; const ihaleTuru = getFilter(ad, 'İhale Türü') || ''; const searchBase = `${ad.title || ''} ${ad.advertiserName || ''}`; // yalnızca arama için - const kalanGun = daysUntil(tarih); + const kalanGun = deadlineInfo({ tarih }).days; let aciliyetBadge = null; if (kalanGun !== null && kalanGun >= 0 && kalanGun <= 3) aciliyetBadge = '⚠️ SON 3 GÜN'; diff --git a/server/user-store.mjs b/server/user-store.mjs index ed05ace..c61243b 100644 --- a/server/user-store.mjs +++ b/server/user-store.mjs @@ -294,6 +294,16 @@ export function getTenderStates(email) { return withDb(db => Object.fromEntries(db.prepare('SELECT * FROM tender_state WHERE email=?').all(email) .map(r => [r.tender_key, { data: JSON.parse(r.data), snapshot: JSON.parse(r.snapshot), version: r.version }]))); } +// Current per-tender state overrides legacy mirrored follow lists, including unfollow. +export function getTrackedTenderKeys(email, states = getTenderStates(email)) { + const legacy = getData(email, 'takip'); + const keys = new Set((Array.isArray(legacy) ? legacy : []).map(String)); + for (const [key, record] of Object.entries(states)) { + if (record.data.takipte === true) keys.add(key); + else if (record.data.takipte === false) keys.delete(key); + } + return [...keys]; +} export function saveTenderState(email, key, patch, snapshot, expectedVersion) { const fields = new Set(['takipte','incelendi','kanbanStatus','atananPersonel','gorev','notes','checklist','bidDraft']); if (!key || key.length > 100 || !patch || typeof patch !== 'object' || Array.isArray(patch) diff --git a/server/zeyilname.mjs b/server/zeyilname.mjs index ff1e8a7..03f95c2 100644 --- a/server/zeyilname.mjs +++ b/server/zeyilname.mjs @@ -59,7 +59,7 @@ export function zeyilnameDurumu() { return { calisiyor, sonTur }; } function takipHaritasi() { const harita = new Map(); for (const u of store.listUsers()) { - const liste = store.getData(u.email, 'takip'); + const liste = store.getTrackedTenderKeys(u.email); if (!Array.isArray(liste)) continue; for (const ikn of liste) { const k = String(ikn); diff --git a/tests/feed-deadlines.test.mjs b/tests/feed-deadlines.test.mjs new file mode 100644 index 0000000..df34070 --- /dev/null +++ b/tests/feed-deadlines.test.mjs @@ -0,0 +1,65 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const dir = mkdtempSync(join(tmpdir(), 'ihaleist-feed-')); +process.env.IHALEIST_USER_DATA_DIR = dir; +const store = await import('../server/user-store.mjs'); +const { ihaleFiltrele, yaklasanIhaleler, trTarih } = await import('../server/feeds-mcp.mjs'); +const { briefMetni, briefHtml, briefScheduleKey } = await import('../server/brief.mjs'); +after(() => rmSync(dir, { recursive: true, force: true })); +const email = 'dates@example.test'; +const rows = [ + { id:'past', ikn:'2026/1', tarih:'06.09.2026', saat:'00:00' }, + { id:'future', ikn:'2026/2', tarih:'06.09.2026', saat:'10:00' }, + { id:'unknown', ikn:'2026/3', tarih:'06.09.2026', saat:'' }, + { id:'invalid', ikn:'2026/4', tarih:'31.09.2026', saat:'10:00' }, + { id:'unfollowed', ikn:'2026/5', tarih:'07.09.2026', saat:'10:00' }, +]; +const now = new Date('2026-09-05T21:30:00Z'); // Istanbul Sep 6, 00:30; UTC still Sep 5. + +test('RSS and MCP use Istanbul deadlines regardless of server timezone', () => { + const previous = process.env.TZ; + try { + for (const zone of ['UTC', 'America/Los_Angeles', 'Europe/Istanbul']) { + process.env.TZ = zone; + assert.deepEqual(ihaleFiltrele(rows, {}, now).map(t => t.id), ['future','unknown','invalid','unfollowed']); + assert.equal(trTarih('6/9/2026').toISOString(), '2026-09-05T21:00:00.000Z'); + assert.equal(trTarih('31.09.2026'), null); + assert.equal(trTarih('06.09.2026 garbage'), null); + } + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } +}); + +test('upcoming API reads saved follows, rejects stale unfollows and includes out-of-pool snapshots', () => { + store.setData(email, 'takip', ['2026/5']); + for (const t of rows) store.saveTenderState(email, t.ikn, { takipte: t.id !== 'unfollowed' }, t, 0); + const outside = { id:'outside', ikn:'2026/6', tarih:'07.09.2026', saat:'09:00' }; + store.saveTenderState(email, outside.ikn, { takipte:true }, outside, 0); + const result = yaklasanIhaleler(email, rows, 14, now); + assert.deepEqual(result.map(t => t.ikn), ['2026/2','2026/3','2026/6']); + assert.equal(result[0].kalan_gun, 0); + assert.equal(result[0].son_teklif_zamani, '2026-09-06T07:00:00.000Z'); + assert.equal(result[1].saat_dogrulanmali, true); + assert.equal(result[1].son_teklif_zamani, null); + assert.equal(result[2].kalan_gun, 1); + assert.deepEqual(yaklasanIhaleler('other@example.test', rows, 14, now), []); + assert.ok(!store.getTrackedTenderKeys(email).includes('2026/5')); + const brief = { hafta:'Test haftası', yaklasan:result, sonuclar:[] }; + for (const output of [briefMetni(brief), briefHtml(brief)]) { + assert.match(output, /10:00 \(Türkiye saati\)/); + assert.match(output, /saat doğrulanmalı/); + } +}); + +test('weekly notification schedule starts at Monday 08:00 Istanbul', () => { + assert.equal(briefScheduleKey(new Date('2026-09-07T04:59:59Z')), null); + assert.equal(briefScheduleKey(new Date('2026-09-07T05:00:00Z')), '2026-8-7'); + assert.equal(briefScheduleKey(new Date('2026-09-07T20:59:59Z')), '2026-8-7'); + assert.equal(briefScheduleKey(new Date('2026-09-07T21:00:00Z')), null); +});