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.
### 📢 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

View File

@ -67,6 +67,7 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
const [targetId, setTargetId] = useState('');
const [messageText, setMessageText] = useState('');
const [isSendingMass, setIsSendingMass] = useState(false);
const [massDelay, setMassDelay] = useState('60');
const [isSendingBdays, setIsSendingBdays] = useState(false);
// Modal de Edição de Modelo
@ -237,7 +238,11 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
const resp = await fetch('/api/enviar-massa', {
method: 'POST',
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();
@ -366,6 +371,21 @@ const Messages: React.FC<MessagesProps> = ({ data, updateData }) => {
/>
</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
onClick={handleMassSend}
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">
Envia avisos para boletos que vencem em até {templates.automationRules.sendDaysBefore} dias.
</p>
<button
onClick={handleDispararPreventivos}
disabled={isSendingPreventive || !data.evolutionConfig?.apiUrl}
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>
<div className="space-y-3 mb-4">
<button
onClick={handleDispararPreventivos}
disabled={isSendingPreventive || !data.evolutionConfig?.apiUrl}
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>
<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 */}
<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
// ============================================================
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.' });
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 evoConfig = appData?.evolutionConfig;
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}`;
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); }
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
// ============================================================
// 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') {
const appData = await getSchoolData();
@ -1124,19 +1149,19 @@ async function executarRotinaCobrancas(tipo = 'ambos') {
for (const cob of atrasados) {
if (!cob.asaas_payment_id || !cob.vencimento) continue;
const vencimento = new Date(cob.vencimento);
vencimento.setHours(0,0,0,0);
const vencimento = getLocalSafeDate(cob.vencimento);
if (!vencimento) continue;
const diffDiasAtraso = Math.floor((hoje.getTime() - vencimento.getTime()) / (1000 * 60 * 60 * 24));
if (diffDiasAtraso >= sendDaysAfter) {
const lastWarn = cob.last_overdue_warning_at ? new Date(cob.last_overdue_warning_at) : null;
if (lastWarn) lastWarn.setHours(0,0,0,0);
const lastWarn = getLocalSafeDate(cob.last_overdue_warning_at);
const diasDesdeUltimoAviso = lastWarn
? Math.floor((hoje.getTime() - lastWarn.getTime()) / (1000 * 60 * 60 * 24))
: null;
const jaEnviadoHoje = lastWarn && lastWarn.getTime() === hoje.getTime();
const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje && (diasDesdeUltimoAviso === null || diasDesdeUltimoAviso >= repeatEveryDays)) {
const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_OVERDUE');
@ -1164,8 +1189,8 @@ async function executarRotinaCobrancas(tipo = 'ambos') {
for (const cob of pendentes) {
if (!cob.asaas_payment_id || !cob.vencimento) continue;
const vencimento = new Date(cob.vencimento);
vencimento.setHours(0,0,0,0);
const vencimento = getLocalSafeDate(cob.vencimento);
if (!vencimento) continue;
const diffDias = Math.ceil((vencimento.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
const sendOnDueDate = rules.sendOnDueDate !== false;
@ -1174,8 +1199,8 @@ async function executarRotinaCobrancas(tipo = 'ambos') {
const currentCount = parseInt(cob.pre_warnings_count) || 0;
if (currentCount < maxPreWarnings) {
const lastWarn = cob.last_pre_warning_at ? new Date(cob.last_pre_warning_at) : null;
const jaEnviadoHoje = lastWarn && lastWarn.toDateString() === hoje.toDateString();
const lastWarn = getLocalSafeDate(cob.last_pre_warning_at);
const jaEnviadoHoje = !rules.ignoreDailyLock && lastWarn && lastWarn.toDateString() === hoje.toDateString();
if (!jaEnviadoHoje) {
const sent = await sendEvolutionMessage(cob.asaas_payment_id, 'PAYMENT_UPCOMING');
@ -1314,6 +1339,16 @@ async function inicializarAgendamento() {
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
app.post('/api/disparar_cobrancas', async (req, res) => {
try {