diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index c074ebf..7879f3e 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -1062,3 +1062,11 @@ Taslak türü seçicisine erişilebilir ad eklendi. Üretim derlemesi geçti. Ö - Yönetici yanıtında `kanallar.telegram/email` için `denenen/kabul/hata`, kapalı tercih sayısı `kapali`, yapılandırılmış kanalı olmayanların sayısı `kanalsiz` var. `gonderilen` en az bir kanalda kabul edilen kullanıcı sayısı, `hata` en az bir hata yaşayan kullanıcı sayısıdır; kısmi başarıda ikisi de artabilir. `success` hata varsa false, `partial` en az bir kullanıcıya kabul varken hata varsa true. Sağlayıcı kabulü alıcının okuduğunu kanıtlamaz. - Altı yeni test: zorla opt-out, kısmi kanal hatası, eksik/false kabul, gönderim sırasında tercih değişikliği, boş/kanalsız özet, kullanıcı/ilan kaynağı hatası. 243 test geçti; gerçek e-posta/Telegram gönderilmedi. - Henüz kapanmayanlar: kalıcı kanal bazlı tekrar engeli, zamanlayıcının gönderimden önce hafta kaydı yazması, Telegram uzun içerik kesilmesi. Bu değişiklik otomatik yeniden deneme eklemiyor; kabul edilmiş iletilerin tekrarına yol açacak toplu tekrar çözüm olarak kullanılmamalı. + +### 2026-09-06 — Haftalık özet içerik bütünlüğü + +- Özet oluşturmadaki ilk 200 takip/ilk 15 sonuç sınırları kaldırıldı. Aynı takip anahtarı tekrar işlenmiyor; sonuçlar tarihe göre yeniler önce sıralanıyor. Önceki yedi tam takvim günü Türkiye saatine göre hesaplanıyor; başlangıç günü dahil, bugün ve gelecek tarihler hariç. Başlık bu aralığı açıkça belirtiyor; haftalık tarih etiketi sunucu saat dilimine bağlı değil. +- Telegram için özetin ilk 3900 karakterini kesmek yerine tüm metin en fazla 3900 UTF-16 birimlik parçalara ayrılıyor; emoji çiftleri bölünmüyor. Özet düz metin gönderiliyor; kaynak başlıklarındaki `<`, `>` ve `&` HTML olarak yorumlanmıyor. Diğer mevcut Telegram çağrılarının HTML modu korunuyor. +- Tercih her parça öncesi kontrol ediliyor. Bir parça hata verirse kalan Telegram parçaları gönderilmiyor; e-posta kanalı ayrıca deneniyor. Telegram `denenen/kabul/hata` sayaçları artık mesaj parçası sayısıdır. `gonderilen` en az bir mesaj kabulü bulunan kullanıcı sayısıdır, tüm parçaların tamamlandığı anlamına gelmez; hatalar birlikte değerlendirilmelidir. +- Doğrulama: 248 testlik tüm süit ve ek 1 Telegram taşıma testi geçti; build başarılı (mevcut büyük paket uyarısı sürüyor). 205 takip kaydı, 15 üzeri sonuç, tarih sınırları, uzun Türkçe/emoji içeriğin birebir birleşmesi, parça hatası ve gerçek HTTP çağrısı yerine sahte taşıyıcı ile düz metin gövdesi doğrulandı. Gerçek mesaj gönderilmedi. +- Açık işler: kalıcı parça/kanal teslimat kaydı, zamanlayıcı/yeniden deneme, sağlayıcı oran limitleri ve izinli test alıcısında kabul. Bu aşama çökme sonrası tam-bir-kez teslimat garantisi vermez. Kaynakta sözleşme tarihi bulunmadığında mevcut ihale tarihi yedeği kullanılıyor; geçmiş sonuç tarihi kaynak doğruluğu ayrıca doğrulanmalı. diff --git a/server/brief-content.mjs b/server/brief-content.mjs new file mode 100644 index 0000000..5048de4 --- /dev/null +++ b/server/brief-content.mjs @@ -0,0 +1,35 @@ +/** Assemble every tracked result in the previous seven Istanbul calendar dates. */ +export function createBriefBuilder({ trackedKeys, upcoming, results, parseDate }) { + return function buildBrief(email, tenders, now = new Date()) { + const yaklasan = upcoming(email, tenders, 7, now); + const today = new Date(now.getTime() + 3 * 3600000).toISOString().slice(0,10); + const end = Date.parse(today + 'T00:00:00+03:00'); + const start = end - 7 * 86400000; + const dated = []; + for (const ikn of new Set(trackedKeys(email))) { + for (const result of results(ikn) || []) { + const date = parseDate(result.sozlesmeTarihi) || parseDate(result.tarih); + if (!date || date.getTime() < start || date.getTime() >= end) continue; + dated.push({ timestamp:date.getTime(), value:{ ikn, baslik:result.baslik, kazanan:result.kazananFirma, + bedel:result.sozlesmeBedeli, birim:result.paraBirimi, teklif:result.teklifSayisi } }); + } + } + dated.sort((a,b) => b.timestamp-a.timestamp || String(a.value.ikn).localeCompare(String(b.value.ikn),'tr')); + const sonuclar = dated.map(row=>row.value); + const hafta = now.toLocaleDateString('tr-TR', { timeZone:'Europe/Istanbul', day:'2-digit', month:'long', year:'numeric' }); + return { hafta, yaklasan, sonuclar, bos:!yaklasan.length && !sonuclar.length }; + }; +} + +/** Keep UTF-16 surrogate pairs intact while bounding each Telegram message. */ +export function splitBriefText(text, limit=3900) { + if (!Number.isInteger(limit) || limit < 2 || limit > 3900) throw new Error('Geçersiz mesaj uzunluğu.'); + const parts=[]; + let part=''; + for (const char of String(text)) { + if (part.length + char.length > limit) { parts.push(part); part=''; } + part += char; + } + if (part) parts.push(part); + return parts; +} diff --git a/server/brief-delivery.mjs b/server/brief-delivery.mjs index 6e723eb..edf768b 100644 --- a/server/brief-delivery.mjs +++ b/server/brief-delivery.mjs @@ -1,3 +1,5 @@ +import { splitBriefText } from './brief-content.mjs'; + /** Delivery accounting shared by scheduled and administrator-triggered briefs. */ export function createBriefSender({ store, buildBrief, text, html, telegramConfigured, sendTelegram, emailConfigured, sendEmail, warn = console.warn }) { return async function sendBrief(tenderGetir) { @@ -14,16 +16,20 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi if (brief.bos) { r.bos++; continue; } const channels = []; const telegram = store.getData(user.email, 'telegram'); - if (telegramConfigured() && telegram?.chatId) channels.push({ - name:'telegram', send:() => sendTelegram(telegram.chatId, text(brief).slice(0,3900)), - valid:receipt => receipt === true, - }); + if (telegramConfigured() && telegram?.chatId) { + for (const part of splitBriefText(text(brief))) channels.push({ + name:'telegram', send:() => sendTelegram(telegram.chatId, part, { parseMode:null }), + valid:receipt => receipt === true, + }); + } if (emailConfigured()) channels.push({ name:'email', send:() => sendEmail({ to:user.email, subject:`İhaleist — Haftalık Brief (${brief.hafta})`, html:html(brief), text:text(brief) }), valid:receipt => typeof receipt?.id === 'string' && Boolean(receipt.id.trim()), }); if (!channels.length) r.kanalsiz++; + const failedChannels = new Set(); for (const channel of channels) { + if (failedChannels.has(channel.name)) continue; // An earlier channel can await the network while the user disables briefs. if (store.getData(user.email, 'brief')?.kapali) { r.kapali++; break; } const counts = r.kanallar[channel.name]; @@ -35,6 +41,7 @@ export function createBriefSender({ store, buildBrief, text, html, telegramConfi accepted = true; } catch (error) { counts.hata++; + failedChannels.add(channel.name); failed = true; warn('[brief]', user.email, channel.name, error.message); } diff --git a/server/brief.mjs b/server/brief.mjs index 41e7755..31ee6f2 100644 --- a/server/brief.mjs +++ b/server/brief.mjs @@ -1,8 +1,9 @@ // Haftalık brief — Pazartesi 08:00: takipteki ihalelerden bu hafta yapılacaklar, -// geçen hafta sonuçlanan takipliler, takiplilere gelen zeyilname sayısı. +// önceki yedi takvim günündeki takipli sonuç kayıtları. // Telegram (bağlıysa) + e-posta. Son gönderim data/brief-son.json'da tutulur; // saatlik kontrol, haftada bir gönderim (node-cron yok, setInterval). import fs from 'node:fs'; +import { createBriefBuilder } from './brief-content.mjs'; import { createBriefSender } from './brief-delivery.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,21 +26,9 @@ function sonGonderimYaz(d) { const paraTR = (v, birim) => (v == null ? '' : `${Number(v).toLocaleString('tr-TR')} ${birim || 'TL'}`); /** Bir kullanıcı için brief içeriği. */ -export function briefOlustur(email, tenders) { - const takip = store.getTrackedTenderKeys(email); - const yaklasan = yaklasanIhaleler(email, tenders, 7); - const birHafta = Date.now() - 7 * 86400000; - const sonuclar = []; - for (const ikn of takip.slice(0, 200)) { - const r = resultsByIkn(ikn) || []; - for (const s of r) { - const d = trTarih(s.sozlesmeTarihi) || trTarih(s.tarih); - if (d && d.getTime() >= birHafta) sonuclar.push({ ikn, baslik: s.baslik, kazanan: s.kazananFirma, bedel: s.sozlesmeBedeli, birim: s.paraBirimi, teklif: s.teklifSayisi }); - } - } - const hafta = new Date().toLocaleDateString('tr-TR', { day: '2-digit', month: 'long', year: 'numeric' }); - return { hafta, yaklasan, sonuclar: sonuclar.slice(0, 15), bos: !yaklasan.length && !sonuclar.length }; -} +export const briefOlustur = createBriefBuilder({ + trackedKeys:store.getTrackedTenderKeys, upcoming:yaklasanIhaleler, results:resultsByIkn, parseDate:trTarih, +}); 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)' @@ -52,7 +41,7 @@ export function briefMetni(b) { 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}):`); + s.push('', `🏁 Önceki 7 takvim günündeki takipli sonuç kayıtları (${b.sonuclar.length}):`); for (const r of b.sonuclar) s.push(`• ${r.baslik} → ${r.kazanan || 'kazanan açıklanmadı'}${r.bedel != null ? ' · ' + paraTR(r.bedel, r.birim) : ''}${r.teklif != null ? ' · ' + r.teklif + ' teklif' : ''}`); } s.push('', 'ihaleist.com'); @@ -66,7 +55,7 @@ export function briefHtml(b) { return `
Bu brief her Pazartesi gönderilir; Bildirimler sekmesinden kapatabilirsiniz.