From 13af66c130305eda973d4395fbe5016268ff90f6 Mon Sep 17 00:00:00 2001 From: Estevez Codando Date: Thu, 5 Mar 2026 11:29:08 -0300 Subject: [PATCH 1/2] Analytics views --- .../versions/0002_log_ingestao_execucao.py | 54 ++++ .../versions/0003_sap_analytics_view.py | 40 +++ .../views/sap_analytics/00_create_schema.sql | 1 + .../vw_atividade_enriquecida.sql | 25 ++ .../views/sap_analytics/vw_ut_enriquecida.sql | 35 ++ backend/src/cp/cli/bootstrap_db.py | 8 +- .../sap_sync/analytics_manager.py | 58 ++++ .../sap_sync/analytics_views.py | 304 ++++++++++++++++++ .../src/cp/infrastructure/sap_sync/sync.py | 9 +- backend/tests/cli/test_bootstrap_db.py | 14 +- backend/tests/conftest.py | 11 + backend/tests/test_ut_subfase_conclusao.py | 288 +++++++++++++++++ backend/tests/test_views_sap_analytics.py | 257 +++++++++++++++ 13 files changed, 1099 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/0002_log_ingestao_execucao.py create mode 100644 backend/alembic/versions/0003_sap_analytics_view.py create mode 100644 backend/db/views/sap_analytics/00_create_schema.sql create mode 100644 backend/db/views/sap_analytics/vw_atividade_enriquecida.sql create mode 100644 backend/db/views/sap_analytics/vw_ut_enriquecida.sql create mode 100644 backend/src/cp/infrastructure/sap_sync/analytics_manager.py create mode 100644 backend/src/cp/infrastructure/sap_sync/analytics_views.py create mode 100644 backend/tests/test_ut_subfase_conclusao.py create mode 100644 backend/tests/test_views_sap_analytics.py diff --git a/backend/alembic/versions/0002_log_ingestao_execucao.py b/backend/alembic/versions/0002_log_ingestao_execucao.py new file mode 100644 index 0000000..d6657d2 --- /dev/null +++ b/backend/alembic/versions/0002_log_ingestao_execucao.py @@ -0,0 +1,54 @@ +"""Cria schema log e tabela log.ingestao_execucao. + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-03-03 +""" + +from __future__ import annotations + +import sqlalchemy as sa + +from alembic import op + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("CREATE SCHEMA IF NOT EXISTS log") + + op.create_table( + "ingestao_execucao", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("iniciado_em", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("status", sa.String(length=32), server_default=sa.text("'em_andamento'"), nullable=False), + sa.Column("disparado_por", sa.String(length=255), nullable=False), + sa.Column("finalizado_em", sa.DateTime(timezone=True), nullable=True), + sa.Column("counts", sa.dialects.postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("mensagem_erro", sa.Text(), nullable=True), + schema="log", + ) + + op.create_index( + "ix_log_ingestao_execucao_iniciado_em", + "ingestao_execucao", + ["iniciado_em"], + schema="log", + ) + + op.create_index( + "ix_log_ingestao_execucao_status", + "ingestao_execucao", + ["status"], + schema="log", + ) + + +def downgrade() -> None: + op.drop_index("ix_log_ingestao_execucao_status", table_name="ingestao_execucao", schema="log") + op.drop_index("ix_log_ingestao_execucao_iniciado_em", table_name="ingestao_execucao", schema="log") + op.drop_table("ingestao_execucao", schema="log") + op.execute("DROP SCHEMA IF EXISTS log") diff --git a/backend/alembic/versions/0003_sap_analytics_view.py b/backend/alembic/versions/0003_sap_analytics_view.py new file mode 100644 index 0000000..a33e10f --- /dev/null +++ b/backend/alembic/versions/0003_sap_analytics_view.py @@ -0,0 +1,40 @@ +"""Cria schema sap_analytics com as views de enriquecimento (UT e Atividade). + +Revision ID: 0002 +Revises: 0001 +Create Date: 2025-01-02 + +Views criadas: + - vw_ut_enriquecida : UT + hierarquia completa (projeto/lote/bloco/subfase/fase) + - vw_atividade_enriquecida : atividade + etapa/tipo_etapa/tipo_situacao/usuario/is_finalizada + - vw_ut_atividade : join completo UT + atividade (critério 4 do sprint) +""" + +from __future__ import annotations + +from alembic import op +from cp.infrastructure.sap_sync.analytics_views import ( + DDL_SCHEMA, + DDL_VW_ATIVIDADE_ENRIQUECIDA, + DDL_VW_UT_ATIVIDADE, + DDL_VW_UT_ENRIQUECIDA, +) + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(DDL_SCHEMA) + op.execute(DDL_VW_UT_ENRIQUECIDA) + op.execute(DDL_VW_ATIVIDADE_ENRIQUECIDA) + op.execute(DDL_VW_UT_ATIVIDADE) + + +def downgrade() -> None: + op.execute("DROP VIEW IF EXISTS sap_analytics.vw_ut_atividade CASCADE;") + op.execute("DROP VIEW IF EXISTS sap_analytics.vw_atividade_enriquecida CASCADE;") + op.execute("DROP VIEW IF EXISTS sap_analytics.vw_ut_enriquecida CASCADE;") + op.execute("DROP SCHEMA IF EXISTS sap_analytics CASCADE;") diff --git a/backend/db/views/sap_analytics/00_create_schema.sql b/backend/db/views/sap_analytics/00_create_schema.sql new file mode 100644 index 0000000..60c2cdc --- /dev/null +++ b/backend/db/views/sap_analytics/00_create_schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS sap_analytics; diff --git a/backend/db/views/sap_analytics/vw_atividade_enriquecida.sql b/backend/db/views/sap_analytics/vw_atividade_enriquecida.sql new file mode 100644 index 0000000..d550272 --- /dev/null +++ b/backend/db/views/sap_analytics/vw_atividade_enriquecida.sql @@ -0,0 +1,25 @@ +CREATE OR REPLACE VIEW sap_analytics.vw_atividade_enriquecida AS +SELECT + a.id AS atividade_id, + a.unidade_trabalho_id, + a.etapa_id, + + e.tipo_etapa_id, + te.nome AS tipo_etapa_nome, + + a.tipo_situacao_id, + ts.nome AS tipo_situacao_nome, + + a.usuario_id, + a.data_inicio, + a.data_fim, + a.observacao, + + (a.tipo_situacao_id = 4) AS is_finalizada +FROM sap_snapshot.macrocontrole_atividade a +JOIN sap_snapshot.macrocontrole_etapa e + ON e.id = a.etapa_id +JOIN sap_snapshot.dominio_tipo_etapa te + ON te.code = e.tipo_etapa_id +JOIN sap_snapshot.dominio_tipo_situacao ts + ON ts.code = a.tipo_situacao_id; diff --git a/backend/db/views/sap_analytics/vw_ut_enriquecida.sql b/backend/db/views/sap_analytics/vw_ut_enriquecida.sql new file mode 100644 index 0000000..c22942f --- /dev/null +++ b/backend/db/views/sap_analytics/vw_ut_enriquecida.sql @@ -0,0 +1,35 @@ +CREATE OR REPLACE VIEW sap_analytics.vw_ut_enriquecida AS +SELECT + p.id AS projeto_id, + p.nome AS projeto_nome, + + l.id AS lote_id, + l.nome AS lote_nome, + + b.id AS bloco_id, + b.nome AS bloco_nome, + + sf.id AS subfase_id, + sf.nome AS subfase_nome, + + f.id AS fase_id, + tf.nome AS fase_nome, + + ut.id AS ut_id, + ut.nome AS ut_nome, + + ut.dificuldade, + ut.tempo_estimado_minutos +FROM sap_snapshot.macrocontrole_unidade_trabalho ut +JOIN sap_snapshot.macrocontrole_bloco b + ON b.id = ut.bloco_id +JOIN sap_snapshot.macrocontrole_lote l + ON l.id = b.lote_id +JOIN sap_snapshot.macrocontrole_projeto p + ON p.id = l.projeto_id +JOIN sap_snapshot.macrocontrole_subfase sf + ON sf.id = ut.subfase_id +JOIN sap_snapshot.macrocontrole_fase f + ON f.id = sf.fase_id +JOIN sap_snapshot.dominio_tipo_fase tf + ON tf.code = f.tipo_fase_id; diff --git a/backend/src/cp/cli/bootstrap_db.py b/backend/src/cp/cli/bootstrap_db.py index 1f2e075..b7d9a0b 100644 --- a/backend/src/cp/cli/bootstrap_db.py +++ b/backend/src/cp/cli/bootstrap_db.py @@ -7,8 +7,11 @@ from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine +from cp.infrastructure.sap_sync.analytics_manager import garantir_views_analytics + _SCHEMAS_CP: tuple[str, ...] = ( "sap_snapshot", + "sap_analytics", "auth_snapshot", "kpi", "agregacao", @@ -73,10 +76,10 @@ def criar_banco_cp( senha_admin: str, nome_banco: str, ) -> bool: - """Bootstrap do CP: garante banco e schemas iniciais (idempotente). + """Bootstrap do CP: garante banco, schemas iniciais e views analíticas (idempotente). Retorna True se o banco foi criado agora. - Se o banco já existir, ainda assim garante os schemas e retorna False. + Se o banco já existir, ainda assim garante os schemas/views e retorna False. """ criado_agora = criar_banco( host=host, @@ -91,5 +94,6 @@ def criar_banco_cp( dsn_cp = f"postgresql+psycopg2://{usuario_admin}:{senha_admin}@{host}:{port}/{nome_banco}" engine_cp = create_engine(dsn_cp, future=True) _criar_schemas_cp(engine_cp) + garantir_views_analytics(engine_cp) return criado_agora diff --git a/backend/src/cp/infrastructure/sap_sync/analytics_manager.py b/backend/src/cp/infrastructure/sap_sync/analytics_manager.py new file mode 100644 index 0000000..d7fb569 --- /dev/null +++ b/backend/src/cp/infrastructure/sap_sync/analytics_manager.py @@ -0,0 +1,58 @@ +"""Gerenciador das views analíticas do schema sap_analytics. + +Responsabilidade única: garantir que as views existam e estejam atualizadas. +Chamado em dois momentos do ciclo de vida: + 1. Bootstrap do banco (ao subir o sistema pela primeira vez ou após deploy) + 2. Após cada ingestão do pipeline SAP → sap_snapshot + +Estratégia DROP + CREATE (em vez de CREATE OR REPLACE): + O PostgreSQL não permite que CREATE OR REPLACE VIEW altere a assinatura de + uma view já existente (ordem ou tipo das colunas). Para suportar evoluções + de schema sem exigir intervenção manual, fazemos DROP IF EXISTS ... CASCADE + seguido de CREATE VIEW dentro da mesma transação. O CASCADE garante que + views dependentes (ex.: vw_ut_atividade depende de vw_ut_enriquecida) sejam + derrubadas e recriadas na ordem correta. +""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.engine import Connection, Engine + +from cp.infrastructure.sap_sync.analytics_views import ( + DDL_SCHEMA, + NOMES_DAS_VIEWS, + TODAS_AS_VIEWS, +) + + +def garantir_views_analytics(engine_cp: Engine) -> None: + """Cria o schema e aplica todas as views analíticas (idempotente). + + Faz DROP IF EXISTS CASCADE + CREATE em transação única. + Seguro para rodar múltiplas vezes e para mudanças de assinatura. + """ + with engine_cp.begin() as conn: + _aplicar_views(conn) + + +def atualizar_views_analytics(conn_cp: Connection) -> None: + """Re-aplica as views dentro de uma conexão/transação já existente. + + Projetado para ser chamado no final do pipeline de sync, aproveitando + a mesma transação e evitando overhead de abertura de nova conexão. + """ + _aplicar_views(conn_cp) + + +def _aplicar_views(conn: Connection) -> None: + conn.execute(text(DDL_SCHEMA)) + _derrubar_views(conn) + for ddl in TODAS_AS_VIEWS: + conn.execute(text(ddl)) + + +def _derrubar_views(conn: Connection) -> None: + """Remove todas as views em ordem inversa de dependência (CASCADE cobre o resto).""" + for nome in NOMES_DAS_VIEWS: + conn.execute(text(f"DROP VIEW IF EXISTS {nome} CASCADE")) diff --git a/backend/src/cp/infrastructure/sap_sync/analytics_views.py b/backend/src/cp/infrastructure/sap_sync/analytics_views.py new file mode 100644 index 0000000..06256e6 --- /dev/null +++ b/backend/src/cp/infrastructure/sap_sync/analytics_views.py @@ -0,0 +1,304 @@ +"""DDL das views analíticas do schema sap_analytics. + +Camada de consumo construída sobre sap_snapshot. +As views são recriadas via DROP ... CASCADE + CREATE a cada aplicação, +garantindo que mudanças de assinatura (novas colunas, reordenação) nunca +causem InvalidTableDefinition no PostgreSQL. + +Views disponíveis: + - vw_ut_enriquecida : UT com toda hierarquia (projeto → lote → bloco → subfase → fase) + - vw_atividade_enriquecida : atividade com etapa, tipo_etapa, tipo_situacao, usuario, flag is_finalizada + - vw_ut_atividade : join completo UT + atividade (critério 4 do sprint) + - vw_ut_subfase_conclusao : agregação (ut, subfase) → conclusão real baseada em todas as atividades + +Regra de conclusão de (ut, subfase): + Uma unidade de trabalho está concluída em uma subfase quando TODAS as suas atividades + naquela subfase têm tipo_situacao_id IN (4=Finalizada, 5=Não finalizada). + Qualquer atividade em 1=Não iniciada, 2=Em execução ou 3=Pausada impede a conclusão. +""" + +from __future__ import annotations + +_SCHEMA_ANALYTICS = "sap_analytics" +_SCHEMA_SNAPSHOT = "sap_snapshot" + +# --------------------------------------------------------------------------- +# DDL — schema +# --------------------------------------------------------------------------- + +DDL_SCHEMA = f"CREATE SCHEMA IF NOT EXISTS {_SCHEMA_ANALYTICS};" + +# --------------------------------------------------------------------------- +# DDL — vw_ut_enriquecida +# +# Hierarquia completa da Unidade de Trabalho: +# ut → subfase → fase → tipo_fase (fase_nome) +# ut → lote → projeto +# ut → bloco +# --------------------------------------------------------------------------- + +DDL_VW_UT_ENRIQUECIDA = f""" +CREATE VIEW {_SCHEMA_ANALYTICS}.vw_ut_enriquecida AS +SELECT + -- Projeto + proj.id AS projeto_id, + proj.nome AS projeto_nome, + + -- Lote + lote.id AS lote_id, + lote.nome AS lote_nome, + + -- Bloco + bloco.id AS bloco_id, + bloco.nome AS bloco_nome, + + -- Subfase + sf.id AS subfase_id, + sf.nome AS subfase_nome, + + -- Fase (tipo_fase fornece o nome legível) + fase.id AS fase_id, + tf.nome AS fase_nome, + + -- Unidade de Trabalho + ut.id AS ut_id, + ut.nome AS ut_nome, + ut.dificuldade, + ut.tempo_estimado_minutos + +FROM {_SCHEMA_SNAPSHOT}.macrocontrole_unidade_trabalho AS ut +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_subfase AS sf ON sf.id = ut.subfase_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_fase AS fase ON fase.id = sf.fase_id +JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_fase AS tf ON tf.code = fase.tipo_fase_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_lote AS lote ON lote.id = ut.lote_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_projeto AS proj ON proj.id = lote.projeto_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_bloco AS bloco ON bloco.id = ut.bloco_id; +""".strip() + +# --------------------------------------------------------------------------- +# DDL — vw_atividade_enriquecida +# +# Atividade com toda informação dimensional resolvida: +# atividade → etapa → tipo_etapa +# atividade → tipo_situacao +# atividade → usuario (dgeo) +# is_finalizada: tipo_situacao_id = 4 (Finalizada) +# --------------------------------------------------------------------------- + +DDL_VW_ATIVIDADE_ENRIQUECIDA = f""" +CREATE VIEW {_SCHEMA_ANALYTICS}.vw_atividade_enriquecida AS +SELECT + -- Atividade + atv.id AS atividade_id, + atv.unidade_trabalho_id, + + -- Etapa + etapa.id AS etapa_id, + + -- Tipo de etapa + te.code AS tipo_etapa_id, + te.nome AS tipo_etapa_nome, + + -- Tipo de situação + ts.code AS tipo_situacao_id, + ts.nome AS tipo_situacao_nome, + + -- Usuário + usr.id AS usuario_id, + usr.nome AS usuario_nome, + + -- Temporais + atv.data_inicio, + atv.data_fim, + + -- Texto livre + atv.observacao, + + -- Flag derivada: finalizada somente quando tipo_situacao_id = 4 + (atv.tipo_situacao_id = 4) AS is_finalizada + +FROM {_SCHEMA_SNAPSHOT}.macrocontrole_atividade AS atv +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_etapa AS etapa ON etapa.id = atv.etapa_id +JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_etapa AS te ON te.code = etapa.tipo_etapa_id +JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_situacao AS ts ON ts.code = atv.tipo_situacao_id +LEFT JOIN {_SCHEMA_SNAPSHOT}.dgeo_usuario AS usr ON usr.id = atv.usuario_id; +""".strip() + +# --------------------------------------------------------------------------- +# DDL — vw_ut_atividade +# +# Join completo conforme critério 4 do sprint: +# lote_id/nome, bloco_id/nome, subfase_id/nome, ut_nome, dificuldade, +# atividade_id, tipo_etapa_id/nome, tipo_situacao_id/nome, +# usuario_id/nome, observação, is_finalizada, data_inicio, data_fim +# --------------------------------------------------------------------------- + +DDL_VW_UT_ATIVIDADE = f""" +CREATE VIEW {_SCHEMA_ANALYTICS}.vw_ut_atividade AS +SELECT + -- Hierarquia da UT + ut_enr.projeto_id, + ut_enr.projeto_nome, + ut_enr.lote_id, + ut_enr.lote_nome, + ut_enr.bloco_id, + ut_enr.bloco_nome, + ut_enr.subfase_id, + ut_enr.subfase_nome, + ut_enr.fase_id, + ut_enr.fase_nome, + ut_enr.ut_id, + ut_enr.ut_nome, + ut_enr.dificuldade, + ut_enr.tempo_estimado_minutos, + + -- Atividade + atv_enr.atividade_id, + atv_enr.etapa_id, + atv_enr.tipo_etapa_id, + atv_enr.tipo_etapa_nome, + atv_enr.tipo_situacao_id, + atv_enr.tipo_situacao_nome, + atv_enr.usuario_id, + atv_enr.usuario_nome, + atv_enr.observacao, + atv_enr.is_finalizada, + atv_enr.data_inicio, + atv_enr.data_fim + +FROM {_SCHEMA_ANALYTICS}.vw_ut_enriquecida AS ut_enr +JOIN {_SCHEMA_ANALYTICS}.vw_atividade_enriquecida AS atv_enr + ON atv_enr.unidade_trabalho_id = ut_enr.ut_id; +""".strip() + +# --------------------------------------------------------------------------- +# DDL — vw_ut_subfase_conclusao +# +# Agrega todas as atividades por (ut, subfase) e deriva se o par está concluído. +# +# Regra de negócio: +# concluida = TRUE → todas as atividades têm situacao IN (4, 5) +# concluida = FALSE → existe ao menos uma atividade em (1, 2 ou 3) +# +# Situações que BLOQUEIAM a conclusão: +# 1 = Não iniciada +# 2 = Em execução +# 3 = Pausada +# +# Situações que PERMITEM a conclusão: +# 4 = Finalizada +# 5 = Não finalizada (encerrada por decisão, não por conclusão normal) +# +# Campos de observabilidade: +# total_atividades — total de atividades do par (ut, subfase) +# total_concluidas — situacao IN (4, 5) +# total_pendentes — situacao IN (1, 2, 3) — devem ser 0 para concluir +# etapas_situacao — array ordenado com "TipoEtapa: Situação" para TODAS as etapas +# ex: ["Execução: Não iniciada", "Revisão: Finalizada"] +# situacoes_pendentes — array com "TipoEtapa: Situação" apenas das que bloqueiam +# ex: ["Execução: Não iniciada", "Revisão: Pausada"] +# --------------------------------------------------------------------------- + +DDL_VW_UT_SUBFASE_CONCLUSAO = f""" +CREATE VIEW {_SCHEMA_ANALYTICS}.vw_ut_subfase_conclusao AS +WITH atividades_por_ut_subfase AS ( + SELECT + atv.unidade_trabalho_id AS ut_id, + etapa.subfase_id, + + -- Contagens por categoria de situação + COUNT(*) AS total_atividades, + COUNT(*) FILTER (WHERE atv.tipo_situacao_id IN (4, 5)) AS total_concluidas, + COUNT(*) FILTER (WHERE atv.tipo_situacao_id IN (1, 2, 3)) AS total_pendentes, + + -- Todas as etapas com sua situação atual, ordenadas por tipo_etapa + -- Formato: "TipoEtapa: Situação" ex: "Execução: Finalizada" + ARRAY_REMOVE( + ARRAY_AGG( + te.nome || ': ' || ts.nome + ORDER BY te.nome, ts.nome + ), + NULL + ) AS etapas_situacao, + + -- Apenas as combinações que ainda bloqueiam (situacao IN 1, 2, 3) + -- Formato: "TipoEtapa: Situação" ex: "Execução: Não iniciada" + ARRAY_REMOVE( + ARRAY_AGG( + CASE + WHEN atv.tipo_situacao_id IN (1, 2, 3) + THEN te.nome || ': ' || ts.nome + END + ORDER BY te.nome, ts.nome + ), + NULL + ) AS situacoes_pendentes, + + -- Conclusão real: nenhuma atividade pendente + BOOL_AND(atv.tipo_situacao_id IN (4, 5)) AS concluida + + FROM {_SCHEMA_SNAPSHOT}.macrocontrole_atividade AS atv + JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_etapa AS etapa ON etapa.id = atv.etapa_id + JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_situacao AS ts ON ts.code = atv.tipo_situacao_id + JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_etapa AS te ON te.code = etapa.tipo_etapa_id + GROUP BY atv.unidade_trabalho_id, etapa.subfase_id +) +SELECT + -- Identificadores + agg.ut_id, + ut.nome AS ut_nome, + agg.subfase_id, + sf.nome AS subfase_nome, + + -- Hierarquia para facilitar filtros downstream + lote.id AS lote_id, + lote.nome AS lote_nome, + bloco.id AS bloco_id, + bloco.nome AS bloco_nome, + fase.id AS fase_id, + tf.nome AS fase_nome, + proj.id AS projeto_id, + proj.nome AS projeto_nome, + + -- Métricas de atividades + agg.total_atividades, + agg.total_concluidas, + agg.total_pendentes, + + -- Diagnóstico completo: todas as etapas com sua situação + agg.etapas_situacao, + + -- Diagnóstico de bloqueio: apenas etapas que impedem conclusão + agg.situacoes_pendentes, + + -- Flag principal + agg.concluida + +FROM atividades_por_ut_subfase AS agg +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_unidade_trabalho AS ut ON ut.id = agg.ut_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_subfase AS sf ON sf.id = agg.subfase_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_fase AS fase ON fase.id = sf.fase_id +JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_fase AS tf ON tf.code = fase.tipo_fase_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_lote AS lote ON lote.id = ut.lote_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_projeto AS proj ON proj.id = lote.projeto_id +JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_bloco AS bloco ON bloco.id = ut.bloco_id; +""".strip() + +# --------------------------------------------------------------------------- +# Conjunto ordenado de DDLs (dependências respeitadas para CREATE) +# --------------------------------------------------------------------------- + +TODAS_AS_VIEWS: tuple[str, ...] = ( + DDL_VW_UT_ENRIQUECIDA, + DDL_VW_ATIVIDADE_ENRIQUECIDA, + DDL_VW_UT_ATIVIDADE, # depende das duas anteriores + DDL_VW_UT_SUBFASE_CONCLUSAO, # agregação independente sobre sap_snapshot +) + +# Ordem inversa para DROP CASCADE (dependentes primeiro) +NOMES_DAS_VIEWS: tuple[str, ...] = ( + f"{_SCHEMA_ANALYTICS}.vw_ut_subfase_conclusao", + f"{_SCHEMA_ANALYTICS}.vw_ut_atividade", + f"{_SCHEMA_ANALYTICS}.vw_atividade_enriquecida", + f"{_SCHEMA_ANALYTICS}.vw_ut_enriquecida", +) diff --git a/backend/src/cp/infrastructure/sap_sync/sync.py b/backend/src/cp/infrastructure/sap_sync/sync.py index 87084db..21f4318 100644 --- a/backend/src/cp/infrastructure/sap_sync/sync.py +++ b/backend/src/cp/infrastructure/sap_sync/sync.py @@ -18,6 +18,8 @@ from sqlalchemy import text from sqlalchemy.engine import Connection, Engine +from cp.infrastructure.sap_sync.analytics_manager import atualizar_views_analytics + _SCHEMA = "sap_snapshot" _BATCH = 5_000 @@ -342,11 +344,16 @@ def sincronizar_sap_para_snapshot( engine_sap: Engine, engine_cp: Engine, ) -> list[ResultadoTabela]: - """Executa o pipeline completo dentro de uma única transação no CP.""" + """Executa o pipeline completo dentro de uma única transação no CP. + + Após a ingestão de todos os dados, as views analíticas (sap_analytics) + são re-aplicadas na mesma transação para garantir consistência. + """ resultados: list[ResultadoTabela] = [] with engine_cp.begin() as conn_cp, engine_sap.connect() as conn_sap: for fn in _PIPELINE: resultados.append(fn(conn_sap, conn_cp)) + atualizar_views_analytics(conn_cp) return resultados diff --git a/backend/tests/cli/test_bootstrap_db.py b/backend/tests/cli/test_bootstrap_db.py index 769c9ad..b172387 100644 --- a/backend/tests/cli/test_bootstrap_db.py +++ b/backend/tests/cli/test_bootstrap_db.py @@ -88,16 +88,20 @@ def test_criar_banco_cp_cria_schemas_mesmo_se_banco_ja_existe(monkeypatch: pytes def criar_banco_fake(**_kwargs: object) -> bool: return False - chamadas: dict[str, int] = {"schemas": 0} + chamadas: dict[str, int] = {"schemas": 0, "views": 0} def criar_schemas_fake(_engine_cp: object) -> None: chamadas["schemas"] += 1 + def garantir_views_fake(_engine_cp: object) -> None: + chamadas["views"] += 1 + def create_engine_fake(_dsn: str, future: bool = True) -> object: return object() monkeypatch.setattr(bootstrap_db, "criar_banco", criar_banco_fake) monkeypatch.setattr(bootstrap_db, "_criar_schemas_cp", criar_schemas_fake) + monkeypatch.setattr(bootstrap_db, "garantir_views_analytics", garantir_views_fake) monkeypatch.setattr(bootstrap_db, "create_engine", create_engine_fake) criado_agora = bootstrap_db.criar_banco_cp( @@ -110,22 +114,27 @@ def create_engine_fake(_dsn: str, future: bool = True) -> object: assert criado_agora is False assert chamadas["schemas"] == 1 + assert chamadas["views"] == 1 def test_criar_banco_cp_retorna_true_quando_criou_banco_e_garante_schemas(monkeypatch: pytest.MonkeyPatch) -> None: def criar_banco_fake(**_kwargs: object) -> bool: return True - chamadas: dict[str, int] = {"schemas": 0} + chamadas: dict[str, int] = {"schemas": 0, "views": 0} def criar_schemas_fake(_engine_cp: object) -> None: chamadas["schemas"] += 1 + def garantir_views_fake(_engine_cp: object) -> None: + chamadas["views"] += 1 + def create_engine_fake(_dsn: str, future: bool = True) -> object: return object() monkeypatch.setattr(bootstrap_db, "criar_banco", criar_banco_fake) monkeypatch.setattr(bootstrap_db, "_criar_schemas_cp", criar_schemas_fake) + monkeypatch.setattr(bootstrap_db, "garantir_views_analytics", garantir_views_fake) monkeypatch.setattr(bootstrap_db, "create_engine", create_engine_fake) criado_agora = bootstrap_db.criar_banco_cp( @@ -138,6 +147,7 @@ def create_engine_fake(_dsn: str, future: bool = True) -> object: assert criado_agora is True assert chamadas["schemas"] == 1 + assert chamadas["views"] == 1 def test_schemas_cp_contem_agregacao_e_dominio() -> None: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1d7babc..9d1799c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,6 +9,7 @@ from cp.config.settings import Settings from cp.infrastructure.db import criar_engine_cp, criar_engine_sap_test +from cp.infrastructure.sap_sync.analytics_manager import garantir_views_analytics _FIXTURES_DIR = Path(__file__).parent / "fixtures" @@ -59,6 +60,16 @@ def engine_sap(settings: Settings) -> Engine: return criar_engine_sap_test(settings) +@pytest.fixture(scope="session", autouse=True) +def analytics_views_bootstrap(engine_cp: Engine) -> None: + """Garante que as views analíticas existam antes de qualquer teste. + + Escopo de sessão: executa uma única vez por suite de testes. + Idempotente — seguro rodar mesmo que as views já existam. + """ + garantir_views_analytics(engine_cp) + + @pytest.fixture() def sap_seed(engine_cp: Engine, settings: Settings) -> None: """Limpa snapshot + SAP e repopula do zero antes de cada teste.""" diff --git a/backend/tests/test_ut_subfase_conclusao.py b/backend/tests/test_ut_subfase_conclusao.py new file mode 100644 index 0000000..bbc9840 --- /dev/null +++ b/backend/tests/test_ut_subfase_conclusao.py @@ -0,0 +1,288 @@ +"""Testes de integração para vw_ut_subfase_conclusao. + +Valida a regra de negócio central: + Uma (ut, subfase) está concluída somente quando TODAS as suas atividades + têm tipo_situacao_id IN (4, 5). + Qualquer atividade em 1=Não iniciada, 2=Em execução ou 3=Pausada bloqueia. + +Cenários cobertos: + 1. Campos obrigatórios presentes na view (incluindo etapas_situacao) + 2. (ut, subfase) com atividades mistas → concluida = FALSE + 3. (ut, subfase) com todas finalizadas/não-finalizadas → concluida = TRUE + 4. total_pendentes == 0 implica concluida == TRUE (e vice-versa) + 5. situacoes_pendentes usa formato "TipoEtapa: Situação" e lista apenas bloqueantes + 6. etapas_situacao inclui TODAS as etapas no formato "TipoEtapa: Situação" + 7. Cenário de injeção manual — atividade Em execução bloqueia par já concluído + 8. Idempotência após múltiplos syncs +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from cp.infrastructure.sap_sync.sync import sincronizar_sap_para_snapshot + +_VIEW = "sap_analytics.vw_ut_subfase_conclusao" +_SEPARADOR = ": " +_SITUACOES_BLOQUEANTES_NOMES = {"Não iniciada", "Em execução", "Pausada"} +_TODAS_SITUACOES_NOMES = {"Não iniciada", "Em execução", "Pausada", "Finalizada", "Não finalizada"} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _buscar(engine: Engine, sql: str, params: dict | None = None) -> list[dict]: + with engine.connect() as conn: + rows = conn.execute(text(sql), params or {}).fetchall() + return [dict(r._mapping) for r in rows] + + +def _scalar(engine: Engine, sql: str, params: dict | None = None) -> object: + with engine.connect() as conn: + return conn.execute(text(sql), params or {}).scalar_one() + + +# --------------------------------------------------------------------------- +# 1. Estrutura da view +# --------------------------------------------------------------------------- + +_CAMPOS_ESPERADOS = { + "ut_id", + "ut_nome", + "subfase_id", + "subfase_nome", + "lote_id", + "lote_nome", + "bloco_id", + "bloco_nome", + "fase_id", + "fase_nome", + "projeto_id", + "projeto_nome", + "total_atividades", + "total_concluidas", + "total_pendentes", + "etapas_situacao", + "situacoes_pendentes", + "concluida", +} + + +def test_vw_ut_subfase_conclusao_tem_campos_obrigatorios(engine_cp: Engine) -> None: + sql = text("SELECT column_name FROM information_schema.columns WHERE table_schema = 'sap_analytics' AND table_name = 'vw_ut_subfase_conclusao'") + with engine_cp.connect() as conn: + colunas = {r[0] for r in conn.execute(sql).fetchall()} + + ausentes = _CAMPOS_ESPERADOS - colunas + assert not ausentes, f"Campos ausentes em vw_ut_subfase_conclusao: {ausentes}" + + +# --------------------------------------------------------------------------- +# 2 & 3. Lógica de conclusão com dados do seed +# --------------------------------------------------------------------------- + + +def test_concluida_false_quando_ha_atividade_pendente(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + pendentes = _buscar(engine_cp, f"SELECT * FROM {_VIEW} WHERE total_pendentes > 0") + assert pendentes, "Seed deve conter pares (ut, subfase) com atividades pendentes" + + for row in pendentes: + assert row["concluida"] is False, ( + f"ut_id={row['ut_id']} subfase_id={row['subfase_id']} tem total_pendentes={row['total_pendentes']} mas concluida=TRUE" + ) + + +def test_concluida_true_somente_quando_zero_pendentes(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + violacoes_true = _scalar( + engine_cp, + f"SELECT COUNT(*) FROM {_VIEW} WHERE concluida = TRUE AND total_pendentes > 0", + ) + assert violacoes_true == 0, f"{violacoes_true} pares marcados concluida=TRUE com atividades pendentes" + + violacoes_false = _scalar( + engine_cp, + f"SELECT COUNT(*) FROM {_VIEW} WHERE concluida = FALSE AND total_pendentes = 0", + ) + assert violacoes_false == 0, f"{violacoes_false} pares marcados concluida=FALSE sem nenhuma pendência" + + +def test_total_atividades_igual_concluidas_mais_pendentes(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + violacoes = _scalar( + engine_cp, + f"SELECT COUNT(*) FROM {_VIEW} WHERE total_atividades <> total_concluidas + total_pendentes", + ) + assert violacoes == 0, f"{violacoes} pares com total_atividades != total_concluidas + total_pendentes" + + +# --------------------------------------------------------------------------- +# 4 & 5. Formato de situacoes_pendentes: "TipoEtapa: Situação" +# --------------------------------------------------------------------------- + + +def test_situacoes_pendentes_formato_etapa_situacao(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """Cada entrada de situacoes_pendentes deve ter formato 'TipoEtapa: Situação'.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + rows = _buscar( + engine_cp, + f"SELECT situacoes_pendentes FROM {_VIEW} WHERE array_length(situacoes_pendentes, 1) > 0", + ) + assert rows, "Seed deve conter pares com situacoes_pendentes preenchidas" + + for row in rows: + for entrada in row["situacoes_pendentes"]: + assert _SEPARADOR in entrada, f"Entrada '{entrada}' não segue o formato 'TipoEtapa: Situação'" + _, situacao = entrada.split(_SEPARADOR, 1) + assert situacao in _SITUACOES_BLOQUEANTES_NOMES, f"situacoes_pendentes contém situação não-bloqueante: '{situacao}'" + + +def test_situacoes_pendentes_vazio_quando_concluida(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + violacoes = _scalar( + engine_cp, + f"SELECT COUNT(*) FROM {_VIEW} WHERE concluida = TRUE AND array_length(situacoes_pendentes, 1) > 0", + ) + assert violacoes == 0, f"{violacoes} pares concluídos ainda têm situacoes_pendentes preenchidas" + + +# --------------------------------------------------------------------------- +# 6. etapas_situacao: todas as etapas no formato "TipoEtapa: Situação" +# --------------------------------------------------------------------------- + + +def test_etapas_situacao_formato_etapa_situacao(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """Cada entrada de etapas_situacao deve ter formato 'TipoEtapa: Situação'.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + rows = _buscar( + engine_cp, + f"SELECT etapas_situacao FROM {_VIEW} WHERE array_length(etapas_situacao, 1) > 0 LIMIT 20", + ) + assert rows, "Seed deve produzir registros com etapas_situacao preenchido" + + for row in rows: + for entrada in row["etapas_situacao"]: + assert _SEPARADOR in entrada, f"Entrada '{entrada}' não segue o formato 'TipoEtapa: Situação'" + _, situacao = entrada.split(_SEPARADOR, 1) + assert situacao in _TODAS_SITUACOES_NOMES, f"etapas_situacao contém situação desconhecida: '{situacao}'" + + +def test_etapas_situacao_inclui_todas_as_situacoes(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """etapas_situacao deve ter pelo menos tantas entradas quanto total_atividades.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + violacoes = _scalar( + engine_cp, + f"SELECT COUNT(*) FROM {_VIEW} WHERE array_length(etapas_situacao, 1) < total_atividades", + ) + assert violacoes == 0, f"{violacoes} pares com etapas_situacao menor que total_atividades" + + +def test_situacoes_pendentes_subconjunto_de_etapas_situacao(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """Todo item de situacoes_pendentes deve aparecer em etapas_situacao.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + violacoes = _scalar( + engine_cp, + f"SELECT COUNT(*) FROM {_VIEW} WHERE NOT (situacoes_pendentes <@ etapas_situacao)", + ) + assert violacoes == 0, f"{violacoes} pares onde situacoes_pendentes não é subconjunto de etapas_situacao" + + +# --------------------------------------------------------------------------- +# 7. Granularidade: cada linha é única por (ut_id, subfase_id) +# --------------------------------------------------------------------------- + + +def test_granularidade_ut_subfase_unica(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + duplicatas = _scalar( + engine_cp, + f"SELECT COUNT(*) FROM ( SELECT ut_id, subfase_id FROM {_VIEW} GROUP BY ut_id, subfase_id HAVING COUNT(*) > 1) AS dup", + ) + assert duplicatas == 0, f"{duplicatas} pares (ut_id, subfase_id) duplicados na view" + + +# --------------------------------------------------------------------------- +# 8. Injeção manual — atividade Em execução bloqueia par já concluído +# --------------------------------------------------------------------------- + + +def test_atividade_em_execucao_bloqueia_conclusao(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + concluidos = _buscar( + engine_cp, + f"SELECT ut_id, subfase_id FROM {_VIEW} WHERE concluida = TRUE LIMIT 1", + ) + if not concluidos: + pytest.skip("Seed não produziu nenhum par concluído para este cenário") + + ut_id = concluidos[0]["ut_id"] + subfase_id = concluidos[0]["subfase_id"] + + etapa_rows = _buscar( + engine_cp, + "SELECT id FROM sap_snapshot.macrocontrole_etapa WHERE subfase_id = :sf LIMIT 1", + {"sf": subfase_id}, + ) + assert etapa_rows, f"Nenhuma etapa encontrada para subfase_id={subfase_id}" + etapa_id = etapa_rows[0]["id"] + + with engine_cp.begin() as conn: + conn.execute( + text( + "INSERT INTO sap_snapshot.macrocontrole_atividade " + "(id, etapa_id, unidade_trabalho_id, usuario_id, tipo_situacao_id, " + " data_inicio, data_fim, observacao) " + "VALUES (99997, :etapa, :ut, NULL, 2, NULL, NULL, 'injecao_teste_bloqueio') " + "ON CONFLICT (id) DO NOTHING" + ), + {"etapa": etapa_id, "ut": ut_id}, + ) + + resultado = _buscar( + engine_cp, + f"SELECT concluida, total_pendentes, situacoes_pendentes, etapas_situacao FROM {_VIEW} WHERE ut_id = :ut AND subfase_id = :sf", + {"ut": ut_id, "sf": subfase_id}, + ) + assert resultado, "Par (ut, subfase) sumiu da view após injeção" + + row = resultado[0] + assert row["concluida"] is False + assert row["total_pendentes"] >= 1 + + pendentes_str = " | ".join(row["situacoes_pendentes"]) + assert "Em execução" in pendentes_str, f"'Em execução' não encontrada em situacoes_pendentes: {row['situacoes_pendentes']}" + + etapas_str = " | ".join(row["etapas_situacao"]) + assert "Em execução" in etapas_str, f"'Em execução' não encontrada em etapas_situacao: {row['etapas_situacao']}" + + with engine_cp.begin() as conn: + conn.execute(text("DELETE FROM sap_snapshot.macrocontrole_atividade WHERE id = 99997")) + + +# --------------------------------------------------------------------------- +# 9. Idempotência após múltiplos syncs +# --------------------------------------------------------------------------- + + +def test_resultado_estavel_apos_multiplos_syncs(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + count_1 = _scalar(engine_cp, f"SELECT COUNT(*) FROM {_VIEW}") + + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + count_2 = _scalar(engine_cp, f"SELECT COUNT(*) FROM {_VIEW}") + + assert count_1 == count_2, f"Contagem instável entre syncs: {count_1} → {count_2}" diff --git a/backend/tests/test_views_sap_analytics.py b/backend/tests/test_views_sap_analytics.py new file mode 100644 index 0000000..a1c9f9a --- /dev/null +++ b/backend/tests/test_views_sap_analytics.py @@ -0,0 +1,257 @@ +"""Testes de integração para as views analíticas do schema sap_analytics. + +Cobertura dos critérios de aceite da Sprint 1.3: + 1. vw_ut_enriquecida contém os campos obrigatórios + 2. vw_atividade_enriquecida contém os campos obrigatórios + 3. is_finalizada é True somente quando tipo_situacao_id = 4 + 4. vw_ut_atividade contém o join completo + 5. As views são recriadas automaticamente após sincronização + 6. Idempotência: garantir_views pode ser chamado N vezes sem erro +""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from cp.infrastructure.sap_sync.analytics_manager import garantir_views_analytics +from cp.infrastructure.sap_sync.sync import sincronizar_sap_para_snapshot + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SCHEMA = "sap_analytics" + + +def _colunas_da_view(engine: Engine, view: str) -> set[str]: + """Retorna o conjunto de nomes de colunas de uma view.""" + sql = text("SELECT column_name FROM information_schema.columns WHERE table_schema = :schema AND table_name = :view") + with engine.connect() as conn: + rows = conn.execute(sql, {"schema": _SCHEMA, "view": view}).fetchall() + return {r[0] for r in rows} + + +def _contar_view(engine: Engine, view: str) -> int: + with engine.connect() as conn: + return conn.execute(text(f"SELECT COUNT(*) FROM {_SCHEMA}.{view}")).scalar_one() + + +def _buscar_view(engine: Engine, view: str) -> list[dict]: + with engine.connect() as conn: + rows = conn.execute(text(f"SELECT * FROM {_SCHEMA}.{view}")).fetchall() + return [dict(r._mapping) for r in rows] + + +# --------------------------------------------------------------------------- +# Critério 1 — vw_ut_enriquecida: campos obrigatórios +# --------------------------------------------------------------------------- + +_CAMPOS_UT = { + "projeto_id", + "projeto_nome", + "lote_id", + "lote_nome", + "bloco_id", + "bloco_nome", + "subfase_id", + "subfase_nome", + "fase_id", + "fase_nome", + "ut_id", + "ut_nome", + "dificuldade", + "tempo_estimado_minutos", +} + + +def test_vw_ut_enriquecida_tem_campos_obrigatorios(engine_cp: Engine) -> None: + colunas = _colunas_da_view(engine_cp, "vw_ut_enriquecida") + ausentes = _CAMPOS_UT - colunas + assert not ausentes, f"Campos ausentes em vw_ut_enriquecida: {ausentes}" + + +# --------------------------------------------------------------------------- +# Critério 2 — vw_atividade_enriquecida: campos obrigatórios +# --------------------------------------------------------------------------- + +_CAMPOS_ATIVIDADE = { + "atividade_id", + "unidade_trabalho_id", + "etapa_id", + "tipo_etapa_id", + "tipo_etapa_nome", + "tipo_situacao_id", + "tipo_situacao_nome", + "usuario_id", + "usuario_nome", + "data_inicio", + "data_fim", + "observacao", + "is_finalizada", +} + + +def test_vw_atividade_enriquecida_tem_campos_obrigatorios(engine_cp: Engine) -> None: + colunas = _colunas_da_view(engine_cp, "vw_atividade_enriquecida") + ausentes = _CAMPOS_ATIVIDADE - colunas + assert not ausentes, f"Campos ausentes em vw_atividade_enriquecida: {ausentes}" + + +# --------------------------------------------------------------------------- +# Critério 3 — is_finalizada somente quando tipo_situacao_id = 4 +# --------------------------------------------------------------------------- + + +def test_is_finalizada_verdadeiro_somente_para_tipo_situacao_4(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + sql = text(f"SELECT tipo_situacao_id, is_finalizada FROM {_SCHEMA}.vw_atividade_enriquecida") + with engine_cp.connect() as conn: + rows = conn.execute(sql).fetchall() + + assert rows, "View não retornou registros após sync" + + for row in rows: + tipo_situacao_id = row[0] + is_finalizada = row[1] + if tipo_situacao_id == 4: + assert is_finalizada is True, f"tipo_situacao_id=4 deveria ter is_finalizada=True, mas foi {is_finalizada}" + else: + assert is_finalizada is False, f"tipo_situacao_id={tipo_situacao_id} deveria ter is_finalizada=False, mas foi {is_finalizada}" + + +def test_is_finalizada_false_para_situacoes_nao_4(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """Verifica que situações 1, 2, 3, 5 nunca geram is_finalizada=True.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + sql = text(f"SELECT COUNT(*) FROM {_SCHEMA}.vw_atividade_enriquecida WHERE tipo_situacao_id <> 4 AND is_finalizada = TRUE") + with engine_cp.connect() as conn: + count = conn.execute(sql).scalar_one() + + assert count == 0, f"{count} registros com tipo_situacao_id<>4 marcados como is_finalizada" + + +# --------------------------------------------------------------------------- +# Critério 4 — vw_ut_atividade: join completo +# --------------------------------------------------------------------------- + +_CAMPOS_UT_ATIVIDADE = { + "lote_id", + "lote_nome", + "bloco_id", + "bloco_nome", + "subfase_id", + "subfase_nome", + "ut_id", + "ut_nome", + "dificuldade", + "atividade_id", + "tipo_etapa_id", + "tipo_etapa_nome", + "tipo_situacao_id", + "tipo_situacao_nome", + "usuario_id", + "usuario_nome", + "observacao", + "is_finalizada", + "data_inicio", + "data_fim", +} + + +def test_vw_ut_atividade_tem_campos_obrigatorios(engine_cp: Engine) -> None: + colunas = _colunas_da_view(engine_cp, "vw_ut_atividade") + ausentes = _CAMPOS_UT_ATIVIDADE - colunas + assert not ausentes, f"Campos ausentes em vw_ut_atividade: {ausentes}" + + +def test_vw_ut_atividade_retorna_dados_apos_sync(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + count = _contar_view(engine_cp, "vw_ut_atividade") + assert count > 0, "vw_ut_atividade não retornou registros após sync" + + +def test_vw_ut_atividade_join_correto(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """Verifica que lote/bloco/subfase dos registros são consistentes com a UT.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + sql = text(f"SELECT COUNT(*) FROM {_SCHEMA}.vw_ut_atividade WHERE lote_id IS NULL OR bloco_id IS NULL OR subfase_id IS NULL") + with engine_cp.connect() as conn: + nulls = conn.execute(sql).scalar_one() + + assert nulls == 0, f"{nulls} registros com lote/bloco/subfase nulos em vw_ut_atividade" + + +# --------------------------------------------------------------------------- +# Critério 5 — Views existem e são consultáveis após sync (integração) +# --------------------------------------------------------------------------- + + +def test_todas_as_views_existem_apos_sync(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """Garante que o pipeline de sync cria/mantém as três views.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + views = ["vw_ut_enriquecida", "vw_atividade_enriquecida", "vw_ut_atividade"] + for view in views: + sql = text("SELECT 1 FROM information_schema.views WHERE table_schema = 'sap_analytics' AND table_name = :view") + with engine_cp.connect() as conn: + exists = conn.execute(sql, {"view": view}).scalar() + assert exists == 1, f"View {view} não encontrada após sync" + + +def test_vw_ut_enriquecida_retorna_dados_apos_sync(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + count = _contar_view(engine_cp, "vw_ut_enriquecida") + assert count > 0, "vw_ut_enriquecida não retornou registros após sync" + + +def test_vw_atividade_enriquecida_retorna_dados_apos_sync(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + count = _contar_view(engine_cp, "vw_atividade_enriquecida") + assert count > 0, "vw_atividade_enriquecida não retornou registros após sync" + + +# --------------------------------------------------------------------------- +# Critério 6 — Idempotência de garantir_views_analytics +# --------------------------------------------------------------------------- + + +def test_garantir_views_idempotente(engine_cp: Engine) -> None: + """garantir_views pode ser chamado múltiplas vezes sem erro nem duplicação.""" + for _ in range(3): + garantir_views_analytics(engine_cp) + + views = ["vw_ut_enriquecida", "vw_atividade_enriquecida", "vw_ut_atividade"] + for view in views: + sql = text("SELECT COUNT(*) FROM information_schema.views WHERE table_schema = 'sap_analytics' AND table_name = :view") + with engine_cp.connect() as conn: + count = conn.execute(sql, {"view": view}).scalar_one() + assert count == 1, f"View {view} deveria existir exatamente 1 vez, encontrou {count}" + + +# --------------------------------------------------------------------------- +# Integridade dos dados — sanidade cruzada +# --------------------------------------------------------------------------- + + +def test_usuario_nulo_permitido_em_atividade_enriquecida(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """Atividades sem usuário (tipo_situacao_id=1, não iniciada) não devem ser excluídas.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + sql = text(f"SELECT COUNT(*) FROM {_SCHEMA}.vw_atividade_enriquecida WHERE usuario_id IS NULL") + with engine_cp.connect() as conn: + sem_usuario = conn.execute(sql).scalar_one() + + assert sem_usuario > 0, "Esperavam-se atividades sem usuário (não iniciadas) — seed contém registros assim" + + +def test_tipo_situacao_nome_preenchido(engine_sap: Engine, engine_cp: Engine, sap_seed: None) -> None: + """tipo_situacao_nome nunca deve ser nulo nas atividades enriquecidas.""" + sincronizar_sap_para_snapshot(engine_sap, engine_cp) + + sql = text(f"SELECT COUNT(*) FROM {_SCHEMA}.vw_atividade_enriquecida WHERE tipo_situacao_nome IS NULL") + with engine_cp.connect() as conn: + nulos = conn.execute(sql).scalar_one() + + assert nulos == 0, f"{nulos} registros com tipo_situacao_nome nulo" From a779fdb3bf7ce65203143a81fcaffc1f0f2c34fb Mon Sep 17 00:00:00 2001 From: Estevez Codando Date: Fri, 6 Mar 2026 10:21:34 -0300 Subject: [PATCH 2/2] Kpi --- .../versions/0004_kpi_fato_ut_subfase.py | 27 + backend/src/cp/cli/bootstrap_db.py | 2 + .../sap_sync/analytics_views.py | 13 + .../cp/infrastructure/sap_sync/kpi_manager.py | 46 ++ .../cp/infrastructure/sap_sync/kpi_views.py | 763 ++++++++++++++++++ .../src/cp/infrastructure/sap_sync/sync.py | 2 + backend/tests/cli/test_bootstrap_db.py | 14 +- 7 files changed, 865 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0004_kpi_fato_ut_subfase.py create mode 100644 backend/src/cp/infrastructure/sap_sync/kpi_manager.py create mode 100644 backend/src/cp/infrastructure/sap_sync/kpi_views.py diff --git a/backend/alembic/versions/0004_kpi_fato_ut_subfase.py b/backend/alembic/versions/0004_kpi_fato_ut_subfase.py new file mode 100644 index 0000000..f4c4bbb --- /dev/null +++ b/backend/alembic/versions/0004_kpi_fato_ut_subfase.py @@ -0,0 +1,27 @@ +"""Cria tabela kpi.fato_ut_subfase para materialização de notas e estado por UT/subfase. + +Revision ID: 0004 +Revises: 0003 +Create Date: 2025-01-03 + +A tabela é populada a cada sync SAP via TRUNCATE + INSERT INTO ... SELECT. +Chave primária natural: (ut_id, subfase_id). +""" + +from __future__ import annotations + +from alembic import op +from cp.infrastructure.sap_sync.kpi_views import DDL_TABELA_FATO_UT_SUBFASE + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(DDL_TABELA_FATO_UT_SUBFASE) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS kpi.fato_ut_subfase;") diff --git a/backend/src/cp/cli/bootstrap_db.py b/backend/src/cp/cli/bootstrap_db.py index b7d9a0b..9bb7a4e 100644 --- a/backend/src/cp/cli/bootstrap_db.py +++ b/backend/src/cp/cli/bootstrap_db.py @@ -8,6 +8,7 @@ from sqlalchemy.engine import Engine from cp.infrastructure.sap_sync.analytics_manager import garantir_views_analytics +from cp.infrastructure.sap_sync.kpi_manager import garantir_fato_ut_subfase _SCHEMAS_CP: tuple[str, ...] = ( "sap_snapshot", @@ -95,5 +96,6 @@ def criar_banco_cp( engine_cp = create_engine(dsn_cp, future=True) _criar_schemas_cp(engine_cp) garantir_views_analytics(engine_cp) + garantir_fato_ut_subfase(engine_cp) return criado_agora diff --git a/backend/src/cp/infrastructure/sap_sync/analytics_views.py b/backend/src/cp/infrastructure/sap_sync/analytics_views.py index 06256e6..dbd215a 100644 --- a/backend/src/cp/infrastructure/sap_sync/analytics_views.py +++ b/backend/src/cp/infrastructure/sap_sync/analytics_views.py @@ -234,6 +234,17 @@ NULL ) AS situacoes_pendentes, + -- Diagnóstico: concatena todas as observações lançadas (ordem estável) + STRING_AGG( + CASE + WHEN atv.observacao IS NOT NULL AND BTRIM(atv.observacao) <> '' + THEN te.nome || ': ' || ts.nome || ' -> ' || + REGEXP_REPLACE(BTRIM(atv.observacao), '\\s+', ' ', 'g') + END, + ' | ' + ORDER BY te.nome, atv.id + ) AS observacoes_concatenadas, + -- Conclusão real: nenhuma atividade pendente BOOL_AND(atv.tipo_situacao_id IN (4, 5)) AS concluida @@ -264,6 +275,8 @@ agg.total_atividades, agg.total_concluidas, agg.total_pendentes, + -- Diagnostico das notas: + agg.observacoes_concatenadas, -- Diagnóstico completo: todas as etapas com sua situação agg.etapas_situacao, diff --git a/backend/src/cp/infrastructure/sap_sync/kpi_manager.py b/backend/src/cp/infrastructure/sap_sync/kpi_manager.py new file mode 100644 index 0000000..d224b13 --- /dev/null +++ b/backend/src/cp/infrastructure/sap_sync/kpi_manager.py @@ -0,0 +1,46 @@ +"""Gerenciador da tabela materializada kpi.fato_ut_subfase. + +A cada sync SAP → sap_snapshot, a tabela é recalculada por completo: +TRUNCATE + INSERT INTO ... SELECT. + +Isso garante que o estado da tabela reflita exatamente o snapshot atual, +sem acúmulo de linhas obsoletas e sem lógica de diff. + +Também expõe garantir_fato_ut_subfase(engine) para uso no bootstrap, +que cria a tabela se ainda não existir (idempotente). +""" + +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.engine import Connection, Engine + +from cp.infrastructure.sap_sync.kpi_views import ( + DDL_TABELA_FATO_UT_SUBFASE, + SQL_SELECT_FATO_UT_SUBFASE, +) + +_TABELA = "kpi.fato_ut_subfase" + + +def garantir_fato_ut_subfase(engine_cp: Engine) -> None: + """Cria a tabela kpi.fato_ut_subfase se não existir (idempotente). + + Chamado no bootstrap do banco — garante que a estrutura existe antes + do primeiro sync. + """ + with engine_cp.begin() as conn: + conn.execute(text(DDL_TABELA_FATO_UT_SUBFASE)) + + +def materializar_fato_ut_subfase(conn_cp: Connection) -> int: + """Recalcula kpi.fato_ut_subfase via TRUNCATE + INSERT INTO ... SELECT. + + Chamado no final do pipeline de sync, dentro da mesma transação dos + snapshots — se o sync falhar, a tabela não é alterada. + + Retorna o número de linhas inseridas. + """ + conn_cp.execute(text(f"TRUNCATE {_TABELA}")) + result = conn_cp.execute(text(f"INSERT INTO {_TABELA}\n{SQL_SELECT_FATO_UT_SUBFASE}")) + return int(result.rowcount or 0) diff --git a/backend/src/cp/infrastructure/sap_sync/kpi_views.py b/backend/src/cp/infrastructure/sap_sync/kpi_views.py new file mode 100644 index 0000000..567ed1d --- /dev/null +++ b/backend/src/cp/infrastructure/sap_sync/kpi_views.py @@ -0,0 +1,763 @@ +"""DDL e SQL de materialização da tabela kpi.fato_ut_subfase. + +Responsabilidade única: definir o schema da tabela e o SELECT de carga. +A orquestração (TRUNCATE + INSERT) é responsabilidade do kpi_manager. +""" + +from __future__ import annotations + +_SCHEMA_KPI = "kpi" +_SCHEMA_SNAPSHOT = "sap_snapshot" + +# --------------------------------------------------------------------------- +# DDL da tabela — criada pela migration 0004 +# --------------------------------------------------------------------------- + +DDL_TABELA_FATO_UT_SUBFASE = f""" +CREATE TABLE IF NOT EXISTS {_SCHEMA_KPI}.fato_ut_subfase ( + -- Hierarquia + projeto_id integer NOT NULL, + projeto_nome text NOT NULL, + lote_id integer NOT NULL, + lote_nome text NOT NULL, + bloco_id integer NOT NULL, + bloco_nome text NOT NULL, + fase_id integer NOT NULL, + fase_nome text NOT NULL, + subfase_id integer NOT NULL, + subfase_nome text NOT NULL, + ut_id integer NOT NULL, + ut_nome text NOT NULL, + + -- Atributos da UT + ut_disponivel boolean, + ut_dificuldade numeric, + + -- Histórico agregado + total_atividades integer, + total_finalizada_ou_nao_finalizada integer, + total_finalizadas integer, + total_nao_finalizadas integer, + total_pendentes integer, + possui_nao_finalizada_no_historico boolean, + somente_finalizada_ou_nao_finalizada boolean, + observacoes_concatenadas text, + + -- Execução + exec_atividade_id integer, + usuario_executor_id integer, + usuario_executor_nome text, + usuario_executor_exibicao text, + executor_tipo_situacao_id integer, + executor_tipo_situacao_nome text, + + -- Revisão (vigente — pode ser tipo 2 ou tipo 5 dependendo do ciclo) + rev_atividade_id integer, + usuario_revisor_id integer, + usuario_revisor_nome text, + usuario_revisor_exibicao text, + revisao_vigente_tipo_situacao_id integer, + revisao_vigente_tipo_situacao_nome text, + + -- Correção (somente CICLO_1_PADRAO) + cor_atividade_id integer, + usuario_corretor_id integer, + usuario_corretor_nome text, + usuario_corretor_exibicao text, + corretor_tipo_situacao_id integer, + corretor_tipo_situacao_nome text, + + -- Nota e classificação + nota_qualidade integer, + texto_qualidade text, + ciclo_modelo text NOT NULL, + estado_ut_subfase text NOT NULL, + concluida boolean NOT NULL, + + -- Percentuais calculados + percentual_producao_revisor numeric(10, 6), + percentual_producao_executor numeric(10, 6), + + -- Pontos calculados + pontos_executor numeric(10, 4), + pontos_revisor numeric(10, 4), + pontos_corretor numeric(10, 4), + + -- Chave primária natural + PRIMARY KEY (ut_id, subfase_id) +); +""".strip() + +# --------------------------------------------------------------------------- +# SELECT de carga — usado no INSERT INTO ... SELECT +# +# Ciclos suportados: +# CICLO_1_PADRAO : Exec(4) → Rev(4) → Cor(4) nota na Cor +# CICLO_2_REVISAO_CORRECAO : Exec(4) → RevCor(4) nota na RevCor +# CICLO_3_SEM_CORRECAO : Exec(4) → Rev(4) sem nota +# CICLO_4_REVISAO_FINAL : Exec(4) → [Rev(4) →] [Cor(4) →] RevFinal(4) +# tipo_etapa_id=5, sem nota, 40/60 fixo +# +# Variantes x.1/x.2/x.3 (Não Finalizada intermediária) são capturadas por +# possui_nao_finalizada_no_historico e somente_finalizada_ou_nao_finalizada. +# --------------------------------------------------------------------------- + +SQL_SELECT_FATO_UT_SUBFASE = f""" +WITH ut_base AS ( + SELECT + ut.id AS ut_id, + ut.nome AS ut_nome, + ut.lote_id, + ut.bloco_id, + ut.subfase_id, + ut.dificuldade AS ut_dificuldade, + ut.disponivel AS ut_disponivel, + lote.nome AS lote_nome, + bloco.nome AS bloco_nome, + sf.nome AS subfase_nome, + fase.id AS fase_id, + tf.nome AS fase_nome, + proj.id AS projeto_id, + proj.nome AS projeto_nome + FROM {_SCHEMA_SNAPSHOT}.macrocontrole_unidade_trabalho ut + JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_lote lote + ON lote.id = ut.lote_id + JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_bloco bloco + ON bloco.id = ut.bloco_id + JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_subfase sf + ON sf.id = ut.subfase_id + JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_fase fase + ON fase.id = sf.fase_id + JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_fase tf + ON tf.code = fase.tipo_fase_id + JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_projeto proj + ON proj.id = lote.projeto_id +), +etapa_com_min_ordem AS ( + -- Pré-calcula min_ordem por partição para evitar window function dentro de FILTER + -- (PostgreSQL não permite OVER dentro de FILTER) + SELECT + e.id AS etapa_id, + e.lote_id, + e.subfase_id, + e.tipo_etapa_id, + e.ordem, + MIN(e.ordem) OVER ( + PARTITION BY e.lote_id, e.subfase_id, e.tipo_etapa_id + ) AS min_ordem + FROM {_SCHEMA_SNAPSHOT}.macrocontrole_etapa e +), +etapa_ordenada AS ( + SELECT + emo.etapa_id, + emo.lote_id, + emo.subfase_id, + emo.tipo_etapa_id, + emo.ordem, + ROW_NUMBER() OVER ( + PARTITION BY emo.lote_id, emo.subfase_id, emo.tipo_etapa_id + ORDER BY emo.ordem, emo.etapa_id + ) AS rn, + COUNT(*) FILTER ( + WHERE emo.ordem = emo.min_ordem + ) OVER ( + PARTITION BY emo.lote_id, emo.subfase_id, emo.tipo_etapa_id + ) AS qtd_menor_ordem + FROM etapa_com_min_ordem emo +), +etapa_canonica AS ( + -- Etapa canônica por tipo: menor ordem para o par (lote_id, subfase_id). + -- Se múltiplas etapas empatam na menor ordem, a UT é marcada ambígua. + SELECT + eo.lote_id, + eo.subfase_id, + eo.tipo_etapa_id, + eo.etapa_id, + (eo.qtd_menor_ordem > 1) AS etapa_canonica_ambigua + FROM etapa_ordenada eo + WHERE eo.rn = 1 +), +atividade_historico AS ( + SELECT + atv.id AS atividade_id, + atv.unidade_trabalho_id AS ut_id, + ub.subfase_id, + ub.lote_id, + ec.tipo_etapa_id, + atv.etapa_id, + atv.usuario_id, + atv.tipo_situacao_id, + ts.nome AS tipo_situacao_nome, + te.nome AS tipo_etapa_nome, + atv.data_inicio, + atv.data_fim, + atv.observacao, + ec.etapa_canonica_ambigua + FROM {_SCHEMA_SNAPSHOT}.macrocontrole_atividade atv + JOIN ut_base ub + ON ub.ut_id = atv.unidade_trabalho_id + JOIN etapa_canonica ec + ON ec.etapa_id = atv.etapa_id + AND ec.lote_id = ub.lote_id + AND ec.subfase_id = ub.subfase_id + JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_situacao ts + ON ts.code = atv.tipo_situacao_id + JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_etapa te + ON te.code = ec.tipo_etapa_id +), +atividade_vigente AS ( + -- Atividade vigente por (ut_id, subfase_id, tipo_etapa_id): maior id. + SELECT * + FROM ( + SELECT + ah.*, + ROW_NUMBER() OVER ( + PARTITION BY ah.ut_id, ah.subfase_id, ah.tipo_etapa_id + ORDER BY ah.atividade_id DESC + ) AS rn + FROM atividade_historico ah + ) x + WHERE x.rn = 1 +), +historico_ut_subfase AS ( + SELECT + atv.unidade_trabalho_id AS ut_id, + etapa.subfase_id, + COUNT(*) AS total_atividades, + COUNT(*) FILTER (WHERE atv.tipo_situacao_id IN (4, 5)) AS total_finalizada_ou_nao_finalizada, + COUNT(*) FILTER (WHERE atv.tipo_situacao_id = 4) AS total_finalizadas, + COUNT(*) FILTER (WHERE atv.tipo_situacao_id = 5) AS total_nao_finalizadas, + COUNT(*) FILTER (WHERE atv.tipo_situacao_id IN (1, 2, 3)) AS total_pendentes, + BOOL_OR(atv.tipo_situacao_id = 5) AS possui_nao_finalizada_no_historico, + -- TRUE quando todas as atividades são Finalizada(4) ou Não finalizada(5): + -- sinal de que não há mais trabalho em andamento nesta UT/subfase. + BOOL_AND(atv.tipo_situacao_id IN (4, 5)) AS somente_finalizada_ou_nao_finalizada, + STRING_AGG( + CASE + WHEN atv.observacao IS NOT NULL + AND BTRIM(atv.observacao) <> '' + THEN te.nome || ': ' || ts.nome || ' -> ' || + REGEXP_REPLACE(BTRIM(atv.observacao), '\\s+', ' ', 'g') + END, + ' | ' + ORDER BY te.nome, atv.id + ) AS observacoes_concatenadas + FROM {_SCHEMA_SNAPSHOT}.macrocontrole_atividade atv + JOIN {_SCHEMA_SNAPSHOT}.macrocontrole_etapa etapa + ON etapa.id = atv.etapa_id + JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_etapa te + ON te.code = etapa.tipo_etapa_id + JOIN {_SCHEMA_SNAPSHOT}.dominio_tipo_situacao ts + ON ts.code = atv.tipo_situacao_id + GROUP BY atv.unidade_trabalho_id, etapa.subfase_id +), +pivot_vigente AS ( + -- Transpõe atividade_vigente: uma linha por (ut_id, subfase_id) com + -- colunas separadas por tipo_etapa_id (1=Exec, 2=Rev, 3=Cor, 4=RevCor, 5=RevFinal). + SELECT + av.ut_id, + av.subfase_id, + + MAX(CASE WHEN av.tipo_etapa_id = 1 THEN av.atividade_id END) AS exec_atividade_id, + MAX(CASE WHEN av.tipo_etapa_id = 1 THEN av.usuario_id END) AS exec_usuario_id, + MAX(CASE WHEN av.tipo_etapa_id = 1 THEN av.tipo_situacao_id END) AS exec_tipo_situacao_id, + MAX(CASE WHEN av.tipo_etapa_id = 1 THEN av.tipo_situacao_nome END) AS exec_tipo_situacao_nome, + MAX(CASE WHEN av.tipo_etapa_id = 1 THEN av.observacao END) AS exec_observacao, + BOOL_OR(CASE WHEN av.tipo_etapa_id = 1 THEN av.etapa_canonica_ambigua ELSE FALSE END) AS exec_ambigua, + + MAX(CASE WHEN av.tipo_etapa_id = 2 THEN av.atividade_id END) AS rev_atividade_id, + MAX(CASE WHEN av.tipo_etapa_id = 2 THEN av.usuario_id END) AS rev_usuario_id, + MAX(CASE WHEN av.tipo_etapa_id = 2 THEN av.tipo_situacao_id END) AS rev_tipo_situacao_id, + MAX(CASE WHEN av.tipo_etapa_id = 2 THEN av.tipo_situacao_nome END) AS rev_tipo_situacao_nome, + MAX(CASE WHEN av.tipo_etapa_id = 2 THEN av.observacao END) AS rev_observacao, + BOOL_OR(CASE WHEN av.tipo_etapa_id = 2 THEN av.etapa_canonica_ambigua ELSE FALSE END) AS rev_ambigua, + + MAX(CASE WHEN av.tipo_etapa_id = 3 THEN av.atividade_id END) AS cor_atividade_id, + MAX(CASE WHEN av.tipo_etapa_id = 3 THEN av.usuario_id END) AS cor_usuario_id, + MAX(CASE WHEN av.tipo_etapa_id = 3 THEN av.tipo_situacao_id END) AS cor_tipo_situacao_id, + MAX(CASE WHEN av.tipo_etapa_id = 3 THEN av.tipo_situacao_nome END) AS cor_tipo_situacao_nome, + MAX(CASE WHEN av.tipo_etapa_id = 3 THEN av.observacao END) AS cor_observacao, + BOOL_OR(CASE WHEN av.tipo_etapa_id = 3 THEN av.etapa_canonica_ambigua ELSE FALSE END) AS cor_ambigua, + + MAX(CASE WHEN av.tipo_etapa_id = 4 THEN av.atividade_id END) AS revcor_atividade_id, + MAX(CASE WHEN av.tipo_etapa_id = 4 THEN av.usuario_id END) AS revcor_usuario_id, + MAX(CASE WHEN av.tipo_etapa_id = 4 THEN av.tipo_situacao_id END) AS revcor_tipo_situacao_id, + MAX(CASE WHEN av.tipo_etapa_id = 4 THEN av.tipo_situacao_nome END) AS revcor_tipo_situacao_nome, + MAX(CASE WHEN av.tipo_etapa_id = 4 THEN av.observacao END) AS revcor_observacao, + BOOL_OR(CASE WHEN av.tipo_etapa_id = 4 THEN av.etapa_canonica_ambigua ELSE FALSE END) AS revcor_ambigua, + + MAX(CASE WHEN av.tipo_etapa_id = 5 THEN av.atividade_id END) AS revfinal_atividade_id, + MAX(CASE WHEN av.tipo_etapa_id = 5 THEN av.usuario_id END) AS revfinal_usuario_id, + MAX(CASE WHEN av.tipo_etapa_id = 5 THEN av.tipo_situacao_id END) AS revfinal_tipo_situacao_id, + MAX(CASE WHEN av.tipo_etapa_id = 5 THEN av.tipo_situacao_nome END) AS revfinal_tipo_situacao_nome, + MAX(CASE WHEN av.tipo_etapa_id = 5 THEN av.observacao END) AS revfinal_observacao, + BOOL_OR(CASE WHEN av.tipo_etapa_id = 5 THEN av.etapa_canonica_ambigua ELSE FALSE END) AS revfinal_ambigua + + FROM atividade_vigente av + GROUP BY av.ut_id, av.subfase_id +), +nota_correcao AS ( + SELECT + pv.ut_id, + pv.subfase_id, + p.nota AS nota_correcao, + p.texto AS texto_nota_correcao + FROM pivot_vigente pv + LEFT JOIN LATERAL ( + SELECT + (m)[1]::integer AS nota, + BTRIM((m)[2]) AS texto + FROM ( + SELECT + REGEXP_MATCHES(BTRIM(seg.segmento), '^([1-9])\\s*;\\s*(.+)$') AS m, + seg.ord + FROM UNNEST(STRING_TO_ARRAY(COALESCE(pv.cor_observacao, ''), '|')) + WITH ORDINALITY AS seg(segmento, ord) + ) x + WHERE x.m IS NOT NULL + ORDER BY x.ord + LIMIT 1 + ) p ON TRUE +), +nota_revisao_correcao AS ( + SELECT + pv.ut_id, + pv.subfase_id, + p.nota AS nota_revisao_correcao, + p.texto AS texto_nota_revisao_correcao + FROM pivot_vigente pv + LEFT JOIN LATERAL ( + SELECT + (m)[1]::integer AS nota, + BTRIM((m)[2]) AS texto + FROM ( + SELECT + REGEXP_MATCHES(BTRIM(seg.segmento), '^([1-9])\\s*;\\s*(.+)$') AS m, + seg.ord + FROM UNNEST(STRING_TO_ARRAY(COALESCE(pv.revcor_observacao, ''), '|')) + WITH ORDINALITY AS seg(segmento, ord) + ) x + WHERE x.m IS NOT NULL + ORDER BY x.ord + LIMIT 1 + ) p ON TRUE +), +usuarios AS ( + SELECT + u.id AS usuario_id, + u.nome AS usuario_nome + FROM {_SCHEMA_SNAPSHOT}.dgeo_usuario u +), +consolidado_base AS ( + SELECT + ub.projeto_id, + ub.projeto_nome, + ub.lote_id, + ub.lote_nome, + ub.bloco_id, + ub.bloco_nome, + ub.fase_id, + ub.fase_nome, + ub.subfase_id, + ub.subfase_nome, + ub.ut_id, + ub.ut_nome, + ub.ut_disponivel, + ub.ut_dificuldade, + + hs.total_atividades, + hs.total_finalizada_ou_nao_finalizada, + hs.total_finalizadas, + hs.total_nao_finalizadas, + hs.total_pendentes, + hs.possui_nao_finalizada_no_historico, + hs.somente_finalizada_ou_nao_finalizada, + hs.observacoes_concatenadas, + + pv.exec_atividade_id, + pv.exec_usuario_id, + ue.usuario_nome AS usuario_executor_nome, + pv.exec_tipo_situacao_id, + pv.exec_tipo_situacao_nome, + + pv.rev_atividade_id, + pv.rev_usuario_id, + ur.usuario_nome AS usuario_revisor_nome_revisao, + pv.rev_tipo_situacao_id, + pv.rev_tipo_situacao_nome, + + pv.cor_atividade_id, + pv.cor_usuario_id, + uc.usuario_nome AS usuario_corretor_nome, + pv.cor_tipo_situacao_id, + pv.cor_tipo_situacao_nome, + + pv.revcor_atividade_id, + pv.revcor_usuario_id, + urc.usuario_nome AS usuario_revisor_nome_revisao_correcao, + pv.revcor_tipo_situacao_id, + pv.revcor_tipo_situacao_nome, + + pv.revfinal_atividade_id, + pv.revfinal_usuario_id, + urf.usuario_nome AS usuario_revisor_final_nome, + pv.revfinal_tipo_situacao_id, + pv.revfinal_tipo_situacao_nome, + + nc.nota_correcao, + nc.texto_nota_correcao, + nrc.nota_revisao_correcao, + nrc.texto_nota_revisao_correcao, + + CASE + WHEN COALESCE(pv.exec_ambigua, FALSE) + OR COALESCE(pv.rev_ambigua, FALSE) + OR COALESCE(pv.cor_ambigua, FALSE) + OR COALESCE(pv.revcor_ambigua, FALSE) + OR COALESCE(pv.revfinal_ambigua, FALSE) + THEN 'INCONSISTENTE_CICLO' + -- Ciclo 1: Exec + Rev + Cor, sem RevCor e sem RevFinal + WHEN pv.exec_atividade_id IS NOT NULL + AND pv.rev_atividade_id IS NOT NULL + AND pv.cor_atividade_id IS NOT NULL + AND pv.revcor_atividade_id IS NULL + AND pv.revfinal_atividade_id IS NULL + THEN 'CICLO_1_PADRAO' + -- Ciclo 2: Exec + RevCor, sem Rev e sem Cor e sem RevFinal + WHEN pv.exec_atividade_id IS NOT NULL + AND pv.rev_atividade_id IS NULL + AND pv.cor_atividade_id IS NULL + AND pv.revcor_atividade_id IS NOT NULL + AND pv.revfinal_atividade_id IS NULL + THEN 'CICLO_2_REVISAO_CORRECAO' + -- Ciclo 3: Exec + Rev, sem Cor e sem RevCor e sem RevFinal + WHEN pv.exec_atividade_id IS NOT NULL + AND pv.rev_atividade_id IS NOT NULL + AND pv.cor_atividade_id IS NULL + AND pv.revcor_atividade_id IS NULL + AND pv.revfinal_atividade_id IS NULL + THEN 'CICLO_3_SEM_CORRECAO' + -- Ciclo 4: Exec + RevFinal (com ou sem Rev e/ou Cor intermediários) + WHEN pv.exec_atividade_id IS NOT NULL + AND pv.revcor_atividade_id IS NULL + AND pv.revfinal_atividade_id IS NOT NULL + THEN 'CICLO_4_REVISAO_FINAL' + ELSE 'INCONSISTENTE_CICLO' + END AS ciclo_modelo + FROM ut_base ub + LEFT JOIN historico_ut_subfase hs + ON hs.ut_id = ub.ut_id + AND hs.subfase_id = ub.subfase_id + LEFT JOIN pivot_vigente pv + ON pv.ut_id = ub.ut_id + AND pv.subfase_id = ub.subfase_id + LEFT JOIN nota_correcao nc + ON nc.ut_id = ub.ut_id + AND nc.subfase_id = ub.subfase_id + LEFT JOIN nota_revisao_correcao nrc + ON nrc.ut_id = ub.ut_id + AND nrc.subfase_id = ub.subfase_id + LEFT JOIN usuarios ue ON ue.usuario_id = pv.exec_usuario_id + LEFT JOIN usuarios ur ON ur.usuario_id = pv.rev_usuario_id + LEFT JOIN usuarios uc ON uc.usuario_id = pv.cor_usuario_id + LEFT JOIN usuarios urc ON urc.usuario_id = pv.revcor_usuario_id + LEFT JOIN usuarios urf ON urf.usuario_id = pv.revfinal_usuario_id +), +consolidado_regras AS ( + SELECT + cb.*, + + -- nota_qualidade: nota de 1-9 extraída da observação da etapa revisora + -- (Correção no ciclo 1, RevCor no ciclo 2; ciclos 3 e 4 não têm nota) + CASE + WHEN cb.ciclo_modelo = 'CICLO_1_PADRAO' THEN cb.nota_correcao + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' THEN cb.nota_revisao_correcao + ELSE NULL + END AS nota_qualidade, + + CASE + WHEN cb.ciclo_modelo = 'CICLO_1_PADRAO' THEN cb.texto_nota_correcao + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' THEN cb.texto_nota_revisao_correcao + ELSE NULL + END AS texto_qualidade, + + -- usuario_revisor_id / nome: quem fez a revisão final de cada ciclo + CASE + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' THEN cb.revcor_usuario_id + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' THEN cb.revfinal_usuario_id + ELSE cb.rev_usuario_id + END AS usuario_revisor_id, + + CASE + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' THEN cb.usuario_revisor_nome_revisao_correcao + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' THEN cb.usuario_revisor_final_nome + ELSE cb.usuario_revisor_nome_revisao + END AS usuario_revisor_nome, + + CASE + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' THEN cb.revcor_tipo_situacao_id + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' THEN cb.revfinal_tipo_situacao_id + ELSE cb.rev_tipo_situacao_id + END AS revisao_vigente_tipo_situacao_id, + + CASE + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' THEN cb.revcor_tipo_situacao_nome + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' THEN cb.revfinal_tipo_situacao_nome + ELSE cb.rev_tipo_situacao_nome + END AS revisao_vigente_tipo_situacao_nome, + + -- concluida: todas as etapas do ciclo finalizadas (id=4) + -- + confirmação via somente_finalizada_ou_nao_finalizada (sem trabalho em andamento) + CASE + WHEN cb.ciclo_modelo = 'CICLO_1_PADRAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.rev_tipo_situacao_id = 4 + AND cb.cor_tipo_situacao_id = 4 + AND cb.nota_correcao BETWEEN 1 AND 9 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + THEN TRUE + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.revcor_tipo_situacao_id = 4 + AND cb.nota_revisao_correcao BETWEEN 1 AND 9 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + THEN TRUE + WHEN cb.ciclo_modelo = 'CICLO_3_SEM_CORRECAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.rev_tipo_situacao_id = 4 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + THEN TRUE + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' + AND cb.exec_tipo_situacao_id = 4 + AND cb.revfinal_tipo_situacao_id = 4 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + THEN TRUE + ELSE FALSE + END AS concluida, + + CASE + WHEN cb.ut_disponivel IS FALSE + THEN 'UT BLOQUEADA' + WHEN COALESCE(cb.ut_dificuldade, 0) = 0 + THEN 'INCONSISTENTE_DIFICULDADE' + WHEN cb.ciclo_modelo = 'INCONSISTENTE_CICLO' + THEN 'INCONSISTENTE_CICLO' + WHEN COALESCE(cb.exec_tipo_situacao_id, 0) <> 4 + THEN 'PENDENTE_EXECUCAO' + -- Pendente revisão por ciclo + WHEN cb.ciclo_modelo IN ('CICLO_1_PADRAO', 'CICLO_3_SEM_CORRECAO') + AND COALESCE(cb.rev_tipo_situacao_id, 0) <> 4 + THEN 'PENDENTE_REVISAO' + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' + AND COALESCE(cb.revcor_tipo_situacao_id, 0) <> 4 + THEN 'PENDENTE_REVISAO' + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' + AND COALESCE(cb.revfinal_tipo_situacao_id, 0) <> 4 + THEN 'PENDENTE_REVISAO' + -- Pendente correção (somente ciclo 1) + WHEN cb.ciclo_modelo = 'CICLO_1_PADRAO' + AND cb.rev_tipo_situacao_id = 4 + AND COALESCE(cb.cor_tipo_situacao_id, 0) <> 4 + THEN 'PENDENTE_CORRECAO' + -- Nota inválida (ciclos com nota obrigatória) + WHEN cb.ciclo_modelo = 'CICLO_1_PADRAO' + AND cb.cor_tipo_situacao_id = 4 + AND NOT (cb.nota_correcao BETWEEN 1 AND 9) + THEN 'INCONSISTENTE_NOTA' + WHEN cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' + AND cb.revcor_tipo_situacao_id = 4 + AND NOT (cb.nota_revisao_correcao BETWEEN 1 AND 9) + THEN 'INCONSISTENTE_NOTA' + -- Concluídas com Não Finalizada no histórico + WHEN cb.ciclo_modelo = 'CICLO_3_SEM_CORRECAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.rev_tipo_situacao_id = 4 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + AND COALESCE(cb.possui_nao_finalizada_no_historico, FALSE) + THEN 'CONCLUIDA_COM_N_Finalizada' + WHEN cb.ciclo_modelo = 'CICLO_3_SEM_CORRECAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.rev_tipo_situacao_id = 4 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + THEN 'CONCLUIDA_SEM_CORRECAO' + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' + AND cb.exec_tipo_situacao_id = 4 + AND cb.revfinal_tipo_situacao_id = 4 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + AND COALESCE(cb.possui_nao_finalizada_no_historico, FALSE) + THEN 'CONCLUIDA_COM_N_Finalizada' + WHEN cb.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' + AND cb.exec_tipo_situacao_id = 4 + AND cb.revfinal_tipo_situacao_id = 4 + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + THEN 'CONCLUIDA_SEM_CORRECAO' + WHEN cb.ciclo_modelo IN ('CICLO_1_PADRAO', 'CICLO_2_REVISAO_CORRECAO') + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + AND ( + (cb.ciclo_modelo = 'CICLO_1_PADRAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.rev_tipo_situacao_id = 4 + AND cb.cor_tipo_situacao_id = 4 + AND cb.nota_correcao BETWEEN 1 AND 9) + OR (cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.revcor_tipo_situacao_id = 4 + AND cb.nota_revisao_correcao BETWEEN 1 AND 9) + ) + AND COALESCE(cb.possui_nao_finalizada_no_historico, FALSE) + THEN 'CONCLUIDA_COM_N_Finalizada' + WHEN cb.ciclo_modelo IN ('CICLO_1_PADRAO', 'CICLO_2_REVISAO_CORRECAO') + AND COALESCE(cb.somente_finalizada_ou_nao_finalizada, FALSE) + AND ( + (cb.ciclo_modelo = 'CICLO_1_PADRAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.rev_tipo_situacao_id = 4 + AND cb.cor_tipo_situacao_id = 4 + AND cb.nota_correcao BETWEEN 1 AND 9) + OR (cb.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' + AND cb.exec_tipo_situacao_id = 4 + AND cb.revcor_tipo_situacao_id = 4 + AND cb.nota_revisao_correcao BETWEEN 1 AND 9) + ) + THEN 'CONCLUIDA_COM_CORRECAO' + ELSE 'INCONSISTENTE_CICLO' + END AS estado_ut_subfase + FROM consolidado_base cb +) +SELECT + cr.projeto_id, + cr.projeto_nome, + cr.lote_id, + cr.lote_nome, + cr.bloco_id, + cr.bloco_nome, + cr.fase_id, + cr.fase_nome, + cr.subfase_id, + cr.subfase_nome, + cr.ut_id, + cr.ut_nome, + + cr.ut_disponivel, + cr.ut_dificuldade, + + cr.total_atividades, + cr.total_finalizada_ou_nao_finalizada, + cr.total_finalizadas, + cr.total_nao_finalizadas, + cr.total_pendentes, + cr.possui_nao_finalizada_no_historico, + cr.somente_finalizada_ou_nao_finalizada, + cr.observacoes_concatenadas, + + cr.exec_atividade_id, + cr.exec_usuario_id AS usuario_executor_id, + cr.usuario_executor_nome, + CASE + WHEN cr.exec_atividade_id IS NULL + OR cr.exec_tipo_situacao_id = 1 THEN 'Não iniciada' + ELSE COALESCE(cr.usuario_executor_nome, 'Sem usuário') + END AS usuario_executor_exibicao, + cr.exec_tipo_situacao_id AS executor_tipo_situacao_id, + cr.exec_tipo_situacao_nome AS executor_tipo_situacao_nome, + + cr.rev_atividade_id, + cr.usuario_revisor_id, + cr.usuario_revisor_nome, + CASE + WHEN cr.ciclo_modelo = 'CICLO_2_REVISAO_CORRECAO' + AND (cr.revcor_atividade_id IS NULL OR cr.revisao_vigente_tipo_situacao_id = 1) + THEN 'Não iniciada' + WHEN cr.ciclo_modelo = 'CICLO_4_REVISAO_FINAL' + AND (cr.revfinal_atividade_id IS NULL OR cr.revisao_vigente_tipo_situacao_id = 1) + THEN 'Não iniciada' + WHEN cr.ciclo_modelo NOT IN ('CICLO_2_REVISAO_CORRECAO', 'CICLO_4_REVISAO_FINAL') + AND (cr.rev_atividade_id IS NULL OR cr.revisao_vigente_tipo_situacao_id = 1) + THEN 'Não iniciada' + ELSE COALESCE(cr.usuario_revisor_nome, 'Sem usuário') + END AS usuario_revisor_exibicao, + cr.revisao_vigente_tipo_situacao_id, + cr.revisao_vigente_tipo_situacao_nome, + + cr.cor_atividade_id, + cr.cor_usuario_id AS usuario_corretor_id, + cr.usuario_corretor_nome, + CASE + WHEN cr.ciclo_modelo <> 'CICLO_1_PADRAO' THEN NULL + WHEN cr.cor_atividade_id IS NULL + OR cr.cor_tipo_situacao_id = 1 THEN 'Não iniciada' + ELSE COALESCE(cr.usuario_corretor_nome, 'Sem usuário') + END AS usuario_corretor_exibicao, + cr.cor_tipo_situacao_id AS corretor_tipo_situacao_id, + cr.cor_tipo_situacao_nome AS corretor_tipo_situacao_nome, + + cr.nota_qualidade, + cr.texto_qualidade, + + cr.ciclo_modelo, + cr.estado_ut_subfase, + cr.concluida, + + -- percentual_producao_revisor: fração da dificuldade que vai para o revisor + -- Ciclos 3 e 4 (sem nota): 40% fixo + -- Ciclos 1 e 2 (com nota): fórmula decrescente — nota alta = mais para o executor + CASE + WHEN cr.ciclo_modelo IN ('CICLO_3_SEM_CORRECAO', 'CICLO_4_REVISAO_FINAL') + THEN 0.40 + WHEN cr.nota_qualidade BETWEEN 1 AND 9 + THEN ROUND((0.4875 - (0.0375 * cr.nota_qualidade))::numeric, 6) + ELSE NULL + END AS percentual_producao_revisor, + + CASE + WHEN cr.ciclo_modelo IN ('CICLO_3_SEM_CORRECAO', 'CICLO_4_REVISAO_FINAL') + THEN 0.60 + WHEN cr.nota_qualidade BETWEEN 1 AND 9 + THEN ROUND((1 - (0.4875 - (0.0375 * cr.nota_qualidade)))::numeric, 6) + ELSE NULL + END AS percentual_producao_executor, + + CASE + WHEN cr.estado_ut_subfase NOT IN ( + 'CONCLUIDA_SEM_CORRECAO', 'CONCLUIDA_COM_CORRECAO', 'CONCLUIDA_COM_N_Finalizada' + ) THEN NULL + WHEN cr.ciclo_modelo IN ('CICLO_3_SEM_CORRECAO', 'CICLO_4_REVISAO_FINAL') + THEN ROUND((cr.ut_dificuldade * 0.60)::numeric, 4) + WHEN cr.nota_qualidade BETWEEN 1 AND 9 + AND cr.exec_usuario_id IS NOT NULL + AND cr.cor_usuario_id IS NOT NULL + AND cr.exec_usuario_id = cr.cor_usuario_id + THEN ROUND((cr.ut_dificuldade * (1 - (0.4875 - (0.0375 * cr.nota_qualidade))))::numeric, 4) + WHEN cr.nota_qualidade BETWEEN 1 AND 9 + THEN ROUND((cr.ut_dificuldade * (1 - (0.4875 - (0.0375 * cr.nota_qualidade))) * (cr.nota_qualidade::numeric / 9.0))::numeric, 4) + ELSE NULL + END AS pontos_executor, + + CASE + WHEN cr.estado_ut_subfase NOT IN ( + 'CONCLUIDA_SEM_CORRECAO', 'CONCLUIDA_COM_CORRECAO', 'CONCLUIDA_COM_N_Finalizada' + ) THEN NULL + WHEN cr.ciclo_modelo IN ('CICLO_3_SEM_CORRECAO', 'CICLO_4_REVISAO_FINAL') + THEN ROUND((cr.ut_dificuldade * 0.40)::numeric, 4) + WHEN cr.nota_qualidade BETWEEN 1 AND 9 + THEN ROUND((cr.ut_dificuldade * (0.4875 - (0.0375 * cr.nota_qualidade)))::numeric, 4) + ELSE NULL + END AS pontos_revisor, + + CASE + WHEN cr.estado_ut_subfase NOT IN ( + 'CONCLUIDA_COM_CORRECAO', 'CONCLUIDA_COM_N_Finalizada' + ) THEN NULL + WHEN cr.ciclo_modelo <> 'CICLO_1_PADRAO' THEN NULL + WHEN cr.nota_qualidade BETWEEN 1 AND 9 + AND cr.exec_usuario_id IS NOT NULL + AND cr.cor_usuario_id IS NOT NULL + AND cr.exec_usuario_id = cr.cor_usuario_id + THEN 0.0000::numeric + WHEN cr.nota_qualidade BETWEEN 1 AND 9 + THEN ROUND((cr.ut_dificuldade * (1 - (0.4875 - (0.0375 * cr.nota_qualidade))) * (1 - (cr.nota_qualidade::numeric / 9.0)))::numeric, 4) + ELSE NULL + END AS pontos_corretor + +FROM consolidado_regras cr +""".strip() diff --git a/backend/src/cp/infrastructure/sap_sync/sync.py b/backend/src/cp/infrastructure/sap_sync/sync.py index 21f4318..f453a43 100644 --- a/backend/src/cp/infrastructure/sap_sync/sync.py +++ b/backend/src/cp/infrastructure/sap_sync/sync.py @@ -19,6 +19,7 @@ from sqlalchemy.engine import Connection, Engine from cp.infrastructure.sap_sync.analytics_manager import atualizar_views_analytics +from cp.infrastructure.sap_sync.kpi_manager import materializar_fato_ut_subfase _SCHEMA = "sap_snapshot" _BATCH = 5_000 @@ -354,6 +355,7 @@ def sincronizar_sap_para_snapshot( for fn in _PIPELINE: resultados.append(fn(conn_sap, conn_cp)) atualizar_views_analytics(conn_cp) + materializar_fato_ut_subfase(conn_cp) return resultados diff --git a/backend/tests/cli/test_bootstrap_db.py b/backend/tests/cli/test_bootstrap_db.py index b172387..44ca324 100644 --- a/backend/tests/cli/test_bootstrap_db.py +++ b/backend/tests/cli/test_bootstrap_db.py @@ -88,7 +88,7 @@ def test_criar_banco_cp_cria_schemas_mesmo_se_banco_ja_existe(monkeypatch: pytes def criar_banco_fake(**_kwargs: object) -> bool: return False - chamadas: dict[str, int] = {"schemas": 0, "views": 0} + chamadas: dict[str, int] = {"schemas": 0, "views": 0, "kpi": 0} def criar_schemas_fake(_engine_cp: object) -> None: chamadas["schemas"] += 1 @@ -96,12 +96,16 @@ def criar_schemas_fake(_engine_cp: object) -> None: def garantir_views_fake(_engine_cp: object) -> None: chamadas["views"] += 1 + def garantir_kpi_fake(_engine_cp: object) -> None: + chamadas["kpi"] += 1 + def create_engine_fake(_dsn: str, future: bool = True) -> object: return object() monkeypatch.setattr(bootstrap_db, "criar_banco", criar_banco_fake) monkeypatch.setattr(bootstrap_db, "_criar_schemas_cp", criar_schemas_fake) monkeypatch.setattr(bootstrap_db, "garantir_views_analytics", garantir_views_fake) + monkeypatch.setattr(bootstrap_db, "garantir_fato_ut_subfase", garantir_kpi_fake) monkeypatch.setattr(bootstrap_db, "create_engine", create_engine_fake) criado_agora = bootstrap_db.criar_banco_cp( @@ -115,13 +119,14 @@ def create_engine_fake(_dsn: str, future: bool = True) -> object: assert criado_agora is False assert chamadas["schemas"] == 1 assert chamadas["views"] == 1 + assert chamadas["kpi"] == 1 def test_criar_banco_cp_retorna_true_quando_criou_banco_e_garante_schemas(monkeypatch: pytest.MonkeyPatch) -> None: def criar_banco_fake(**_kwargs: object) -> bool: return True - chamadas: dict[str, int] = {"schemas": 0, "views": 0} + chamadas: dict[str, int] = {"schemas": 0, "views": 0, "kpi": 0} def criar_schemas_fake(_engine_cp: object) -> None: chamadas["schemas"] += 1 @@ -129,12 +134,16 @@ def criar_schemas_fake(_engine_cp: object) -> None: def garantir_views_fake(_engine_cp: object) -> None: chamadas["views"] += 1 + def garantir_kpi_fake(_engine_cp: object) -> None: + chamadas["kpi"] += 1 + def create_engine_fake(_dsn: str, future: bool = True) -> object: return object() monkeypatch.setattr(bootstrap_db, "criar_banco", criar_banco_fake) monkeypatch.setattr(bootstrap_db, "_criar_schemas_cp", criar_schemas_fake) monkeypatch.setattr(bootstrap_db, "garantir_views_analytics", garantir_views_fake) + monkeypatch.setattr(bootstrap_db, "garantir_fato_ut_subfase", garantir_kpi_fake) monkeypatch.setattr(bootstrap_db, "create_engine", create_engine_fake) criado_agora = bootstrap_db.criar_banco_cp( @@ -148,6 +157,7 @@ def create_engine_fake(_dsn: str, future: bool = True) -> object: assert criado_agora is True assert chamadas["schemas"] == 1 assert chamadas["views"] == 1 + assert chamadas["kpi"] == 1 def test_schemas_cp_contem_agregacao_e_dominio() -> None: