Pular para o conteúdo

D-8 — Manutenção Programada

Parte do Ciclo de Demandas — Fase 2


Gera demandas automáticas de manutenção preventiva a partir de projetos concluídos com parâmetros de manutenção definidos. Quando uma demanda do tipo “projeto” ou “obra” é concluída e o responsável técnico registra um plano de manutenção, esta colônia assume o monitoramento: agenda os ciclos, monta o dossiê completo com histórico do projeto, materiais utilizados e intervenções anteriores e dispara a demanda de manutenção no momento programado.

A demanda gerada entra no pipeline normal como qualquer outra — é categorizada, ranqueada e atribuída a um conselheiro. A diferença é que o conselheiro recebe um dossiê pronto, com as informações que normalmente precisaria descobrir por conta própria: quem construiu, com que material, quando foi a última manutenção e qual intervenção é esperada agora. O trabalho do conselheiro deixa de ser investigativo e passa a ser de acionamento e acompanhamento.

A D-8 não executa manutenção, não fiscaliza o estado físico do ativo, não decide prioridade e não avalia a qualidade da manutenção executada. Apenas gera a demanda no momento certo, com o dossiê completo, e mantém o vínculo rastreável com o projeto original.

A manutenção é tratada como parte do ciclo de vida de um projeto, não como exceção. O ciclo de uma demanda só fecha de fato quando a manutenção programada está ativa e sendo cumprida. Projetos concluídos sem plano de manutenção são ignorados. Projetos com plano de manutenção ativo geram demandas automaticamente até o fim da vida útil programada ou até o encerramento manual do monitoramento.



A D-8 é um módulo NestJS com encapsulamento próprio dentro do monolito modular. É uma colônia mista: consome eventos do barramento via EventBusService (N-0a), executa um CronJob de varredura periódica via @nestjs/schedule e publica eventos de saída. Não expõe endpoints REST.

src/demanda/d-8-manutencao-programada/
├── d8.module.ts # Module definition
├── d8.service.ts # Lógica de negócio: handlers de eventos, CronJob, orquestração
├── entities/
│ ├── projeto-monitorado.entity.ts # Prisma entity para d8.projetos_monitorados
│ ├── historico-manutencao.entity.ts # Prisma entity para d8.historico_manutencoes
│ ├── processed-event.entity.ts # Prisma entity para d8.processed_events
│ └── consumer-offset.entity.ts # Prisma entity para d8.consumer_offset
├── repositories/
│ ├── projeto-monitorado.repository.ts # Acesso a d8.projetos_monitorados
│ ├── historico-manutencao.repository.ts # Acesso a d8.historico_manutencoes (append + query)
│ ├── processed-event.repository.ts # Acesso a d8.processed_events
│ └── consumer-offset.repository.ts # Acesso a d8.consumer_offset
├── manutencao/
│ ├── dossie-builder.ts # Monta o dossiê completo da demanda de manutenção
│ ├── agendador-ciclos.ts # CronJob: varre projetos com ciclo vencido e gera demandas
│ ├── calculadora-peso-atraso.ts # Calcula peso adicional por atraso além da tolerância
│ └── d8.constants.ts # Config estática: profundidade máxima, intervalo de varredura, limiares de atraso
├── dto/
│ ├── plano-manutencao.dto.ts # Contrato do plano_manutencao embutido em demanda.concluída
│ ├── demanda-manutencao-gerada.dto.ts # Contrato do evento demanda.manutenção_gerada
│ └── dossie-manutencao.dto.ts # Estrutura do dossiê montado
└── types.ts # Tipos internos: StatusProjeto, StatusCiclo, TipoManutencao
@Module({
imports: [ScheduleModule.forRoot()], // @nestjs/schedule para CronJob de varredura
controllers: [], // Colônia sem REST
providers: [
D8Service,
ProjetoMonitoradoRepository,
HistoricoManutencaoRepository,
ProcessedEventRepository,
ConsumerOffsetRepository,
DossieBuilder,
AgendadorCiclos,
CalculadoraPesoAtraso,
],
exports: [],
})
export class D8Module implements OnModuleInit {
constructor(private readonly d8Service: D8Service) {}
async onModuleInit() {
await this.d8Service.iniciar();
}
}
  • O módulo não é @Global(). A D-8 não é dependência de nenhuma outra colônia. Outras colônias consomem seus eventos (demanda.recebida, demanda.manutenção_gerada, manutenção.ciclo_encerrado), não seu código.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O módulo não importa RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento do publicar().
  • O módulo importa ScheduleModule do @nestjs/schedule para o CronJob de varredura de ciclos (@Cron no AgendadorCiclos).
  • O OnModuleInit dispara o protocolo de inicialização: carrega config estática, replay de eventos perdidos + registro de handlers, inicia o CronJob.
  • O módulo não registra ThrottlerModule — a D-8 não expõe endpoints REST.
  • Os parâmetros de configuração (profundidade_maxima, intervalo_varredura_minutos, limiares_atraso) são carregados de manutencao/d8.constants.ts — arquivo de configuração estática na Fase 2.
  • O CronJob de varredura usa a estratégia de polling periódico em vez de agendamento dinâmico por projeto. A cada execução, varre todos os projetos com proximo_ciclo <= NOW(). O custo é baixo para o volume esperado na Fase 2 e elimina a complexidade de persistir cron jobs dinâmicos entre reinicializações.
interface ID8Service {
iniciar(): Promise<void>;
onDemandaConcluida(event: EventLog): Promise<void>;
onManutencaoExecutada(event: EventLog): Promise<void>;
onDemandaRecebida(event: EventLog): Promise<void>;
executarVarredura(): Promise<void>;
encerrarMonitoramento(projetoId: string, motivo: string): Promise<void>;
}
// D8Service consome 3 eventos em produção,
// publica 'demanda.recebida', 'demanda.manutenção_gerada' e 'manutenção.ciclo_encerrado'

A interface é interna ao módulo. Nenhuma outra colônia injeta D8Service. A comunicação com o exterior é via barramento (eventos de saída).

A D-8 não expõe controllers REST. Toda interação externa ocorre via eventos no barramento. O CronJob é a única ação iniciada internamente. O encerramento manual de monitoramento de um projeto pode ser exposto via endpoint administrativo na Fase 3, mas na Fase 2 opera apenas por eventos.

1.6 Projeção própria como fonte da verdade do monitoramento

Seção intitulada “1.6 Projeção própria como fonte da verdade do monitoramento”

A D-8 mantém projeção própria dos projetos monitorados e do histórico de manutenções no schema d8. Os dados são populados exclusivamente a partir de eventos consumidos (demanda.concluída, manutenção.executada) e da geração interna de demandas. Nenhuma outra colônia escreve em d8. O estado do monitoramento é reconstruível a partir do replay completo do barramento.


Todas as tabelas da D-8 residem no schema d8 do PostgreSQL. Este schema é de uso exclusivo do módulo D-8. Nenhuma outra colônia lê ou escreve nestas tabelas.

Registro de cada projeto que possui plano de manutenção ativo. Uma linha é criada quando a D-8 recebe demanda.concluída com plano_manutencao preenchido. Uma linha pode ser encerrada por fim de vida útil, encerramento manual ou remoção do plano.

CREATE SCHEMA IF NOT EXISTS d8;
CREATE TABLE d8.projetos_monitorados (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
demanda_original_id UUID NOT NULL,
categoria VARCHAR(100) NOT NULL,
subcategoria VARCHAR(100),
localizacao JSONB NOT NULL DEFAULT '{}',
uc_id UUID NOT NULL,
plano_manutencao JSONB NOT NULL,
profundidade INTEGER NOT NULL DEFAULT 0
CHECK (profundidade >= 0 AND profundidade <= 2),
dossie_original JSONB NOT NULL DEFAULT '{}',
proximo_ciclo TIMESTAMPTZ NOT NULL,
ciclo_atual INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'ativo'
CHECK (status IN ('ativo', 'encerrado', 'pausado')),
data_encerramento TIMESTAMPTZ,
motivo_encerramento VARCHAR(50),
event_id_origem UUID NOT NULL,
event_id_ultima_alteracao UUID NOT NULL,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_d8_projetos_demanda_original
UNIQUE (demanda_original_id)
);
CREATE INDEX idx_d8_projetos_status_proximo_ciclo
ON d8.projetos_monitorados (status, proximo_ciclo)
WHERE status = 'ativo';
CREATE INDEX idx_d8_projetos_uc
ON d8.projetos_monitorados (uc_id);
CREATE INDEX idx_d8_projetos_categoria
ON d8.projetos_monitorados (categoria);
CREATE INDEX idx_d8_projetos_event_origem
ON d8.projetos_monitorados (event_id_origem);
Coluna Tipo Descrição
id UUID PK Identificador interno do projeto monitorado. Gerado pela D-8.
demanda_original_id UUID FK lógica para a demanda que originou o projeto. UNIQUE — cada demanda concluída gera no máximo um registro de monitoramento.
categoria VARCHAR(100) Categoria herdada da demanda original (ex: 3.9_pracas_parques). Usada na geração da demanda de manutenção.
subcategoria VARCHAR(100) Subcategoria específica. Ex: playground_quebrado, calcada_esburacada.
localizacao JSONB {lat: number, lng: number, endereco: string}. Herdado da demanda original.
uc_id UUID Unidade cívica do projeto. Herdado da demanda original.
plano_manutencao JSONB {intervalo_dias: number, tipo: string, prazo_tolerancia_dias: number, vida_util_dias: number}. O plano original definido pelo responsável técnico. Imutável após criação.
profundidade INTEGER Nível de aninhamento: 0 = projeto original, 1 = manutenção do original, 2 = manutenção da manutenção. Limitado a 2.
dossie_original JSONB Dossiê do projeto original: título, responsável técnico, data conclusão, materiais, localização detalhada.
proximo_ciclo TIMESTAMPTZ Data agendada para o próximo ciclo de manutenção. Índice parcial cobre apenas status = 'ativo'.
ciclo_atual INTEGER Quantos ciclos já foram executados. Incrementado a cada manutenção.executada.
status VARCHAR(20) ativo — gerando demandas; encerrado — fim de vida útil ou encerramento manual; pausado — suspenso temporariamente.
data_encerramento TIMESTAMPTZ Quando o monitoramento foi encerrado. Preenchido ao publicar manutenção.ciclo_encerrado ou por encerramento manual.
motivo_encerramento VARCHAR(50) vida_util_esgotada, encerramento_manual, projeto_removido.
event_id_origem UUID event_id do evento demanda.concluída que originou este monitoramento. Para idempotência.
event_id_ultima_alteracao UUID event_id do último evento que alterou este registro. Para rastreamento.
criado_em TIMESTAMPTZ Timestamp de criação do registro.
atualizado_em TIMESTAMPTZ Última alteração no registro.

Histórico append-only de todas as demandas de manutenção geradas. Cada linha corresponde a um ciclo de manutenção de um projeto monitorado. Nenhum registro é alterado ou removido.

CREATE TABLE d8.historico_manutencoes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
projeto_id UUID NOT NULL,
demanda_manutencao_id UUID NOT NULL,
ciclo_numero INTEGER NOT NULL,
data_geracao TIMESTAMPTZ NOT NULL DEFAULT NOW(),
data_prevista TIMESTAMPTZ NOT NULL,
data_tolerancia TIMESTAMPTZ NOT NULL,
data_conclusao TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'gerada'
CHECK (status IN (
'gerada', 'em_andamento',
'concluida', 'vencida', 'cancelada'
)),
conselheiro_id UUID,
dossie_montado JSONB NOT NULL DEFAULT '{}',
peso_atraso NUMERIC(5,4) NOT NULL DEFAULT 0,
event_id_geracao UUID NOT NULL,
event_id_ultima_alteracao UUID NOT NULL,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_d8_historico_projeto
ON d8.historico_manutencoes (projeto_id);
CREATE INDEX idx_d8_historico_demanda
ON d8.historico_manutencoes (demanda_manutencao_id);
CREATE INDEX idx_d8_historico_status
ON d8.historico_manutencoes (projeto_id, status);
CREATE INDEX idx_d8_historico_event_geracao
ON d8.historico_manutencoes (event_id_geracao);
Coluna Tipo Descrição
id UUID PK Identificador interno do registro de histórico.
projeto_id UUID FK lógica para d8.projetos_monitorados.id.
demanda_manutencao_id UUID ID da demanda de manutenção gerada e publicada no barramento.
ciclo_numero INTEGER Número sequencial do ciclo (1, 2, 3…).
data_geracao TIMESTAMPTZ Quando a demanda foi gerada pelo CronJob.
data_prevista TIMESTAMPTZ Data em que o ciclo estava programado (proximo_ciclo no momento da geração).
data_tolerancia TIMESTAMPTZ Data limite de tolerância (data_prevista + prazo_tolerancia_dias).
data_conclusao TIMESTAMPTZ Quando a manutenção foi concluída. Preenchido ao receber manutenção.executada.
status VARCHAR(20) gerada — demanda publicada, aguardando pipeline; em_andamento — conselheiro iniciou acompanhamento; concluida — manutenção executada; vencida — além do prazo de tolerância sem conclusão; cancelada — demanda cancelada.
conselheiro_id UUID FK lógica para o cidadão que atuou como conselheiro relator desta manutenção.
dossie_montado JSONB Dossiê completo entregue ao conselheiro: dados do projeto original, histórico de manutenções anteriores, materiais, responsável técnico, métricas de atraso.
peso_atraso NUMERIC(5,4) Peso adicional calculado quando a geração ocorre após data_prevista. Vai no dossiê para consumo da D-4.
event_id_geracao UUID event_id do evento que gerou este ciclo. Para idempotência.
event_id_ultima_alteracao UUID event_id do último evento que alterou este registro.
criado_em TIMESTAMPTZ Timestamp de criação.
atualizado_em TIMESTAMPTZ Última alteração.

Registro de idempotência para todo evento processado pela D-8. Um event_id só é processado uma vez. Usado como cache de deduplicação com TTL.

CREATE TABLE d8.processed_events (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL,
tipo_evento VARCHAR(150) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_d8_processed_events_event
UNIQUE (event_id)
);

Registro de progresso de consumo para cada tipo de evento. Usado no protocolo de inicialização (iniciar()) para replay de eventos perdidos.

CREATE TABLE d8.consumer_offset (
tipo_evento VARCHAR(150) PRIMARY KEY,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
  1. V001 — Create schemaCREATE SCHEMA IF NOT EXISTS d8
  2. V002 — Create projetos_monitorados — Cria d8.projetos_monitorados com índices e constraints.
  3. V003 — Create historico_manutencoes — Cria d8.historico_manutencoes com índices e constraints.
  4. V004 — Create processed_events — Cria d8.processed_events com UNIQUE em event_id.
  5. V005 — Create consumer_offset — Cria d8.consumer_offset com PK em tipo_evento.
  6. V006 — Seed consumer offsets — 3 tipos seedados em runtime com obterMaiorSequence(), sem seed em zero.

Migrations futuras (Fase 3): adição de coluna d8.projetos_monitorados.config_merge para inteligência de deduplicação de manutenções sobrepostas.

A única FK lógica interna é historico_manutencoes.projeto_id → projetos_monitorados.id. Sem FK formal — a integridade é mantida pela lógica de negócio e pelo processamento sequencial de eventos. historico_manutencoes.demanda_manutencao_id e historico_manutencoes.conselheiro_id não têm FK com schemas de outras colônias.

Tabela projetos_monitorados com UNIQUE em demanda_original_id. Uma demanda concluída gera no máximo um registro de monitoramento. Se o mesmo demanda.concluída for recebido duas vezes (replay), o segundo é ignorado por idempotência no nível de evento, não de registro. A UNIQUE atua como camada adicional de segurança.

plano_manutencao como JSONB, não como colunas separadas. O plano tem campos com semântica específica (intervalo_dias, tipo, prazo_tolerancia_dias, vida_util_dias) que são lidos em bloco pela lógica de agendamento. JSONB preserva a estrutura original do payload e facilita evolução futura (adição de campos como estacao_do_ano, condicao_climatica) sem migration. A validação da estrutura é feita no handler do evento, não no banco.

dossie_original e dossie_montado como JSONB, não como tabela separada. O dossiê é um documento montado no momento da geração, consumido como bloco pelo conselheiro e pela D-7. Não é consultado por campos individuais. JSONB com índice GIN seria prematuro para o volume da Fase 2. Se na Fase 3 houver necessidade de busca textual no dossiê, adiciona-se índice GIN.

peso_atraso como NUMERIC(5,4) em historico_manutencoes. O peso é um valor entre 0 e 1, com 4 casas decimais. Armazenar no histórico permite auditoria: o peso usado no ranking é recuperável mesmo que os parâmetros da calculadora mudem. O dossiê também carrega o peso, mas o campo dedicado facilita consultas agregadas.

Status vencida no histórico. Uma demanda de manutenção entra em status vencida quando a data atual ultrapassa data_tolerancia e a demanda ainda não foi concluída. A transição é calculada sob demanda (não há CronJob dedicado para isso) — leitores do histórico interpretam status = 'gerada' AND NOW() > data_tolerancia como vencida. O status é materializado em consultas que a D-7 faz ao histórico.

Índice parcial em projetos_monitorados (status, proximo_ciclo) WHERE status = 'ativo'. O CronJob varre apenas projetos ativos com proximo_ciclo <= NOW(). O índice parcial cobre exatamente esse padrão de acesso, reduzindo o tamanho do índice e o custo de manutenção.


A D-8 consome três tipos de evento como gatilho de processamento e produz três. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da D-8.

3.1 Evento consumido: demanda.concluída (gatilho primário)

Seção intitulada “3.1 Evento consumido: demanda.concluída (gatilho primário)”
Propriedade Valor
Tipo demanda.concluída
Schema version 1.1.0 na Fase 2, quando o campo plano_manutencao entra no schema
Produtor D-6b (Relatoria e Acompanhamento)
Consumidor D-8 (esta colônia)
Descrição Demanda concluída pelo conselheiro. Pode conter plano de manutenção para projetos/obras.

Payload esperado (schema previsto para a Fase 2, v1.1.0):

interface DemandaConcluidaPayload {
demanda_id: string;
conselheiro_id: string;
unidade_civica_id: string;
data_conclusao: string; // ISO-8601
categoria: string;
subcategoria?: string;
localizacao: {
lat: number;
lng: number;
endereco?: string;
};
resumo_final: string;
responsavel_tecnico?: string; // Nome ou identificação do executor técnico
materiais_utilizados?: string[]; // Lista de materiais empregados na obra/projeto
plano_manutencao?: { // Campo da Fase 2, opcional
intervalo_dias: number;
tipo: string; // Ex: 'inspecao', 'pintura', 'substituicao', 'limpeza'
prazo_tolerancia_dias: number;
vida_util_dias: number;
profundidade: number; // Herdado do plano original, incrementado se nested
};
}

Regras de filtro:

  • Se plano_manutencao é undefined ou null: ignora. Demanda comum, sem manutenção programada.
  • Se plano_manutencao.profundidade > profundidade_maxima (padrão: 2): ignora. Limite de aninhamento.
  • Se já existe projetos_monitorados com demanda_original_id = demanda_id: ignora (idempotente).
Propriedade Valor
Tipo manutenção.executada
Schema version 1.0.0
Produtor D-6b (Relatoria e Acompanhamento)
Consumidor D-8 (esta colônia)
Descrição Demanda de manutenção concluída pelo conselheiro. Publicado pela D-6b quando a demanda tem origem = 'manutenção_programada'.

Payload esperado (schema previsto para a Fase 2, v1.0.0):

interface ManutencaoExecutadaPayload {
demanda_id: string; // ID da demanda de manutenção
projeto_original_id: string; // ID da demanda original que gerou o projeto
conselheiro_id: string;
data_conclusao: string; // ISO-8601
status_execucao: string; // 'concluida', 'parcial', 'nao_executada'
observacoes?: string;
plano_manutencao?: { // Plano para PRÓXIMO ciclo, se aplicável
intervalo_dias: number;
tipo: string;
prazo_tolerancia_dias: number;
vida_util_dias: number;
profundidade: number;
};
}

Regras de processamento:

  • Localiza o projeto_id a partir de historico_manutencoes.demanda_manutencao_id = demanda_id.
  • Atualiza status do histórico para concluida.
  • Recalcula proximo_ciclo = NOW() + plano_manutencao.intervalo_dias no projeto monitorado.
  • Se proximo_ciclo > data_criacao_projeto + vida_util_dias: publica manutenção.ciclo_encerrado e encerra monitoramento.

3.3 Evento consumido: demanda.recebida (tracking de pipeline)

Seção intitulada “3.3 Evento consumido: demanda.recebida (tracking de pipeline)”
Propriedade Valor
Tipo demanda.recebida
Schema version 1.1.0 na versão corrente; a Fase 2 acrescenta origem, projeto_original_id e dossie em nova versão do schema
Produtor D-1a (BFF) ou D-8 (esta colônia, via auto-consumo)
Consumidor D-8 (esta colônia)
Descrição Demanda que entrou no pipeline. D-8 consome apenas demandas com origem = 'manutenção_programada' para tracking.

Payload esperado (filtro aplicado):

interface DemandaRecebidaPayload {
demanda_id: string;
origem: string; // D-8 só processa se === 'manutenção_programada'
projeto_original_id?: string; // ID do projeto monitorado
// ... demais campos do demanda.recebida padrão
}

Regras de processamento:

  • Se origem !== 'manutenção_programada': ignora.
  • Se projeto_original_id não é conhecido em projetos_monitorados: ignora (defensivo).
  • Atualiza historico_manutencoes.status de gerada para em_andamento (a demanda entrou no pipeline e está visível).
Propriedade Valor
Tipo demanda.recebida
Schema version 2.0.0 na Fase 2. A 1.1.0 é a versão corrente do catálogo; a 2.0.0 acrescenta origem, projeto_original_id e dossie, torna cidadao_id opcional e admite canal = 'sistema'
Produtor D-8 (esta colônia)
Consumidores D-1b (Normalização), D-7 (Transparência)
Descrição Demanda de manutenção gerada automaticamente, entrando no pipeline como qualquer demanda cidadã.

Payload:

interface DemandaRecebidaPayload {
demanda_id: string;
origem: 'manutenção_programada';
projeto_original_id: string;
texto_bruto: string; // Descrição automática legível da manutenção esperada
tipo_midia: 'texto';
localizacao_bruta: {
lat: number;
lng: number;
endereco?: string;
};
canal: 'sistema';
categoria_id: string; // Herdada do projeto original
subcategoria_id?: string;
timestamp_criacao: string; // ISO-8601
dossie: DossieManutencao; // Dossiê completo (ver 3.7)
metadata: {
profundidade: number;
intervalo_dias: number;
ciclo_numero: number;
};
}

A versão 2.0.0 é a primeira do catálogo que aceita demanda sem autor cidadão. cidadao_id deixa de ser obrigatório, canal ganha o valor sistema e a unidade cívica é resolvida pela D-2 a partir das coordenadas, como no fluxo comum.

A D-8 publica este evento no barramento. A D-1b o consome e inicia o pipeline de normalização. Para a D-1b, é indistinguível de uma demanda.recebida comum: a diferença está nos campos origem e canal, na ausência de cidadao_id e na presença do dossie. A D-1b na Fase 2 reconhece origem = 'manutenção_programada' e aplica normalização mais leve (texto já estruturado, não há mídia bruta para processar). Publica demanda.normalizada de forma idêntica ao fluxo normal.

Propriedade Valor
Tipo demanda.manutenção_gerada
Schema version 1.0.0
Produtor D-8 (esta colônia)
Consumidores D-7 (Transparência)
Descrição Notificação de que uma demanda de manutenção foi gerada. Usado pela D-7 para timeline e dashboard.

Payload:

interface DemandaManutencaoGeradaPayload {
demanda_id: string; // ID da nova demanda de manutenção
projeto_original_id: string; // ID do projeto monitorado
categoria: string;
localizacao: {
lat: number;
lng: number;
endereco?: string;
};
tipo_manutencao: string; // Tipo de intervenção esperada
intervalo_programado: number; // Intervalo em dias
prazo_tolerancia: number; // Prazo de tolerância em dias
ciclo_numero: number;
data_prevista: string; // ISO-8601 — data em que o ciclo estava programado
data_geracao: string; // ISO-8601 — data real da geração
dias_atraso: number; // 0 se no prazo, > 0 se atrasado
dossie: DossieManutencao;
}

Este evento é puramente informativo. A D-7 o consome para adicionar uma entrada na timeline do projeto original e da UC, exibindo “Manutenção programada gerada para [projeto] — ciclo #N”.

3.6 Evento produzido: manutenção.ciclo_encerrado

Seção intitulada “3.6 Evento produzido: manutenção.ciclo_encerrado”
Propriedade Valor
Tipo manutenção.ciclo_encerrado
Schema version 1.0.0
Produtor D-8 (esta colônia)
Consumidores D-7 (Transparência)
Descrição Notificação de que o monitoramento de um projeto foi encerrado. Fim da vida útil programada ou encerramento manual.

Payload:

interface ManutencaoCicloEncerradoPayload {
projeto_id: string;
demanda_original_id: string;
categoria: string;
uc_id: string;
motivo: string; // 'vida_util_esgotada', 'encerramento_manual', 'projeto_removido'
total_ciclos: number;
data_inicio_monitoramento: string; // ISO-8601
data_encerramento: string; // ISO-8601
}

O dossiê é um documento JSONB montado pelo DossieBuilder e embutido nos eventos demanda.recebida e demanda.manutenção_gerada. É a principal entrega de valor da D-8: o conselheiro recebe tudo que precisa para acionar a manutenção sem trabalho investigativo.

interface DossieManutencao {
projeto_original: {
titulo: string;
descricao: string;
responsavel_tecnico: string | null;
data_conclusao: string; // ISO-8601
materiais_utilizados: string[];
};
historico_manutencoes: Array<{
ciclo: number;
data_prevista: string;
data_real: string;
status: string;
conselheiro: string | null;
observacoes: string | null;
}>;
ciclo_atual: {
numero: number;
tipo_intervencao: string;
intervalo_programado_dias: number;
prazo_tolerancia_dias: number;
data_prevista: string;
data_geracao: string;
};
metricas_atraso: {
dias_atraso: number;
peso_adicional_sugerido: number;
dentro_tolerancia: boolean;
} | null; // null se gerado no prazo
localizacao: {
lat: number;
lng: number;
endereco: string | null;
};
}

3.8 Ordem de operações no handler onDemandaConcluida

Seção intitulada “3.8 Ordem de operações no handler onDemandaConcluida”
handler onDemandaConcluida(evento):
payload = evento.payload as DemandaConcluidaPayload
// 1. Verificar plano de manutenção
se payload.plano_manutencao é nulo ou undefined:
return // nada a fazer
// 2. Verificar profundidade
profundidade = payload.plano_manutencao.profundidade ?? 0
se profundidade > d8Constants.PROFUNDIDADE_MAXIMA:
obs.info("Plano de manutenção ignorado — profundidade excede máximo.", {
demanda_id: payload.demanda_id,
profundidade: profundidade,
})
return
// 3. Idempotência por demanda_original_id
existente = projetoRepo.findByDemandaOriginal(payload.demanda_id)
se existente não é nulo:
return // já registrado
// 4. Montar dossiê original
dossieOriginal = DossieBuilder.montarDossieOriginal(payload)
// 5. Calcular primeiro ciclo
dataConclusao = new Date(payload.data_conclusao)
proximoCiclo = new Date(
dataConclusao.getTime() +
payload.plano_manutencao.intervalo_dias * 86400000
)
// 6. Persistir projeto monitorado
EM TRANSAÇÃO:
projeto = projetoRepo.criar({
demanda_original_id: payload.demanda_id,
categoria: payload.categoria,
subcategoria: payload.subcategoria,
localizacao: payload.localizacao,
uc_id: payload.unidade_civica_id,
plano_manutencao: payload.plano_manutencao,
profundidade: profundidade,
dossie_original: dossieOriginal,
proximo_ciclo: proximoCiclo,
ciclo_atual: 0,
status: 'ativo',
event_id_origem: evento.event_id,
event_id_ultima_alteracao: evento.event_id,
})
processedEventRepo.registrar(evento.event_id, 'demanda.concluída')
obs.info("Projeto registrado para monitoramento de manutenção.", {
projeto_id: projeto.id,
demanda_original_id: payload.demanda_id,
proximo_ciclo: proximoCiclo.toISOString(),
intervalo_dias: payload.plano_manutencao.intervalo_dias,
profundidade: profundidade,
})

3.9 Ordem de operações no handler onManutencaoExecutada

Seção intitulada “3.9 Ordem de operações no handler onManutencaoExecutada”
handler onManutencaoExecutada(evento):
payload = evento.payload as ManutencaoExecutadaPayload
// 1. Idempotência
se processedEventRepo.existe(evento.event_id):
return
// 2. Localizar projeto
projeto = projetoRepo.findById(payload.projeto_original_id)
se projeto é nulo:
obs.warn("manutenção.executada recebido para projeto não monitorado.", {
projeto_original_id: payload.projeto_original_id,
demanda_id: payload.demanda_id,
})
return // defensivo
// 3. Localizar registro no histórico
historico = historicoRepo.findByDemandaManutencao(payload.demanda_id)
se historico é nulo:
obs.warn("manutenção.executada recebido para demanda sem registro no histórico.", {
demanda_id: payload.demanda_id,
})
return
// 4. Verificar vida útil
dataCriacao = projeto.criado_em
vidaUtilMs = projeto.plano_manutencao.vida_util_dias * 86400000
vidaUtilEsgotada = (Date.now() > dataCriacao.getTime() + vidaUtilMs)
EM TRANSAÇÃO:
// 4a. Atualizar histórico
historicoRepo.atualizar(historico.id, {
status: 'concluida',
data_conclusao: payload.data_conclusao,
conselheiro_id: payload.conselheiro_id,
event_id_ultima_alteracao: evento.event_id,
})
se vidaUtilEsgotada:
// 4b. Encerrar monitoramento
projetoRepo.atualizar(projeto.id, {
status: 'encerrado',
data_encerramento: new Date().toISOString(),
motivo_encerramento: 'vida_util_esgotada',
event_id_ultima_alteracao: evento.event_id,
})
// 4c. Publicar encerramento
await eventBus.publicar({
tipo: 'manutenção.ciclo_encerrado',
origem: 'D-8',
event_id: uuidv4(),
correlacao_id: evento.correlacao_id,
payload: {
projeto_id: projeto.id,
demanda_original_id: projeto.demanda_original_id,
categoria: projeto.categoria,
uc_id: projeto.uc_id,
motivo: 'vida_util_esgotada',
total_ciclos: projeto.ciclo_atual,
data_inicio_monitoramento: projeto.criado_em.toISOString(),
data_encerramento: new Date().toISOString(),
},
})
senão:
// 4d. Recalcular próximo ciclo
proximoCiclo = new Date(
Date.now() +
projeto.plano_manutencao.intervalo_dias * 86400000
)
projetoRepo.atualizar(projeto.id, {
proximo_ciclo: proximoCiclo,
ciclo_atual: projeto.ciclo_atual + 1,
event_id_ultima_alteracao: evento.event_id,
})
processedEventRepo.registrar(evento.event_id, 'manutenção.executada')
obs.info("Manutenção registrada como executada.", {
projeto_id: projeto.id,
demanda_manutencao_id: payload.demanda_id,
vida_util_esgotada: vidaUtilEsgotada,
proximo_ciclo: vidaUtilEsgotada ? null : proximoCiclo.toISOString(),
})

3.10 Ordem de operações no handler onDemandaRecebida (tracking)

Seção intitulada “3.10 Ordem de operações no handler onDemandaRecebida (tracking)”
handler onDemandaRecebida(evento):
payload = evento.payload as DemandaRecebidaPayload
// 1. Filtrar apenas manutenção programada
se payload.origem !== 'manutenção_programada':
return
// 2. Idempotência
se processedEventRepo.existe(evento.event_id):
return
// 3. Localizar histórico
historico = historicoRepo.findByDemandaManutencao(payload.demanda_id)
se historico é nulo:
return // a demanda foi gerada por outra origem (defensivo)
// 4. Atualizar status
EM TRANSAÇÃO:
historicoRepo.atualizar(historico.id, {
status: 'em_andamento',
event_id_ultima_alteracao: evento.event_id,
})
processedEventRepo.registrar(evento.event_id, 'demanda.recebida')

A D-8 implementa idempotência em dois níveis:

  1. d8.processed_events — todo event_id processado é registrado antes do commit. Se o mesmo evento chegar duas vezes, é ignorado. Cobre duplicação por replay.

  2. UNIQUE uq_d8_projetos_demanda_original — protege contra criação duplicada de projeto monitorado para a mesma demanda original.

  3. Verificação defensiva nos handlersonManutencaoExecutada e onDemandaRecebida verificam se o projeto/histórico existe antes de operar. Evita falha silenciosa se eventos chegarem fora de ordem.

Estratégia de retry:

  • Eventos consumidos via EventBusService com confirmação após commit. Se o commit falhar, o offset não avança e o evento é reprocessado na próxima inicialização.
  • O CronJob de varredura é idempotente por design: mesmo que execute duas vezes antes do commit, a segunda execução não encontra projetos com proximo_ciclo <= NOW() que já foram processados.

Publicação de dois eventos na geração: demanda.recebida e demanda.manutenção_gerada. demanda.recebida é o evento que inicia o pipeline (consumido por D-1b, D-3, D-4, D-5, D-7). demanda.manutenção_gerada é o evento de notificação específica (consumido apenas pela D-7 para timeline enriquecida). Separar permite que a D-7 exiba “Manutenção programada gerada para [projeto]” sem precisar inspecionar o campo origem de todo demanda.recebida. São audiências diferentes: o pipeline trata como demanda comum; a transparência trata como evento de manutenção.

CronJob de polling em vez de agendamento dinâmico. Agendar um setTimeout ou cron job dinâmico para cada projeto criaria estado volátil que não sobrevive a reinicializações — seria necessário persistir e restaurar timers no iniciar(). O polling periódico é mais simples, determinístico e robusto: o proximo_ciclo está no banco, a varredura é um SELECT com índice parcial. Para o volume da Fase 2 (< 50.000 projetos monitorados), o custo é desprezível.

profundidade_maxima = 2 como constante estática. O limite de aninhamento impede cadeias infinitas de manutenção (a manutenção da manutenção da manutenção…). O valor 2 permite: projeto original → manutenção do original → manutenção da manutenção. Na Fase 3, a inteligência de merge substituirá a limitação por profundidade por detecção de sobreposição.

Auto-consumo do próprio demanda.recebida. A D-8 publica demanda.recebida e também a consome (filtrada por origem = 'manutenção_programada'). Isso permite que a D-8 rastreie o status da demanda que ela mesma gerou sem depender de outras colônias. O auto-consumo é seguro porque o filtro de origem impede processamento em loop.


4.1 DossieBuilder.montarDossie(projeto, cicloNumero) — pseudocódigo

Seção intitulada “4.1 DossieBuilder.montarDossie(projeto, cicloNumero) — pseudocódigo”
função montarDossie(
projeto: ProjetoMonitorado,
cicloNumero: number,
dataPrevista: Date,
dataGeracao: Date
) -> DossieManutencao:
// 1. Dados do projeto original
projetoOriginal = {
titulo: extrairTitulo(projeto.dossie_original),
descricao: extrairDescricao(projeto.dossie_original),
responsavel_tecnico: projeto.dossie_original.responsavel_tecnico ?? null,
data_conclusao: projeto.dossie_original.data_conclusao,
materiais_utilizados: projeto.dossie_original.materiais_utilizados ?? [],
}
// 2. Histórico de manutenções anteriores
historico = historicoRepo.findByProjeto(projeto.id)
historicoFormatado = historico.map(h => ({
ciclo: h.ciclo_numero,
data_prevista: h.data_prevista.toISOString(),
data_real: h.data_geracao.toISOString(),
status: h.status,
conselheiro: h.conselheiro_id,
observacoes: h.dossie_montado?.observacoes ?? null,
}))
// 3. Dados do ciclo atual
cicloAtual = {
numero: cicloNumero,
tipo_intervencao: projeto.plano_manutencao.tipo,
intervalo_programado_dias: projeto.plano_manutencao.intervalo_dias,
prazo_tolerancia_dias: projeto.plano_manutencao.prazo_tolerancia_dias,
data_prevista: dataPrevista.toISOString(),
data_geracao: dataGeracao.toISOString(),
}
// 4. Métricas de atraso
diasAtraso = calcularDiasAtraso(dataPrevista, dataGeracao)
metricasAtraso = null
se diasAtraso > 0:
pesoAdicional = CalculadoraPesoAtraso.calcular(diasAtraso)
metricasAtraso = {
dias_atraso: diasAtraso,
peso_adicional_sugerido: pesoAdicional,
dentro_tolerancia: diasAtraso <= projeto.plano_manutencao.prazo_tolerancia_dias,
}
// 5. Localização
localizacao = {
lat: projeto.localizacao.lat,
lng: projeto.localizacao.lng,
endereco: projeto.localizacao.endereco ?? null,
}
retornar {
projeto_original: projetoOriginal,
historico_manutencoes: historicoFormatado,
ciclo_atual: cicloAtual,
metricas_atraso: metricasAtraso,
localizacao: localizacao,
}

4.2 CalculadoraPesoAtraso.calcular(diasAtraso) — pseudocódigo

Seção intitulada “4.2 CalculadoraPesoAtraso.calcular(diasAtraso) — pseudocódigo”

A D-8 não ranqueia demandas. Ela calcula um peso adicional sugerido que é embutido no dossiê. A D-4 (Priorização e Ranking) consome esse peso quando presente no payload da demanda e o incorpora ao score_horizontal ou como bônus aditivo. A D-8 não impõe o peso — apenas sugere com base em critérios públicos e auditáveis.

função calcular(diasAtraso: number) -> number:
se diasAtraso <= 0:
retornar 0
peso = 0
// Fase 1: crescimento linear (dias 1–30)
// Demanda recém-atrasada ganha visibilidade progressiva
se diasAtraso <= 30:
peso = (diasAtraso / 30) * d8Constants.PESO_ATRASO_FASE1_MAX // 0.15
// Fase 2: crescimento logarítmico (dias 31–90)
// Atraso crônico — ganho marginal decrescente, teto suave
senão se diasAtraso <= 90:
excesso = diasAtraso - 30
peso = d8Constants.PESO_ATRASO_FASE1_MAX +
Math.log10(1 + excesso) / Math.log10(61) *
(d8Constants.PESO_ATRASO_FASE2_MAX - d8Constants.PESO_ATRASO_FASE1_MAX)
// log10(1 + 60) / log10(61) ≈ 0.99 → tende a PESO_ATRASO_FASE2_MAX
// Fase 3: teto (dias 91+)
// Manutenção abandonada — peso máximo, não escala mais
senão:
peso = d8Constants.PESO_ATRASO_TETO // 0.40
retornar peso

Parâmetros em d8.constants.ts:

export const PESO_ATRASO_FASE1_MAX = 0.15; // teto do crescimento linear (30 dias)
export const PESO_ATRASO_FASE2_MAX = 0.30; // teto do crescimento logarítmico (90 dias)
export const PESO_ATRASO_TETO = 0.40; // teto absoluto (91+ dias)

O peso é aditivo ao score_final na D-4. A D-4 aplica: score_final_com_atraso = score_final + (peso_atraso * peso_situacional_da_uc). O peso_situacional atua como modulador: uma UC com gap crítico naquela categoria amplifica o efeito do atraso; uma UC com gap próximo de zero praticamente anula o bônus de atraso.

4.3 AgendadorCiclos.executar() — CronJob de varredura

Seção intitulada “4.3 AgendadorCiclos.executar() — CronJob de varredura”
@Cron(CronExpression.EVERY_HOUR) // configurável via d8.constants.ts
async função executar():
obs.debug("Iniciando varredura de ciclos de manutenção.")
// 1. Buscar projetos com ciclo vencido
agora = new Date()
projetosVencidos = projetoRepo.findAtivosComCicloVencido(agora)
se projetosVencidos.length === 0:
obs.debug("Nenhum ciclo de manutenção vencido.")
return
obs.info("Ciclos de manutenção vencidos encontrados.", {
total: projetosVencidos.length,
})
// 2. Processar cada projeto
gerados = 0
falhas = 0
para cada projeto em projetosVencidos:
tentar:
// 2a. Montar dossiê
dataPrevista = projeto.proximo_ciclo
cicloNumero = projeto.ciclo_atual + 1
dossie = DossieBuilder.montarDossie(projeto, cicloNumero, dataPrevista, agora)
// 2b. Gerar ID da nova demanda
novaDemandaId = uuidv4()
// 2c. Montar texto legível
textoBruto = gerarTextoDemanda(projeto, cicloNumero, dossie)
// Ex: "Manutenção programada — Ciclo #3: Inspeção de playground na Praça X.
// Projeto original: Reforma da Praça X (concluído em 15/03/2025).
// Responsável técnico: Secretaria de Obras.
// Última manutenção: 10/09/2025 (concluída).
// Intervenção esperada: inspeção estrutural dos brinquedos."
// 2d. Publicar demanda.recebida no barramento
correlacaoId = uuidv4()
eventIdGeracao = uuidv4()
await eventBus.publicar({
tipo: 'demanda.recebida',
origem: 'D-8',
event_id: eventIdGeracao,
correlacao_id: correlacaoId,
payload: {
demanda_id: novaDemandaId,
origem: 'manutenção_programada',
projeto_original_id: projeto.id,
texto_bruto: textoBruto,
tipo_midia: 'texto',
localizacao_bruta: projeto.localizacao,
canal: 'sistema',
categoria_id: projeto.categoria,
subcategoria_id: projeto.subcategoria,
timestamp_criacao: agora.toISOString(),
dossie: dossie,
metadata: {
profundidade: projeto.profundidade,
intervalo_dias: projeto.plano_manutencao.intervalo_dias,
ciclo_numero: cicloNumero,
},
},
})
// 2e. Publicar demanda.manutenção_gerada (notificação)
diasAtraso = dossie.metricas_atraso?.dias_atraso ?? 0
await eventBus.publicar({
tipo: 'demanda.manutenção_gerada',
origem: 'D-8',
event_id: uuidv4(),
correlacao_id: correlacaoId,
payload: {
demanda_id: novaDemandaId,
projeto_original_id: projeto.id,
categoria: projeto.categoria,
localizacao: projeto.localizacao,
tipo_manutencao: projeto.plano_manutencao.tipo,
intervalo_programado: projeto.plano_manutencao.intervalo_dias,
prazo_tolerancia: projeto.plano_manutencao.prazo_tolerancia_dias,
ciclo_numero: cicloNumero,
data_prevista: dataPrevista.toISOString(),
data_geracao: agora.toISOString(),
dias_atraso: diasAtraso,
dossie: dossie,
},
})
// 2f. Persistir no histórico
pesoAtraso = dossie.metricas_atraso?.peso_adicional_sugerido ?? 0
EM TRANSAÇÃO:
historicoRepo.criar({
projeto_id: projeto.id,
demanda_manutencao_id: novaDemandaId,
ciclo_numero: cicloNumero,
data_geracao: agora,
data_prevista: dataPrevista,
data_tolerancia: new Date(
dataPrevista.getTime() +
projeto.plano_manutencao.prazo_tolerancia_dias * 86400000
),
status: 'gerada',
dossie_montado: dossie,
peso_atraso: pesoAtraso,
event_id_geracao: eventIdGeracao,
event_id_ultima_alteracao: eventIdGeracao,
})
// 2g. Recalcular próximo ciclo
proximoCiclo = new Date(
agora.getTime() +
projeto.plano_manutencao.intervalo_dias * 86400000
)
projetoRepo.atualizar(projeto.id, {
proximo_ciclo: proximoCiclo,
ciclo_atual: cicloNumero,
event_id_ultima_alteracao: eventIdGeracao,
})
gerados++
capturar erro:
obs.error("Falha ao gerar demanda de manutenção.", erro, {
projeto_id: projeto.id,
demanda_original_id: projeto.demanda_original_id,
})
falhas++
obs.info("Varredura de ciclos concluída.", {
total_projetos_verificados: projetosVencidos.length,
demandas_geradas: gerados,
falhas: falhas,
})

4.4 gerarTextoDemanda(projeto, cicloNumero, dossie) — texto automático

Seção intitulada “4.4 gerarTextoDemanda(projeto, cicloNumero, dossie) — texto automático”
função gerarTextoDemanda(
projeto: ProjetoMonitorado,
cicloNumero: number,
dossie: DossieManutencao
) -> string:
linhas = []
linhas.push(`Manutenção programada — Ciclo #${cicloNumero}`)
linhas.push(`Projeto: ${dossie.projeto_original.titulo}`)
linhas.push(`Concluído em: ${dossie.projeto_original.data_conclusao}`)
se dossie.projeto_original.responsavel_tecnico:
linhas.push(`Responsável técnico: ${dossie.projeto_original.responsavel_tecnico}`)
se dossie.projeto_original.materiais_utilizados.length > 0:
linhas.push(`Materiais: ${dossie.projeto_original.materiais_utilizados.join(', ')}`)
se dossie.historico_manutencoes.length > 0:
ultimaManutencao = dossie.historico_manutencoes[dossie.historico_manutencoes.length - 1]
linhas.push(`Última manutenção: ${ultimaManutencao.data_real} (${ultimaManutencao.status})`)
linhas.push(`Intervenção esperada: ${dossie.ciclo_atual.tipo_intervencao}`)
se dossie.metricas_atraso !== null:
linhas.push(`ATENÇÃO: Manutenção atrasada em ${dossie.metricas_atraso.dias_atraso} dias.`)
se !dossie.metricas_atraso.dentro_tolerancia:
linhas.push(`Prazo de tolerância (${dossie.ciclo_atual.prazo_tolerancia_dias} dias) excedido.`)
retornar linhas.join('\n')

4.5 D8Service.iniciar() — protocolo de inicialização

Seção intitulada “4.5 D8Service.iniciar() — protocolo de inicialização”
async função iniciar():
// 1. Carregar configuração estática
d8Constants.carregar()
// 2. Garantir os cursores no banco próprio
// O seed usa a maior sequência do barramento e não sobrescreve cursor existente
tipos = [
'demanda.concluída',
'manutenção.executada',
'demanda.recebida',
]
maiorSequence = await eventBus.obterMaiorSequence()
consumerOffsetRepo.seed(tipos, maiorSequence)
offsets = consumerOffsetRepo.findAll()
offsetMap = new Map(offsets.map(o => [o.tipo_evento, o.last_sequence]))
// 3. Replay de eventos perdidos (ordem natural)
para cada tipo em tipos:
lastSeq = offsetMap.get(tipo) ?? 0
eventos = await eventBus.replayDeSequence(lastSeq, [tipo])
para cada evento em eventos:
tentar:
await this.despacharEvento(evento.tipo, evento)
lastSeq = evento.sequence_number
capturar erro:
obs.error("Erro no replay de evento.", erro, {
tipo_evento: evento.tipo,
event_id: evento.event_id,
sequence: evento.sequence_number,
})
// Não interrompe — continua replay dos eventos seguintes
// Atualizar offset para este tipo
EM TRANSAÇÃO:
consumerOffsetRepo.upsert(tipo, lastSeq)
// 4. Registrar handlers para consumo contínuo
eventBus.inscrever('demanda.concluída', 'D-8', this.onDemandaConcluida.bind(this))
eventBus.inscrever('manutenção.executada', 'D-8', this.onManutencaoExecutada.bind(this))
eventBus.inscrever('demanda.recebida', 'D-8', this.onDemandaRecebida.bind(this))
// 5. Iniciar CronJob
this.agendadorCiclos.iniciar()
obs.info("D-8 inicializada.", {
offsets_restaurados: Object.fromEntries(offsetMap),
projetos_ativos: await projetoRepo.countAtivos(),
})
demanda.concluída
(com plano_manutencao)
┌───────┐
│ ativo │──────────────────────────────┐
└───┬───┘ │
│ │
│ CronJob gera demanda │
│ de manutenção │
▼ │
┌───────┐ manutenção.executada │
│ ativo │◄─────────────────────────┐ │
│(ciclo │ │ │
│ N+1) │─── vida útil esgotada ───┼───┤
└───────┘ │ │
│ │
┌─────────────────────────────────┘ │
▼ ▼
┌──────────┐ ┌──────────┐
│ encerrado│ │ encerrado│
│ (vida │ │ (manual) │
│ útil) │ └──────────┘
└──────────┘

Transições:

  • ativoativo: cada ciclo de manutenção executado. O status não muda.
  • ativoencerrado (vida útil esgotada): quando NOW() > data_criacao + vida_util_dias ao receber manutenção.executada.
  • ativoencerrado (manual): via método encerrarMonitoramento(), exposto como endpoint administrativo na Fase 3.
  • ativopausado: não implementado na Fase 2. Reservado para Fase 3 (ex: suspensão de manutenção durante calamidade pública).

4.7 Máquina de estados do ciclo de manutenção (histórico)

Seção intitulada “4.7 Máquina de estados do ciclo de manutenção (histórico)”
CronJob gera demanda
┌────────┐
│ gerada │──────── NOW() > data_tolerancia ────────┐
└───┬────┘ │
│ │
│ demanda.recebida (tracking) │
▼ ▼
┌──────────────┐ ┌────────┐
│ em_andamento │ │ vencida│
└──────┬───────┘ └────────┘
│ manutenção.executada
┌───────────┐
│ concluida │
└───────────┘

O status vencida não é materializado proativamente. É derivado na leitura: status = 'gerada' AND NOW() > data_tolerancia é interpretado como vencida. A D-7 aplica essa interpretação ao montar a timeline. A materialização pode ser adicionada na Fase 3 via CronJob dedicado se o volume justificar.

Projeto concluído com plano_manutencao mas sem responsavel_tecnico. O dossiê é gerado com responsavel_tecnico: null. O conselheiro vê o campo vazio e pode buscar a informação. A D-8 não bloqueia a geração.

CronJob executado duas vezes para o mesmo projeto (concorrência). O SELECT ... FOR UPDATE no PostgreSQL serializa o acesso durante a transação de atualização do proximo_ciclo. A segunda execução encontra proximo_ciclo > NOW() e ignora. Se dois CronJobs rodarem em instâncias diferentes do monolito (modo cluster), SELECT ... FOR UPDATE garante exclusão mútua.

Demanda de manutenção cancelada antes da conclusão. A D-8 não tem handler para cancelamento na Fase 2. Se a demanda for cancelada (ex: via D-6b por desistência do conselheiro), o registro no histórico permanece em gerada ou em_andamento e eventualmente será interpretado como vencida. O próximo ciclo será gerado normalmente no intervalo programado. A limpeza de ciclos cancelados entra na Fase 3.

Projeto com vida_util_dias muito curto (ex: 30 dias) e intervalo_dias muito longo (ex: 90 dias). O primeiro proximo_ciclo = data_conclusao + 90 já excede data_criacao + 30. O CronJob gera a demanda e, ao receber manutenção.executada, detecta vida útil esgotada e encerra. A demanda de manutenção pode nunca ser executada se a vida útil expirar antes da primeira intervenção. A D-8 não valida consistência entre intervalo_dias e vida_util_dias — essa responsabilidade é do responsável técnico que define o plano.


5. Integração com o Barramento e Outras Colônias

Seção intitulada “5. Integração com o Barramento e Outras Colônias”

5.1 Consumo e publicação de eventos via EventBusService

Seção intitulada “5.1 Consumo e publicação de eventos via EventBusService”

A D-8 utiliza EventBusService (N-0a) para todo consumo e publicação. Os eventos são validados contra os schemas do Registry (N-0b) no momento da publicação. No consumo, chegam pré-validados.

Consumo:

// Registro no iniciar()
eventBus.inscrever('demanda.concluída', 'D-8', this.onDemandaConcluida.bind(this));
eventBus.inscrever('manutenção.executada', 'D-8', this.onManutencaoExecutada.bind(this));
eventBus.inscrever('demanda.recebida', 'D-8', this.onDemandaRecebida.bind(this));

Publicação (padrão, exemplo):

await this.eventBus.publicar({
tipo: 'demanda.manutenção_gerada',
origem: 'D-8',
event_id: uuidv4(),
correlacao_id: correlacaoId,
payload: { /* ... */ },
});

5.2 Fluxo de eventos — cadeia completa de manutenção programada

Seção intitulada “5.2 Fluxo de eventos — cadeia completa de manutenção programada”
FASE 1: Registro do projeto (disparado por demanda.concluída)
D-6b ──demanda.concluída(plano_manutencao)──▶ D-8
├─ registra em projetos_monitorados
└─ agenda primeiro ciclo
(proximo_ciclo = data_conclusao + intervalo)
FASE 2: Geração da manutenção (disparado pelo CronJob)
D-8 (CronJob)
├──▶ demanda.recebida ──▶ D-1b ──▶ D-3 ──▶ D-4 ──▶ D-5 ──▶ D-6a ──▶ D-6b
│ (origem=manutenção_programada)
└──▶ demanda.manutenção_gerada ──▶ D-7 (timeline)
FASE 3: Execução da manutenção (disparado por manutenção.executada)
D-6b ──manutenção.executada──▶ D-8
├─ atualiza histórico (concluida)
├─ recalcula proximo_ciclo
└─ se vida útil esgotada:
└──▶ manutenção.ciclo_encerrado ──▶ D-7 (timeline)

Publicação de demanda.recebida para entrada no pipeline:

private async publicarDemandaRecebida(
projeto: ProjetoMonitorado,
dossie: DossieManutencao,
cicloNumero: number,
): Promise<string> {
const novaDemandaId = uuidv4();
const correlacaoId = uuidv4();
const eventIdGeracao = uuidv4();
const textoBruto = this.gerarTextoDemanda(projeto, cicloNumero, dossie);
try {
await this.eventBus.publicar({
tipo: 'demanda.recebida',
origem: 'D-8',
event_id: eventIdGeracao,
correlacao_id: correlacaoId,
payload: {
demanda_id: novaDemandaId,
origem: 'manutenção_programada',
projeto_original_id: projeto.id,
texto_bruto: textoBruto,
tipo_midia: 'texto',
localizacao_bruta: projeto.localizacao,
canal: 'sistema',
categoria_id: projeto.categoria,
subcategoria_id: projeto.subcategoria,
timestamp_criacao: new Date().toISOString(),
dossie: dossie,
metadata: {
profundidade: projeto.profundidade,
intervalo_dias: projeto.plano_manutencao.intervalo_dias,
ciclo_numero: cicloNumero,
},
},
});
return novaDemandaId;
} catch (erro) {
this.obs.error('Falha ao publicar demanda.recebida para manutenção.', erro, {
projeto_id: projeto.id,
demanda_original_id: projeto.demanda_original_id,
});
throw erro;
}
}

A D-8 não faz chamadas síncronas a outras colônias. Não expõe endpoints REST. Não atua como proxy. Toda comunicação é via barramento.

A D-8 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:

  • core.event_log via EventBusService.replayDeSequence() — dependência do núcleo, permitida.
  • Arquivo de configuração estática d8.constants.ts — parâmetros de profundidade máxima, intervalo de varredura e limiares de peso por atraso. Na Fase 3, pode migrar para consumo de parâmetros.atualizados.

A D-8 não utiliza Redis. Opera exclusivamente sobre PostgreSQL para seu estado próprio e sobre eventos do barramento para entrada de dados. O CronJob de varredura consulta projetos_monitorados com índice parcial e SELECT ... FOR UPDATE para serialização.

5.7 Colônias afetadas pela introdução da D-8 na Fase 2

Seção intitulada “5.7 Colônias afetadas pela introdução da D-8 na Fase 2”

A D-8 é uma colônia nova na Fase 2. Sua introdução requer alterações nas seguintes colônias existentes:

Colônia Mudança necessária
D-6b (Relatoria) demanda.concluída ganha campo opcional plano_manutencao e responsavel_tecnico. Ao concluir demanda com origem = 'manutenção_programada', publica também manutenção.executada. Formulário de conclusão da D-6b inclui campos opcionais de plano de manutenção.
D-1b (Normalização) Handler para demanda.recebida com origem = 'manutenção_programada' aplica normalização mais leve: texto já estruturado, sem mídia bruta, sem necessidade de transcrição ou descrição de imagem.
D-3 (Categorização) Handler para demandas com origem = 'manutenção_programada' faz bypass parcial: valida que a categoria herdada é válida, mas não reclassifica. Se a categoria não for reconhecida, aplica categorização normal como fallback.
D-4 (Priorização) Handler para demandas com dossie.metricas_atraso.peso_adicional_sugerido > 0: incorpora o peso como bônus no score_final. Fórmula: score_final = score_base + (peso_atraso * peso_situacional_uc).
D-7 (Transparência) Passa a consumir demanda.manutenção_gerada e manutenção.ciclo_encerrado. Template de descrição para timeline: “Manutenção programada gerada para [projeto] — Ciclo #N” e “Monitoramento de manutenção encerrado para [projeto] — [motivo]”.

Nenhuma dessas alterações quebra a Fase 1. Os novos campos são opcionais, e os handlers novos processam apenas eventos com os campos preenchidos. A Fase 1 continua operando normalmente sem a D-8.

5.8 Registro de novos tipos de evento no Registry (N-0b)

Seção intitulada “5.8 Registro de novos tipos de evento no Registry (N-0b)”

A D-8 introduz dois novos tipos de evento no barramento:

  • demanda.manutenção_gerada (v1.0.0)
  • manutenção.ciclo_encerrado (v1.0.0)

E estende dois tipos existentes:

  • demanda.recebida ganha origem, projeto_original_id e dossie na versão 2.0.0, que também torna cidadao_id opcional e admite canal = 'sistema'
  • demanda.concluída ganha plano_manutencao na versão 1.1.0

E introduz um novo tipo consumido:

  • manutenção.executada (v1.0.0)

No catálogo corrente, demanda.recebida está na 1.1.0 e demanda.concluída na 1.0.0. Os três tipos novos e as duas versões novas entram no catálogo canônico do N-0b - Registry.md na mesma leva que implementa a colônia.


A D-8 não aplica rate limiting. O volume de eventos é limitado indiretamente pelo pipeline de conclusão de demandas da D-6b. A geração de demandas pelo CronJob é autorregulada: cada projeto gera no máximo 1 demanda por ciclo, e o ciclo seguinte só é agendado após manutenção.executada.

Recurso Limite Justificativa
profundidade máxima 2 Constante PROFUNDIDADE_MAXIMA. Cadeias de manutenção além de 2 níveis são rejeitadas no handler de demanda.concluída.
Tamanho do dossie JSONB ~10 KB típico, ~50 KB máximo O dossiê inclui projeto original + até 100 entradas de histórico + localização. Com 100 manutenções no histórico, o JSONB não deve exceder 50 KB.
texto_bruto da demanda gerada Máx. 5000 caracteres Texto automático legível. Suficiente para descrever o projeto, histórico e intervenção esperada.
Projetos por varredura Sem limite explícito O processamento é serial dentro da transação. Para > 10.000 projetos vencidos simultaneamente, o CronJob pode levar vários minutos. Nesse caso, a Fase 3 introduz processamento em lotes com LIMIT e OFFSET.
Tamanho de lote no replay 500 eventos por lote Consistente com as demais colônias.

Query mais frequente — CronJob de varredura:

SELECT * FROM d8.projetos_monitorados
WHERE status = 'ativo' AND proximo_ciclo <= NOW()
ORDER BY proximo_ciclo ASC;

Atendida por: idx_d8_projetos_status_proximo_ciclo (índice parcial em (status, proximo_ciclo) WHERE status = 'ativo').

Query de tracking — handler onDemandaRecebida:

SELECT * FROM d8.historico_manutencoes
WHERE demanda_manutencao_id = $1;

Atendida por: idx_d8_historico_demanda.

Query do DossieBuilder — buscar histórico do projeto:

SELECT * FROM d8.historico_manutencoes
WHERE projeto_id = $1
ORDER BY ciclo_numero ASC;

Atendida por: idx_d8_historico_projeto.

Query de idempotência:

SELECT 1 FROM d8.processed_events WHERE event_id = $1;

Atendida por: UNIQUE constraint uq_d8_processed_events_event.

  • CronJob: 1 query a cada 1 hora (configurável). Varre projetos_monitorados com índice parcial. Leitura de baixo custo.
  • Handlers de evento: 1–3 queries por evento recebido. Volume limitado pela taxa de conclusão de demandas (D-6b).
  • Replay na inicialização: 1 query de replay por tipo de evento, com processamento em lotes de 500.

A D-8 não implementa cache. O estado é consultado diretamente no PostgreSQL. Para o volume da Fase 2, índices adequados são suficientes. Se na Fase 3 o número de projetos ativos ultrapassar 100.000, a varredura pode ser otimizada com cache em memória dos projetos com proximo_ciclo mais próximo, mas isso é prematuro.

Cenário Projetos ativos Ciclos/ano Demandas geradas/ano Carga no CronJob
1 município pequeno (Fase 2 inicial) 100 1–2 100–200 < 10 ms
10 municípios médios 5.000 1–4 5.000–20.000 ~50 ms
100 municípios 50.000 1–4 50.000–200.000 ~500 ms
Nacional (Fase 3) 500.000+ 1–4 500.000+ Requer batching

Para a Fase 2, o volume projetado é de até 50.000 projetos ativos. Com índice parcial, a varredura processa apenas projetos com proximo_ciclo <= NOW(). Em um dia típico, menos de 1% dos projetos estarão vencidos (~500). O tempo de execução do CronJob é dominado pela publicação de eventos no barramento, não pela query.


A D-8 pode ser testada sem nenhuma outra colônia ativa:

  1. Mock do EventBusService — emite eventos sintéticos e captura publicações para assertions.
  2. Banco de dados isolado — schema d8 em banco de teste dedicado, migrations aplicadas via prisma migrate deploy.
  3. CronJob controlável — o AgendadorCiclos aceita injeção de um trigger() manual que dispara a varredura sem esperar o intervalo real.
  4. Configuração injetáveld8.constants.ts aceita overrides via módulo de configuração NestJS para testes (ex: PROFUNDIDADE_MAXIMA=1, INTERVALO_VARREDURA_MINUTOS=0).

Setup de teste:

beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [ScheduleModule.forRoot()],
providers: [
D8Service,
DossieBuilder,
CalculadoraPesoAtraso,
AgendadorCiclos,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: ProjetoMonitoradoRepository, useValue: mockProjetoRepo },
{ provide: HistoricoManutencaoRepository, useValue: mockHistoricoRepo },
{ provide: ProcessedEventRepository, useValue: mockProcessedEventRepo },
{ provide: ConsumerOffsetRepository, useValue: mockConsumerOffsetRepo },
],
}).compile();
service = module.get(D8Service);
await service.iniciar();
});

Cenário 1: Projeto concluído sem plano de manutenção é ignorado

  • Publicar demanda.concluída sem plano_manutencao
  • Assert: nenhum registro criado em projetos_monitorados

Cenário 2: Projeto concluído com plano de manutenção é registrado

  • Publicar demanda.concluída com plano_manutencao preenchido
  • Assert: 1 registro em projetos_monitorados com status = 'ativo' e proximo_ciclo calculado

Cenário 3: Profundidade máxima bloqueia aninhamento excessivo

  • Publicar demanda.concluída com plano_manutencao.profundidade = 3
  • Assert: nenhum registro criado em projetos_monitorados
  • Assert: log de warning contendo “profundidade excede máximo”

Cenário 4: Idempotência — mesma demanda.concluída duas vezes

  • Publicar demanda.concluída com plano (event_id = A)
  • Publicar novamente demanda.concluída com mesmo event_id
  • Assert: apenas 1 registro em projetos_monitorados

Cenário 5: CronJob gera demanda no proximo_ciclo

  • Criar projeto com proximo_ciclo = NOW() - 1 hora e status = 'ativo'
  • Disparar agendadorCiclos.executar()
  • Assert: demanda.recebida publicado no barramento com origem = 'manutenção_programada'
  • Assert: 1 registro em historico_manutencoes com status = 'gerada'
  • Assert: proximo_ciclo do projeto atualizado para NOW() + intervalo_dias

Cenário 6: CronJob não gera demanda para projeto com ciclo futuro

  • Criar projeto com proximo_ciclo = NOW() + 7 dias e status = 'ativo'
  • Disparar agendadorCiclos.executar()
  • Assert: nenhum evento publicado no barramento

Cenário 7: manutenção.executada atualiza histórico e recalcula ciclo

  • Criar projeto ativo com 1 ciclo no histórico (status = 'gerada')
  • Publicar manutenção.executada referenciando o demanda_manutencao_id
  • Assert: histórico atualizado para status = 'concluida'
  • Assert: proximo_ciclo do projeto recalculado

Cenário 8: Vida útil esgotada encerra monitoramento

  • Criar projeto com vida_util_dias = 30, criado há 35 dias
  • Publicar manutenção.executada
  • Assert: projeto atualizado para status = 'encerrado', motivo_encerramento = 'vida_util_esgotada'
  • Assert: manutenção.ciclo_encerrado publicado

Cenário 9: Atraso gera peso adicional no dossiê

  • Criar projeto com proximo_ciclo = NOW() - 15 dias (15 dias de atraso)
  • Disparar agendadorCiclos.executar()
  • Assert: dossiê contém metricas_atraso.dias_atraso = 15
  • Assert: metricas_atraso.peso_adicional_sugerido > 0
  • Assert: metricas_atraso.dentro_tolerancia depende do prazo_tolerancia_dias

Cenário 10: Replay após reinicialização processa eventos perdidos

  • Simular queda após 5 eventos processados mas antes do offset ser atualizado
  • Reinicializar módulo
  • Assert: iniciar() faz replay a partir do último offset e processa eventos perdidos
  • Assert: idempotência impede duplicação dos 5 já processados

Cenário 11: CronJob não gera demanda para projeto encerrado

  • Criar projeto com status = 'encerrado' e proximo_ciclo = NOW() - 1 hora
  • Disparar agendadorCiclos.executar()
  • Assert: nenhum evento publicado

Cenário 12: Tracking — demanda.recebida atualiza status do histórico

  • Criar entrada no histórico com status = 'gerada' e demanda_manutencao_id = X
  • Publicar demanda.recebida com origem = 'manutenção_programada' e demanda_id = X
  • Assert: histórico atualizado para status = 'em_andamento'
// Seed para projetos_monitorados
export const projetosMonitoradosSeed = [
{
id: 'a0000000-0000-0000-0000-000000000001',
demanda_original_id: 'd0000000-0000-0000-0000-000000000101',
categoria: '3.9_pracas_parques',
subcategoria: 'playground_quebrado',
localizacao: { lat: -23.5505, lng: -46.6333, endereco: 'Praça da Sé, São Paulo' },
uc_id: 'uc000000-0000-0000-0000-000000000001',
plano_manutencao: {
intervalo_dias: 180,
tipo: 'inspecao',
prazo_tolerancia_dias: 30,
vida_util_dias: 1825, // 5 anos
},
profundidade: 0,
dossie_original: {
titulo: 'Reforma da Praça da Sé — Playground',
responsavel_tecnico: 'Secretaria Municipal de Obras',
data_conclusao: '2025-01-15T10:00:00Z',
materiais_utilizados: ['madeira tratada', 'parafusos galvanizados', 'areia', 'tinta atóxica'],
},
proximo_ciclo: '2025-07-14T10:00:00Z', // 180 dias após conclusão
ciclo_atual: 0,
status: 'ativo',
},
{
id: 'a0000000-0000-0000-0000-000000000002',
demanda_original_id: 'd0000000-0000-0000-0000-000000000102',
categoria: '3.4_iluminacao_publica',
subcategoria: 'poste_apagado',
localizacao: { lat: -23.5610, lng: -46.6560, endereco: 'Av. Paulista, São Paulo' },
plano_manutencao: {
intervalo_dias: 365,
tipo: 'substituicao',
prazo_tolerancia_dias: 15,
vida_util_dias: 3650, // 10 anos
},
profundidade: 0,
dossie_original: {
titulo: 'Troca de luminárias LED — Av. Paulista',
responsavel_tecnico: 'Ilumina SP',
data_conclusao: '2025-03-01T10:00:00Z',
materiais_utilizados: ['luminárias LED 150W', 'cabos', 'conectores'],
},
proximo_ciclo: '2025-08-01T10:00:00Z', // já vencido para teste do CronJob
ciclo_atual: 1,
status: 'ativo',
},
];
// Seed para historico_manutencoes (ciclo #1 do segundo projeto)
export const historicoSeed = [
{
id: 'b0000000-0000-0000-0000-000000000001',
projeto_id: 'a0000000-0000-0000-0000-000000000002',
demanda_manutencao_id: 'e0000000-0000-0000-0000-000000000201',
ciclo_numero: 1,
data_geracao: '2026-03-01T10:00:00Z',
data_prevista: '2026-03-01T10:00:00Z',
data_tolerancia: '2026-03-16T10:00:00Z',
status: 'concluida',
conselheiro_id: 'c0000000-0000-0000-0000-000000000001',
dossie_montado: {},
peso_atraso: 0,
},
];

Funcionalidade Status
Consumo de demanda.concluída com plano_manutencao e registro de projeto monitorado Obrigatório
Filtro de profundidade (profundidade > PROFUNDIDADE_MAXIMA → ignora) Obrigatório
CronJob de varredura com intervalo configurável Obrigatório
Geração de demanda.recebida com origem = 'manutenção_programada' e dossiê completo Obrigatório
Publicação de demanda.manutenção_gerada para D-7 Obrigatório
Consumo de manutenção.executada com atualização de histórico e recálculo de ciclo Obrigatório
Detecção de fim de vida útil com publicação de manutenção.ciclo_encerrado Obrigatório
Tracking de pipeline via auto-consumo de demanda.recebida Obrigatório
Cálculo de peso por atraso no dossiê Obrigatório
DossieBuilder com projeto original, histórico e métricas de atraso Obrigatório
Idempotência via processed_events Obrigatório
Replay de eventos na inicialização com consumer offsets Obrigatório
Logs estruturados com projeto_id, demanda_original_id e event_id Obrigatório
Contadores de projetos ativos, demandas geradas, ciclos encerrados e atraso médio nos logs estruturados Obrigatório
Simplificação Justificativa Quando remover
CronJob de polling em vez de agendamento dinâmico por projeto Elimina complexidade de persistir timers entre reinicializações. Para o volume da Fase 2, polling horário é suficiente. Migrar para scheduler persistente (ex: Bull/BullMQ com Redis) quando o número de projetos ativos exceder 50.000 e a latência entre proximo_ciclo e a geração da demanda precisar ser < 1 hora.
profundidade_maxima como constante estática O limite de 2 níveis é suficiente para a Fase 2. A inteligência de merge para cadeias de manutenção sobrepostas é complexa e entra na Fase 3. Substituir por detecção de sobreposição e merge de manutenções na Fase 3 (Colônia de deduplicação estendida).
Sem endpoint REST para encerramento manual de monitoramento Encerramento manual é raro na Fase 2. Pode ser feito via console ou migration direta. Criar endpoint POST /d8/projetos/:id/encerrar na Fase 3, integrado ao front-end administrativo.
Sem notificação de manutenção vencida para conselheiros A D-8 publica eventos no barramento e a D-7 exibe na timeline. O conselheiro descobre consultando a interface. Adicionar colônia de notificação que consome demanda.manutenção_gerada com dias_atraso > 0 e notifica o conselheiro da UC (Fase 3).
Sem status = 'pausado' para suspensão temporária Casos de uso de suspensão (calamidade, interdição) não são cobertos na Fase 2. Implementar pausado com motivo e data de retorno na Fase 3.
Peso por atraso é sugestivo, não vinculante A D-8 sugere o peso no dossiê. A D-4 decide se e como aplicar. Na Fase 2, a D-4 aplica o peso como bônus aditivo. Se houver necessidade de override manual do peso (ex: manutenção atrasada mas segura), criar endpoint de ajuste na Fase 3.
Sem validação de consistência entre intervalo_dias e vida_util_dias A responsabilidade é do técnico que define o plano. A D-8 não rejeita planos inconsistentes. Adicionar warnings no momento do registro se intervalo_dias > vida_util_dias ou se prazo_tolerancia_dias > intervalo_dias (Fase 3).
  • Inteligência de merge de manutenções sobrepostas — detecção de que duas demandas de manutenção cobrem o mesmo ativo e podem ser unificadas
  • status = 'pausado' com motivo e data de retorno para suspensão temporária de monitoramento
  • Endpoint REST POST /d8/projetos/:id/encerrar para encerramento manual de monitoramento
  • Notificações push/email para conselheiros sobre manutenções vencidas
  • Validação de consistência do plano de manutenção com warnings na interface
  • Batching no CronJob para volumes > 10.000 projetos vencidos simultaneamente
  • Materialização de status = 'vencida' no histórico via CronJob dedicado
  • Consumo de parâmetros.atualizados para hot-reload de PROFUNDIDADE_MAXIMA e limiares de atraso sem redeploy
  • Migração para scheduler persistente (Bull/BullMQ) para agendamento de ciclos com precisão sub-hora
  • Métricas Prometheus: d8_projetos_ativos, d8_demandas_geradas_total, d8_ciclos_encerrados_total, d8_atraso_medio_dias, d8_peso_atraso_medio
  • Tabela d8.config com histórico de versões de parâmetros

8.4 Verificação de conflitos com outras colônias

Seção intitulada “8.4 Verificação de conflitos com outras colônias”

Conflito potencial: a D-8 publica demanda.recebida, mesmo tipo que a D-1a publica. Como a D-1b distingue a origem? Avaliação: a D-1b consome demanda.recebida e inspeciona o campo origem. Na Fase 1, esse campo não existe (ou é 'cidadao' implícito). Na Fase 2, origem = 'manutenção_programada' aciona normalização mais leve. A D-1b não precisa saber quem publicou — apenas reage ao conteúdo do payload. Sem conflito.

Conflito potencial: a D-8 consome demanda.recebida (auto-consumo) e a D-1b também consome o mesmo evento. A ordem de processamento importa? Avaliação: a D-1b processa demanda.recebida e publica demanda.normalizada. A D-8 processa o mesmo evento para tracking. São independentes — a D-8 não depende do output da D-1b para seu tracking. Se a D-8 processar antes da D-1b, atualiza o histórico para em_andamento antes da normalização — o que é semanticamente correto (a demanda entrou no pipeline). Sem conflito.

Conflito potencial: a ficha da D-8 no Apêndice B lista demanda.manutenção_gerada e manutenção.ciclo_encerrado, mas a D-8 também publica demanda.recebida. Avaliação: a ficha descreve os eventos de negócio próprios da colônia. demanda.recebida é um evento do domínio de Captura (D-1a) que a D-8 publica como mecanismo de entrada no pipeline. Não há conflito de contrato: o schema é o mesmo e os consumidores reagem ao tipo de evento, não à origem.

Conflito potencial: a D-8 depende de plano_manutencao no payload de demanda.concluída, mas a D-6b (Fase 1) não inclui esse campo. Avaliação: a D-6b na Fase 1 publica demanda.concluída v1.0.0, sem plano_manutencao. A D-8 entra na Fase 2, quando a D-6b é atualizada para publicar demanda.concluída v1.1.0 com o campo opcional. A D-8 trata plano_manutencao = undefined como “ignorar” — compatível com eventos da Fase 1 que ainda estejam no barramento. Sem conflito.

Conflito potencial: a ordem de deploy. Se D-8 subir antes de D-6b ter o formulário de plano de manutenção? Avaliação: a D-8 opera em modo passivo. Sem eventos demanda.concluída com plano_manutencao, não há projetos monitorados. Os handlers ficam registrados e processarão eventos assim que a D-6b começar a publicar. O iniciar() faz replay a partir do último offset conhecido. A ordem de deploy não importa.

Conflito potencial: duas demandas de manutenção para o mesmo projeto são geradas em sequência rápida (ex: primeira gerada com atraso de 30 dias, executada em 1 dia, próximo ciclo programado para 180 dias depois). O CronJob pode gerar uma segunda demanda antes da primeira ser concluída? Avaliação: o CronJob varre proximo_ciclo <= NOW(). Após a geração, proximo_ciclo é atualizado para NOW() + intervalo_dias (ex: +180 dias). A segunda execução do CronJob não encontra o projeto porque proximo_ciclo está no futuro. A única condição de corrida possível é duas instâncias do CronJob executando simultaneamente — resolvida por SELECT ... FOR UPDATE no PostgreSQL.



Documento de especificação técnica de implementação.