D-8 — Manutenção Programada
Parte do Ciclo de Demandas — Fase 2
Propósito
Seção intitulada “Propósito”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.
Referência
Seção intitulada “Referência”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-8 — Manutenção Programada”
- Conceito de manutenção programada: rede_civica.md, Parte II — Gestão, seção “Manutenção programada”
- Ciclo de vida de demandas e conclusão: contexto_IA.md, seção 7 (A Gestão — Ciclos)
- Papel do conselheiro como operador de rastreabilidade: contexto_IA.md, seção 5 (Conselhos de unidade cívica)
- A D-8 é a primeira colônia do Bloco Fase 2 (após D-7, antes de D-9).
- Colônias a montante: D-6b - Relatoria e Acompanhamento.md (publica
demanda.concluídaemanutenção.executada) - Colônias a jusante: D-1b - Normalização.md, D-3 - Categorização.md, D-4 - Priorização e Ranking.md, D-5 - Agenda.md, D-7 - Transparência.md
- Parâmetros de peso por atraso e profundidade máxima: este documento, seção 4.2
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”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.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”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, TipoManutencao1.2 Module definition
Seção intitulada “1.2 Module definition”@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(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- 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
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé 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 dopublicar(). - O módulo importa
ScheduleModuledo@nestjs/schedulepara o CronJob de varredura de ciclos (@CronnoAgendadorCiclos). - O
OnModuleInitdispara 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 demanutencao/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.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”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).
1.5 Colônia sem REST
Seção intitulada “1.5 Colônia sem REST”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.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d8
Seção intitulada “2.1 Schema d8”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.
2.2 Tabela d8.projetos_monitorados
Seção intitulada “2.2 Tabela d8.projetos_monitorados”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);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.3 Tabela d8.historico_manutencoes
Seção intitulada “2.3 Tabela d8.historico_manutencoes”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);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.4 Tabela d8.processed_events
Seção intitulada “2.4 Tabela d8.processed_events”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));2.5 Tabela d8.consumer_offset
Seção intitulada “2.5 Tabela d8.consumer_offset”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());2.6 Migrations esperadas
Seção intitulada “2.6 Migrations esperadas”- V001 — Create schema —
CREATE SCHEMA IF NOT EXISTS d8 - V002 — Create projetos_monitorados — Cria
d8.projetos_monitoradoscom índices e constraints. - V003 — Create historico_manutencoes — Cria
d8.historico_manutencoescom índices e constraints. - V004 — Create processed_events — Cria
d8.processed_eventscom UNIQUE emevent_id. - V005 — Create consumer_offset — Cria
d8.consumer_offsetcom PK emtipo_evento. - 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.
2.7 Relações internas
Seção intitulada “2.7 Relações internas”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.
2.8 Decisões de schema
Seção intitulada “2.8 Decisões de schema”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.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”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éundefinedounull: ignora. Demanda comum, sem manutenção programada. - Se
plano_manutencao.profundidade > profundidade_maxima(padrão: 2): ignora. Limite de aninhamento. - Se já existe
projetos_monitoradoscomdemanda_original_id = demanda_id: ignora (idempotente).
3.2 Evento consumido: manutenção.executada
Seção intitulada “3.2 Evento consumido: manutenção.executada”| 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_ida partir dehistorico_manutencoes.demanda_manutencao_id = demanda_id. - Atualiza status do histórico para
concluida. - Recalcula
proximo_ciclo = NOW() + plano_manutencao.intervalo_diasno projeto monitorado. - Se
proximo_ciclo > data_criacao_projeto + vida_util_dias: publicamanutenção.ciclo_encerradoe 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_idnão é conhecido emprojetos_monitorados: ignora (defensivo). - Atualiza
historico_manutencoes.statusdegeradaparaem_andamento(a demanda entrou no pipeline e está visível).
3.4 Evento produzido: demanda.recebida
Seção intitulada “3.4 Evento produzido: demanda.recebida”| 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.
3.5 Evento produzido: demanda.manutenção_gerada
Seção intitulada “3.5 Evento produzido: demanda.manutenção_gerada”| 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}3.7 Estrutura do dossiê (DossieManutencao)
Seção intitulada “3.7 Estrutura do dossiê (DossieManutencao)”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')3.11 Tratamento de erro e idempotência
Seção intitulada “3.11 Tratamento de erro e idempotência”A D-8 implementa idempotência em dois níveis:
-
d8.processed_events— todoevent_idprocessado é registrado antes do commit. Se o mesmo evento chegar duas vezes, é ignorado. Cobre duplicação por replay. -
UNIQUE
uq_d8_projetos_demanda_original— protege contra criação duplicada de projeto monitorado para a mesma demanda original. -
Verificação defensiva nos handlers —
onManutencaoExecutadaeonDemandaRecebidaverificam 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
EventBusServicecom 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.
3.12 Decisões de design com justificativa
Seção intitulada “3.12 Decisões de design com justificativa”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. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”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 pesoParâ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.tsasync 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(), })4.6 Máquina de estados do projeto monitorado
Seção intitulada “4.6 Máquina de estados do projeto monitorado” 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:
ativo→ativo: cada ciclo de manutenção executado. O status não muda.ativo→encerrado (vida útil esgotada): quandoNOW() > data_criacao + vida_util_diasao recebermanutenção.executada.ativo→encerrado (manual): via métodoencerrarMonitoramento(), exposto como endpoint administrativo na Fase 3.ativo→pausado: 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.
4.8 Casos de borda adicionais
Seção intitulada “4.8 Casos de borda adicionais”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)5.3 Publicação de eventos — exemplos
Seção intitulada “5.3 Publicação de eventos — exemplos”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; }}5.4 Chamadas síncronas via BFF
Seção intitulada “5.4 Chamadas síncronas via BFF”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.
5.5 Dependências de projeções de leitura
Seção intitulada “5.5 Dependências de projeções de leitura”A D-8 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.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 deparâmetros.atualizados.
5.6 Independência de Redis
Seção intitulada “5.6 Independência de Redis”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.recebidaganhaorigem,projeto_original_idedossiena versão2.0.0, que também tornacidadao_idopcional e admitecanal = 'sistema'demanda.concluídaganhaplano_manutencaona versão1.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.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”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.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| 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. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”Query mais frequente — CronJob de varredura:
SELECT * FROM d8.projetos_monitoradosWHERE 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_manutencoesWHERE demanda_manutencao_id = $1;Atendida por: idx_d8_historico_demanda.
Query do DossieBuilder — buscar histórico do projeto:
SELECT * FROM d8.historico_manutencoesWHERE projeto_id = $1ORDER 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.
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”- CronJob: 1 query a cada 1 hora (configurável). Varre
projetos_monitoradoscom í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.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”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.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| 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.
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”A D-8 pode ser testada sem nenhuma outra colônia ativa:
- Mock do EventBusService — emite eventos sintéticos e captura publicações para assertions.
- Banco de dados isolado — schema
d8em banco de teste dedicado, migrations aplicadas viaprisma migrate deploy. - CronJob controlável — o
AgendadorCiclosaceita injeção de umtrigger()manual que dispara a varredura sem esperar o intervalo real. - Configuração injetável —
d8.constants.tsaceita 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();});7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Cenário 1: Projeto concluído sem plano de manutenção é ignorado
- Publicar
demanda.concluídasemplano_manutencao - Assert: nenhum registro criado em
projetos_monitorados
Cenário 2: Projeto concluído com plano de manutenção é registrado
- Publicar
demanda.concluídacomplano_manutencaopreenchido - Assert: 1 registro em
projetos_monitoradoscomstatus = 'ativo'eproximo_ciclocalculado
Cenário 3: Profundidade máxima bloqueia aninhamento excessivo
- Publicar
demanda.concluídacomplano_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ídacom plano (event_id = A) - Publicar novamente
demanda.concluídacom 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 horaestatus = 'ativo' - Disparar
agendadorCiclos.executar() - Assert:
demanda.recebidapublicado no barramento comorigem = 'manutenção_programada' - Assert: 1 registro em
historico_manutencoescomstatus = 'gerada' - Assert:
proximo_ciclodo projeto atualizado paraNOW() + intervalo_dias
Cenário 6: CronJob não gera demanda para projeto com ciclo futuro
- Criar projeto com
proximo_ciclo = NOW() + 7 diasestatus = '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.executadareferenciando odemanda_manutencao_id - Assert: histórico atualizado para
status = 'concluida' - Assert:
proximo_ciclodo 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_encerradopublicado
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_toleranciadepende doprazo_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'eproximo_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'edemanda_manutencao_id = X - Publicar
demanda.recebidacomorigem = 'manutenção_programada'edemanda_id = X - Assert: histórico atualizado para
status = 'em_andamento'
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”// Seed para projetos_monitoradosexport 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, },];8. Alinhamento com a Fase 2
Seção intitulada “8. Alinhamento com a Fase 2”8.1 O que é Fase 2 obrigatório
Seção intitulada “8.1 O que é Fase 2 obrigatório”| 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 |
8.2 Simplificações válidas na Fase 2
Seção intitulada “8.2 Simplificações válidas na Fase 2”| 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). |
8.3 O que vai para a Fase 3
Seção intitulada “8.3 O que vai para a 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/encerrarpara 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.atualizadospara hot-reload dePROFUNDIDADE_MAXIMAe 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.configcom 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.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-8 — Manutenção Programada”
- Conceito de manutenção programada: rede_civica.md, Parte II — Gestão, seção “Manutenção programada”
- Ciclos de avaliação pós-execução: contexto_IA.md, seção 7 (A Gestão — Ciclos)
- Papel do conselheiro e ciclos de atuação: contexto_IA.md, seções 5 (Conselhos de unidade cívica) e 6 (Ciclos de atuação, sorteio e progressão)
- Schemas de eventos: N-0b - Registry.md
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônias a montante: D-6b - Relatoria e Acompanhamento.md
- Colônias a jusante: D-1b - Normalização.md, D-3 - Categorização.md, D-4 - Priorização e Ranking.md, D-5 - Agenda.md, D-7 - Transparência.md
- Parâmetros de priorização e fórmula: contexto_IA.md, seção 7 (A Gestão — Priorização)
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seções “Arquitetura do MVP — Monolito Modular” e “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
Documento de especificação técnica de implementação.