fix: estabilização final dos lembretes, correção definitiva de timezone e ferramentas de debug para mensagens

This commit is contained in:
Sidney 2026-05-08 09:45:31 -03:00
parent db7b79fe87
commit 161b074bf2
3 changed files with 116 additions and 24 deletions

View File

@ -107,7 +107,7 @@
- **Melhoria:** O build agora ocorre diretamente na arquitetura de destino, sem emulação QEMU, garantindo velocidade e estabilidade total. - **Melhoria:** O build agora ocorre diretamente na arquitetura de destino, sem emulação QEMU, garantindo velocidade e estabilidade total.
### 📢 Automação de Mensagens ### 📢 Automação de Mensagens
- [x] **Estabilização de Lembretes:** Sistema de avisos preventivos e de inadimplência agora é 100% confiável, com tracking preciso de envios e suporte a múltiplos templates dinâmicos. - [x] **Estabilização de Lembretes (V2):** Resolvido bug de deslocamento de fuso horário (-1 dia) nas datas de vencimento. Implementadas ferramentas de debug (ignorar trava diária e reset de contadores) e **controle manual de delay para disparos em massa** para facilitar testes e garantir segurança contra banimento.
## 📋 Próximos Passos Pendentes ## 📋 Próximos Passos Pendentes

View File

@ -67,6 +67,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
const [targetId, setTargetId] = useState(''); const [targetId, setTargetId] = useState('');
const [messageText, setMessageText] = useState(''); const [messageText, setMessageText] = useState('');
const [isSendingMass, setIsSendingMass] = useState(false); const [isSendingMass, setIsSendingMass] = useState(false);
const [massDelay, setMassDelay] = useState('60');
const [isSendingBdays, setIsSendingBdays] = useState(false); const [isSendingBdays, setIsSendingBdays] = useState(false);
// Modal de Edição de Modelo // Modal de Edição de Modelo
@ -237,7 +238,11 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
const resp = await fetch('/api/enviar-massa', { const resp = await fetch('/api/enviar-massa', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alunos: payloadAlunos, mensagem: normalizeLineBreaks(messageText) }) body: JSON.stringify({
alunos: payloadAlunos,
mensagem: normalizeLineBreaks(messageText),
delay: parseInt(massDelay) || 60
})
}); });
const resData = await resp.json(); const resData = await resp.json();
@ -366,6 +371,21 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
/> />
</div> </div>
<div className="bg-white/50 p-3 rounded-xl border border-emerald-100 mb-2">
<label className="block text-[9px] font-black text-emerald-600 uppercase tracking-widest mb-1.5 ml-1">Intervalo Mínimo (Segundos)</label>
<div className="flex items-center gap-2">
<input
type="number"
min="10" max="600"
value={massDelay}
onChange={(e) => setMassDelay(e.target.value)}
className="w-full px-3 py-2 border border-emerald-200 rounded-lg text-sm font-bold text-center focus:ring-emerald-500 focus:outline-none"
/>
<div className="text-[10px] text-emerald-400 font-bold whitespace-nowrap">seg/msg</div>
</div>
<p className="text-[8px] text-emerald-400 mt-1 font-medium leading-tight">Recomendado: 60s ou mais para evitar banimento.</p>
</div>
<button <button
onClick={handleMassSend} onClick={handleMassSend}
disabled={isSendingMass || !data.evolutionConfig?.apiUrl} disabled={isSendingMass || !data.evolutionConfig?.apiUrl}
@ -385,15 +405,52 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
<p className="text-[10px] text-indigo-600 font-medium mb-4"> <p className="text-[10px] text-indigo-600 font-medium mb-4">
Envia avisos para boletos que vencem em até {templates.automationRules.sendDaysBefore} dias. Envia avisos para boletos que vencem em até {templates.automationRules.sendDaysBefore} dias.
</p> </p>
<button <div className="space-y-3 mb-4">
onClick={handleDispararPreventivos} <button
disabled={isSendingPreventive || !data.evolutionConfig?.apiUrl} onClick={handleDispararPreventivos}
className={`w-full py-3.5 px-4 rounded-xl font-black text-sm text-white shadow-lg transition-all active:scale-95 ${ disabled={isSendingPreventive || !data.evolutionConfig?.apiUrl}
isSendingPreventive || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-indigo-600 hover:bg-indigo-700' className={`w-full py-3.5 px-4 rounded-xl font-black text-sm text-white shadow-lg transition-all active:scale-95 ${
}`} isSendingPreventive || !data.evolutionConfig?.apiUrl ? 'bg-slate-400' : 'bg-indigo-600 hover:bg-indigo-700'
> }`}
{isSendingPreventive ? 'Processando...' : 'Enviar Lembretes Agora'} >
</button> {isSendingPreventive ? 'Processando...' : 'Enviar Lembretes Agora'}
</button>
<div className="flex flex-col gap-2 p-3 bg-white/50 rounded-xl border border-indigo-100">
<label className="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
checked={!!templates.automationRules.ignoreDailyLock}
onChange={(e) => {
const newRules = { ...templates.automationRules, ignoreDailyLock: e.target.checked };
setTemplates(prev => ({ ...prev, automationRules: newRules }));
updateData({ messageTemplates: { ...templates, automationRules: newRules } });
}}
className="w-4 h-4 rounded border-indigo-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-[10px] font-black text-indigo-700 uppercase tracking-tight group-hover:text-indigo-900 transition-colors">
Ignorar trava de envio diário (Debug)
</span>
</label>
<button
onClick={async () => {
showConfirm('Zerar Contadores', 'Tem certeza que deseja zerar todos os contadores de avisos (preventivos e atrasos) de todos os alunos? Isso permitirá que eles recebam os avisos novamente.', async () => {
try {
const resp = await fetch('/api/admin/reset-cobrancas-counters', { method: 'POST' });
if (resp.ok) showAlert('Sucesso', 'Todos os contadores foram zerados!', 'success');
else showAlert('Erro', 'Falha ao zerar contadores.', 'error');
} catch (e) {
showAlert('Erro', 'Erro de conexão.', 'error');
}
});
}}
className="w-full py-2 px-3 bg-white border border-indigo-200 text-indigo-600 rounded-lg font-black text-[9px] uppercase tracking-widest hover:bg-indigo-50 hover:border-indigo-300 transition-all active:scale-95"
>
Zerar Contadores de Avisos
</button>
</div>
</div>
{/* Agendamento Automático */} {/* Agendamento Automático */}
<div className="mt-5 pt-5 border-t border-indigo-200"> <div className="mt-5 pt-5 border-t border-indigo-200">

View File

@ -937,13 +937,13 @@ app.put('/api/notificacoes/remover-anexo/:id', async (req, res) => {
// Disparo em Massa // Disparo em Massa
// ============================================================ // ============================================================
app.post('/api/enviar-massa', (req, res) => { app.post('/api/enviar-massa', (req, res) => {
const { alunos, mensagem } = req.body; const { alunos, mensagem, delay } = req.body;
if (!alunos || !Array.isArray(alunos) || alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno.' }); if (!alunos || !Array.isArray(alunos) || alunos.length === 0) return res.status(400).json({ error: 'Nenhum aluno.' });
res.status(200).json({ success: true, message: 'Background iniciado.' }); res.status(200).json({ success: true, message: 'Background iniciado.' });
processarFilaWhatsApp(alunos, mensagem); processarFilaWhatsApp(alunos, mensagem, delay || 60);
}); });
async function processarFilaWhatsApp(alunos, mensagemTemplate) { async function processarFilaWhatsApp(alunos, mensagemTemplate, customDelay = 60) {
const appData = await getSchoolData(); const appData = await getSchoolData();
const evoConfig = appData?.evolutionConfig; const evoConfig = appData?.evolutionConfig;
if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) return; if (!evoConfig?.apiUrl || !evoConfig?.apiKey || !evoConfig?.instanceName) return;
@ -957,7 +957,11 @@ async function processarFilaWhatsApp(alunos, mensagemTemplate) {
const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`; const url = `${evoConfig.apiUrl.replace(/\/$/, '')}/message/sendText/${evoConfig.instanceName}`;
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) }); await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'apikey': evoConfig.apiKey }, body: JSON.stringify({ number: cleanPhone, text: msg }) });
} catch (error) { console.error(`[Massa] Erro ${aluno.nome}:`, error.message); } } catch (error) { console.error(`[Massa] Erro ${aluno.nome}:`, error.message); }
if (i < alunos.length - 1) await new Promise(r => setTimeout(r, Math.floor(Math.random() * 120000) + 60000)); if (i < alunos.length - 1) {
// Delay base informado pelo usuário + variância aleatória de 0-30s para evitar padrões robóticos
const delayMs = (customDelay * 1000) + (Math.floor(Math.random() * 30000));
await new Promise(r => setTimeout(r, delayMs));
}
} }
} }
@ -1103,6 +1107,27 @@ app.get('/api/alunos/:id/carne', async (req, res) => {
// ============================================================ // ============================================================
// ============================================================ // ============================================================
// LÓGICA REUTILIZÁVEL DE DISPARO DE COBRANÇAS // LÓGICA REUTILIZÁVEL DE DISPARO DE COBRANÇAS
// ============================================================
// Helpers de Data
// ============================================================
const getLocalSafeDate = (val) => {
if (!val) return null;
const d = new Date(val);
if (isNaN(d.getTime())) return null;
// Se for string YYYY-MM-DD pura, o JS cria em UTC.
// Forçamos para os componentes locais para evitar o deslocamento de -1 dia.
if (typeof val === 'string' && val.includes('-') && !val.includes('T') && !val.includes(':')) {
const parts = val.split(' ')[0].split('-');
if (parts.length === 3) {
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
}
// Para objetos Date ou strings com tempo, extraímos o dia civil local
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
};
// ============================================================ // ============================================================
async function executarRotinaCobrancas(tipo = 'ambos') { async function executarRotinaCobrancas(tipo = 'ambos') {
const appData = await getSchoolData(); const appData = await getSchoolData();
@ -1124,19 +1149,19 @@ async function executarRotinaCobrancas(tipo = 'ambos') {
for (const cob of atrasados) { for (const cob of atrasados) {
if (!cob.asaas_payment_id || !cob.vencimento) continue; if (!cob.asaas_payment_id || !cob.vencimento) continue;
const vencimento = new Date(cob.vencimento); const vencimento = getLocalSafeDate(cob.vencimento);
vencimento.setHours(0,0,0,0); if (!vencimento) continue;
const diffDiasAtraso = Math.floor((hoje.getTime() - vencimento.getTime()) / (1000 * 60 * 60 * 24)); const diffDiasAtraso = Math.floor((hoje.getTime() - vencimento.getTime()) / (1000 * 60 * 60 * 24));
if (diffDiasAtraso >= sendDaysAfter) { if (diffDiasAtraso >= sendDaysAfter) {
const lastWarn = cob.last_overdue_warning_at ? new Date(cob.last_overdue_warning_at) : null; const lastWarn = getLocalSafeDate(cob.last_overdue_warning_at);
if (lastWarn) lastWarn.setHours(0,0,0,0);
const diasDesdeUltimoAviso = lastWarn const diasDesdeUltimoAviso = lastWarn
? Math.floor((hoje.getTime() - lastWarn.getTime()) / (1000 * 60 * 60 * 24)) ? Math.floor((hoje.getTime() - lastWarn.getTime()) / (1000 * 60 * 60 * 24))
: null; : null;
const jaEnviadoHoje = lastWarn && lastWarn.getTime() === hoje.getTime(); const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje && (diasDesdeUltimoAviso === null || diasDesdeUltimoAviso >= repeatEveryDays)) { if (!jaEnviadoHoje && (diasDesdeUltimoAviso === null || diasDesdeUltimoAviso >= repeatEveryDays)) {
const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE'); const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE');
@ -1164,8 +1189,8 @@ async function executarRotinaCobrancas(tipo = 'ambos') {
for (const cob of pendentes) { for (const cob of pendentes) {
if (!cob.asaas_payment_id || !cob.vencimento) continue; if (!cob.asaas_payment_id || !cob.vencimento) continue;
const vencimento = new Date(cob.vencimento); const vencimento = getLocalSafeDate(cob.vencimento);
vencimento.setHours(0,0,0,0); if (!vencimento) continue;
const diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)); const diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
const sendOnDueDate = rules.sendOnDueDate !== false; const sendOnDueDate = rules.sendOnDueDate !== false;
@ -1174,8 +1199,8 @@ async function executarRotinaCobrancas(tipo = 'ambos') {
const currentCount = parseInt(cob.pre_warnings_count) || 0; const currentCount = parseInt(cob.pre_warnings_count) || 0;
if (currentCount < maxPreWarnings) { if (currentCount < maxPreWarnings) {
const lastWarn = cob.last_pre_warning_at ? new Date(cob.last_pre_warning_at) : null; const lastWarn = getLocalSafeDate(cob.last_pre_warning_at);
const jaEnviadoHoje = lastWarn && lastWarn.toDateString() === hoje.toDateString(); const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje) { if (!jaEnviadoHoje) {
const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING'); const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING');
@ -1314,6 +1339,16 @@ async function inicializarAgendamento() {
async function startServer() { async function startServer() {
// Rota para zerar contadores de avisos
app.post('/api/admin/reset-cobrancas-counters', async (req, res) => {
try {
await pool.query('UPDATE alunos_cobrancas SET pre_warnings_count = 0, last_pre_warning_at = NULL, overdue_warnings_count = 0, last_overdue_warning_at = NULL');
return res.json({ success: true, message: 'Contadores zerados com sucesso!' });
} catch (e) {
return res.status(500).json({ error: e.message });
}
});
// Disparo Manual de Inadimplência e Lembretes // Disparo Manual de Inadimplência e Lembretes
app.post('/api/disparar_cobrancas', async (req, res) => { app.post('/api/disparar_cobrancas', async (req, res) => {
try { try {