diff --git a/src/components/whatsapp/movement-campaign-control.tsx b/src/components/whatsapp/movement-campaign-control.tsx
index 2919b9cf..6f530bd2 100644
--- a/src/components/whatsapp/movement-campaign-control.tsx
+++ b/src/components/whatsapp/movement-campaign-control.tsx
@@ -22,7 +22,7 @@ type CampaignKind = "movement" | "publication";
type Preview = {
ok:boolean;error?:string;consentAttested?:boolean;kind?:CampaignKind;counts?:{
scanned:number;withoutPhone:number;withoutEvent:number;blocked:number;samePhone:number;
- alreadyQueued:number;eligible:number;alreadyClosed?:number;consentMissing?:number;
+ alreadyQueued:number;eligible:number;alreadyClosed?:number;closedEligible?:number;consentMissing?:number;
needsReview?:number;alreadyNotified?:number;missingReturn?:number;noNewMovement?:number;
}; samples?:Sample[];
};
@@ -172,8 +172,8 @@ export function MovementCampaignControl() {
{kind==='publication'?'Avisar novidades pendentes da carteira':'Avisar clientes — última movimentação'}
{kind==='publication'
- ? 'Considera toda a carteira da empresa, de todos os responsáveis. Envia apenas para processos abertos com movimentação DataJud/DJEN posterior ao último retorno marcado. Eventos antigos e processos encerrados são excluídos.'
- : 'Consulta toda a carteira da empresa. Prepara um aviso somente quando há movimentação posterior ao último retorno, excluindo encerrados, bloqueados e avisos já registrados.'}
+ ? 'Considera toda a carteira da empresa, de todos os responsáveis. Considera também processos marcados como encerrados. Envia somente movimentação DataJud/DJEN posterior ao último retorno, com teor oficial verificável, consentimento e sem bloqueio.'
+ : 'Consulta toda a carteira da empresa. Prepara um aviso por novidade posterior ao último retorno, inclusive de processos marcados como encerrados. Bloqueios, avisos repetidos e eventos sem prova verificável são excluídos.'}
{loading&&!preview?
-
{preview.counts.alreadyClosed||0} já encerrados na carteira (excluídos)
+
{preview.counts.closedEligible||0} marcados como encerrados com aviso verificável elegível (incluídos na seleção)
{preview.counts.consentMissing||0} sem autorização expressa de WhatsApp (excluídos)
{preview.counts.noNewMovement||0} sem novidade após o último retorno (excluídos)
{preview.counts.missingReturn||0} sem data de último retorno para comparar
@@ -211,8 +211,8 @@ export function MovementCampaignControl() {
Nenhum comunicado está pronto neste momento. Retornos vencidos não são envios aptos.
O scanner DataJud + DJEN pode conferir casos individuais; somente novidades oficiais
- posteriores ao último retorno entram na fila. Casos encerrados e telefones ausentes
- permanecem excluídos.
+ posteriores ao último retorno entram na fila. Casos sem novidade verificável e telefones ausentes
+ permanecem excluídos, independentemente do status interno.
)}
diff --git a/src/lib/wa-daily-return-policy.test.ts b/src/lib/wa-daily-return-policy.test.ts
index e2ea790e..29a33b32 100644
--- a/src/lib/wa-daily-return-policy.test.ts
+++ b/src/lib/wa-daily-return-policy.test.ts
@@ -1,5 +1,6 @@
import {describe,expect,it} from 'vitest';
import {isStatusRequest,normalizePhone,prepareDailyReturn,type ReturnCase} from './wa-daily-return-policy';
+import {prepareMovementAlert} from './wa-movement-builder';
const base:ReturnCase={
id:1,empresa_id:'11111111-1111-1111-1111-111111111111',
cliente:'Maria de Souza',telefone:'(11) 99999-4321',
@@ -43,6 +44,16 @@ describe('retorno inteligente DataJud/DJEN',()=>{
expect(prepareDailyReturn({...base,ultimo_retorno:null},{mode:'due',today:'2026-10-09'}).reason).toBe('missing_return');
expect(prepareDailyReturn({...base,status:'ENCERRADO'},{mode:'due',today:'2026-10-09'}).reason).toBe('closed');
});
+ it('includes closed cases only in verified movement campaigns, not routine due returns',()=>{
+ const closed={...base,status:'ENCERRADO'};
+ const allowed=prepareMovementAlert(closed);
+ expect(allowed.reason).toBe('ok');
+ expect(allowed.alert?.message).toContain('Setor Processual');
+ expect(allowed.alert?.message).not.toMatch(/seu processo foi encerrado|seu processo foi extinto/i);
+ expect(prepareDailyReturn(closed,{mode:'due',today:'2026-10-09'}).reason).toBe('closed');
+ expect(prepareMovementAlert({...closed,ultimo_retorno:'2026-10-08'}).reason).toBe('no_new_movement');
+ expect(prepareMovementAlert({...closed,dados:{whatsapp_opt_in:true,whatsapp_opt_out:true}}).reason).toBe('blocked');
+ });
it('requires explicit opt-in and honors opt-out',()=>{
expect(prepareDailyReturn({...base,dados:{}},{mode:'due',today:'2026-10-09'}).reason).toBe('no_consent');
expect(prepareDailyReturn({...base,dados:{whatsapp_opt_in:true,nao_contatar:true}},{mode:'due',today:'2026-10-09'}).reason).toBe('blocked');
diff --git a/src/lib/wa-daily-return-policy.ts b/src/lib/wa-daily-return-policy.ts
index 311401fb..718243e9 100644
--- a/src/lib/wa-daily-return-policy.ts
+++ b/src/lib/wa-daily-return-policy.ts
@@ -64,10 +64,13 @@ export function isStatusRequest(input:string):boolean {
* A date-only last-return record cannot distinguish two events on the same day.
*/
export function prepareDailyReturn(row:ReturnCase,opts:{
- mode:ReturnMode;today?:string;intervalDays?:number;consentAttested?:boolean;
+ mode:ReturnMode;today?:string;intervalDays?:number;consentAttested?:boolean;includeClosed?:boolean;
}):ReturnDecision {
const meta=row.dados||{};
- if(isCasoEncerrado(row))return {ready:null,reason:'closed'};
+ // Movimento posterior confirmado pode ocorrer em uma carteira marcada como encerrada.
+ // Apenas os avisos de movimentação da carteira optam por incluir esses casos;
+ // um status interno nunca é prova de encerramento definitivo no tribunal.
+ if(isCasoEncerrado(row) && !opts.includeClosed)return {ready:null,reason:'closed'};
const hasOptIn=['whatsapp_opt_in','consentimento_whatsapp','whatsapp_autorizado'].some(k=>affirmative(meta[k]));
if(['nao_contatar','não_contatar','whatsapp_opt_out','optOut','optout','bloquear_whatsapp','naoEnviarWhatsapp'].some(k=>blockedValue(meta[k])) || ['whatsapp_opt_in','consentimento_whatsapp','whatsapp_autorizado'].some(k=>denied(meta[k])))
return {ready:null,reason:'blocked'};
diff --git a/src/lib/wa-movement-builder.test.ts b/src/lib/wa-movement-builder.test.ts
index f2e410d0..3df4ca33 100644
--- a/src/lib/wa-movement-builder.test.ts
+++ b/src/lib/wa-movement-builder.test.ts
@@ -33,8 +33,8 @@ describe('Avisos processuais WA.Auto',()=>{
expect(prepareMovementAlert({...base,dados:{whatsapp_opt_in:false}}).reason).toBe('blocked');
expect(prepareMovementAlert({...base,dados:{consentimento_whatsapp:'não'}}).reason).toBe('blocked');
});
- it('excludes closed and already-reported events',()=>{
- expect(prepareMovementAlert({...base,status:'ENCERRADO'}).reason).toBe('closed');
+ it('includes closed cases with verified updates, but excludes already-reported events',()=>{
+ expect(prepareMovementAlert({...base,status:'ENCERRADO'}).reason).toBe('ok');
expect(prepareMovementAlert({...base,ultimo_retorno:'2026-10-05'}).reason).toBe('no_new_movement');
expect(prepareMovementAlert({...base,ultimo_retorno:null}).reason).toBe('missing_return');
});
diff --git a/src/lib/wa-movement-builder.ts b/src/lib/wa-movement-builder.ts
index cf0a8743..2fa601e8 100644
--- a/src/lib/wa-movement-builder.ts
+++ b/src/lib/wa-movement-builder.ts
@@ -9,7 +9,7 @@ export type Alert = {
/** Both scanner and portfolio queues use the same last-return cutoff. */
export function prepareMovementAlert(row: SourceRow, consentAttested = false) {
- const result = prepareDailyReturn(row, {mode:'single', consentAttested});
+ const result = prepareDailyReturn(row, {mode:'single', consentAttested, includeClosed:true});
const notice = result.ready;
if (!notice) return {alert:null, reason:result.reason};
return {reason:'ok' as const, alert:{
diff --git a/src/lib/wa-movement-campaign.ts b/src/lib/wa-movement-campaign.ts
index b23e1b72..c8851051 100644
--- a/src/lib/wa-movement-campaign.ts
+++ b/src/lib/wa-movement-campaign.ts
@@ -10,6 +10,7 @@ import { persistWhatsAppMessage } from '@/lib/whatsapp-persist';
import { prepareMovementAlert, type SourceRow, type Alert } from '@/lib/wa-movement-builder';
import {getDailyReturnSettings,upsertDailyReturnSettings} from '@/lib/wa-daily-return-service';
import {brazilToday} from '@/lib/wa-daily-return-policy';
+import {isCasoEncerrado} from '@/lib/status-encerrado';
async function requireManager() {
@@ -28,7 +29,7 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement',
const db = await getSupabaseAdmin();
const entries: Alert[] = [];
const counts = { scanned:0, withoutPhone:0, withoutEvent:0, blocked:0, samePhone:0,
- alreadyClosed:0, consentMissing:0, needsReview:0, alreadyNotified:0, missingReturn:0, noNewMovement:0 };
+ alreadyClosed:0, closedEligible:0, consentMissing:0, needsReview:0, alreadyNotified:0, missingReturn:0, noNewMovement:0 };
let after=0;
for (;;) {
const {data,error}=await db.rpc('wa_notice_portfolio_rows',{p_empresa:empresaId,p_after:after,p_limit:3000});
@@ -46,7 +47,10 @@ async function collectPortfolio(empresaId: string, kind:CampaignKind='movement',
else if(reason==='needs_source_review')counts.needsReview++;
else if(reason==='no_new_movement')counts.noNewMovement++;
else counts.withoutEvent++;
- } else entries.push(alert);
+ } else {
+ entries.push(alert);
+ if(isCasoEncerrado(row))counts.closedEligible++;
+ }
}
if(rows.length<3000)break;
after=Number(rows[rows.length-1].id);
@@ -99,7 +103,7 @@ export async function createMovementCampaign(confirmed: boolean,kind:CampaignKin
const {entries}=await collectPortfolio(ctx.empresa_id,kind,confirmed||settings.consentAttested);
if (!entries.length) throw new Error(
kind === 'publication'
- ? 'Nenhum aviso elegível: confira telefone, data do último retorno e novidade em processos abertos.'
+ ? 'Nenhum aviso elegível: confira telefone, último retorno e nova publicação verificada na carteira.'
: 'Nenhum processo com telefone válido e movimentação identificada.'
);
const {data: campaign,error: createErr} = await db.from('wa_movement_campaigns')