Data Lake (BI)

Como consumir os dados da Fhinck no BigQuery: acesso, ferramentas de BI, boas práticas e arquitetura recomendada.

Responsabilidade sobre personalizações

Qualquer regra de negócio adicional, transformação, enriquecimento, agregação, modelagem, métricas e dashboards criados a partir dos dados disponibilizados pela Fhinck são de responsabilidade integral do cliente/parceiro.

A Fhinck disponibiliza o acesso aos dados e às tabelas padronizadas, mas não se responsabiliza por resultados obtidos a partir de consultas customizadas, nem por decisões tomadas com base em transformações implementadas fora do escopo do dashboard padrão.

Índice

Visão geral

A Fhinck disponibiliza aos clientes/parceiros um projeto e dataset exclusivo no Google Cloud Platform (GCP), contendo tabelas padronizadas e prontas para consumo em uma arquitetura de Data Lake / Data Warehouse (BigQuery).

O acesso normalmente é concedido por uma Conta de Serviço (Service Account) com permissões de leitura nas tabelas do dataset.

Como os dados são disponibilizados no BigQuery

Atualização dos dados e consumo mensal

Frequência/latência de atualização

A frequência (e o tempo de atualização) das tabelas disponibilizadas para consumo em BI varia conforme o tipo de licença contratada pelo cliente.

O cliente deve considerar essa característica ao definir janelas de refresh no Power BI (por exemplo, refresh diário, múltiplas vezes ao dia, etc.), alinhando com a licença contratada.

Limite de consumo (10 TB/mês)

O contrato de BI prevê um consumo mensal de referência de 10 TB/mês.

Ferramentas de BI suportadas

Os dados podem ser consumidos por praticamente qualquer ferramenta moderna de BI, usando conectores nativos ou drivers (ODBC/JDBC) do BigQuery.

Acesso (Service Account) e quando é disponibilizado

A Fhinck fornece o acesso ao Data Lake via Conta de Serviço (Service Account) do Google Cloud após a contratação da licença de BI.

O cliente recebe:

Importante: a chave JSON deve ser tratada como segredo. Ela dá acesso de leitura ao dataset do cliente no BigQuery e não deve ser compartilhada em canais não seguros.

Passo a passo: conectar no Microsoft Power BI (com JSON)

Pré-requisitos

1) Selecionar a fonte

2) Autenticar via Service Account

3) Escolher projeto/dataset e tabelas

4) Definir modo de consumo (Import vs DirectQuery)

5) Publicar e configurar atualização (Power BI Service)

Conectar no Looker Studio

Outras plataformas

Em todas as ferramentas, a regra é a mesma: a credencial (JSON) autentica e as permissões IAM determinam quais projetos/datasets/tabelas serão visíveis.

Modelo recomendado para Data Lake corporativo (Cloud Storage + Parquet)

As seções anteriores cobrem o consumo dos dados em ferramentas de BI (Power BI, Looker Studio, Tableau, etc.). Para alimentar um Data Lake corporativo com extrações volumosas e recorrentes (Snowflake, Oracle, Databricks, Postgres, Redshift, ou BigQuery do próprio cliente), o modelo de extração via JDBC síncrono tradicional não é o mais indicado. Recomendamos o pipeline desacoplado descrito abaixo.

Por que extração JDBC síncrona não escala para Data Lake

Pipeline desacoplado (BigQuery EXPORT DATA → Cloud Storage)

O BigQuery exporta o snapshot diário em Parquet com compressão Snappy para um bucket dedicado no Cloud Storage. O Data Lake do cliente consome o arquivo, sem dependência de rede ou JDBC. Vantagens objetivas:

Ferramentas de mercado para consumir Cloud Storage

Do lado do consumidor (Data Lake, Oracle, Snowflake, Postgres, etc.), há diversas opções consolidadas:

Como solicitar o pipeline com a Fhinck

A Fhinck disponibiliza o pipeline (job EXPORT DATA agendado + bucket regional dedicado + Cloud Scheduler + IAM read-only para a Service Account do cliente) em até 5 dias úteis após alinhamento técnico. Solicitação por meio do canal de CSM ou Slack do cliente.

Padrões e anti-padrões em extração de dados

Recomendações práticas para evitar os erros mais comuns observados em extrações contra o BigQuery, válidas tanto para o consumo via BI quanto para alimentação de Data Lake.

Padrões recomendados

Anti-padrões a evitar

Detalhamento técnico do erro responseTooLarge, do limite de 128 MB e da Storage Read API disponível na documentação oficial do BigQuery: cloud.google.com/bigquery/docs.

Boas práticas (Data Lake)

Para manter governança, rastreabilidade e performance, recomenda-se:

Regras de negócio e customizações (modelo recomendado)

Quando o cliente precisar aplicar regras de negócio próprias (ou replicar regras do dashboard Fhinck com adaptações), a recomendação é criar um script agendado (job), alinhado a boas práticas de data lakes.

Esse job deve:

  1. Executar uma consulta SQL (ou conjunto de consultas) no BigQuery.
  2. Aplicar as regras de negócio e transformações necessárias.
  3. Gravar o resultado em tabelas/vistas do cliente (camada curada), que serão então consumidas pelo Power BI/Looker/Tableau.

Por que assim?

Conta de serviço e tabelas padrão

A conta de serviço possui acesso a um projeto e dataset exclusivos no GCP, com tabelas padrão:

Dicionário de dados (schema resumido)

Dicionário de dados completo disponível em: 📖 Dicionário de Dados — Data Lake BI — referência técnica com schema detalhado, tipos, particionamento, clustering e padrões de consulta.

Padrão: tabelas no projeto fnk-bi-{cliente}, dataset {company}. Convenção: {nome_tabela}_{company}.

  1. events_{company} (eventos brutos do agente desktop da Fhinck)
    • bigquery_id (STRING) — ID único do evento
    • p2o_username (STRING) — email/username do colaborador
    • p2o_hostname (STRING) — nome da máquina
    • p2o_datetime_in (TIMESTAMP) — início do evento (janela em foco)
    • p2o_datetime_out (TIMESTAMP) — fim do evento
    • p2o_total_seconds (FLOAT64) — duração total (segundos)
    • p2o_total_idle (FLOAT64) — tempo ocioso dentro do evento
    • p2o_app_exe (STRING) — executável (ex.: chrome.exe)
    • p2o_app_exe_fullname (STRING) — nome completo do app
    • p2o_title_windows (STRING) — título da janela ativa
    • p2o_url (STRING) — URL (quando navegador)
    • p2o_url_domain (STRING) — domínio da URL
    • p2o_network (STRING) — informações de rede (SSID etc.)
    • p2o_mouse_hooks (STRING) — dados de atividade do mouse
    • p2o_keyboard_hooks (STRING) — dados de atividade do teclado
    • p2o_IsWorkstationLocked (BOOLEAN) — estação bloqueada
    • p2o_source (STRING) — origem do dado
    • p2o_datetime_last_insert (TIMESTAMP) — timestamp da última inserção
    • odd_behavior (STRING) — flags de comportamento anômalo
  2. rel_department_hierarchy_{company} (hierarquia de departamentos)
    • department_name (STRING) — nome do departamento
    • department_code (STRING) — código do departamento
    • creation_user (STRING) — usuário que criou o registro
    • creation_date (TIMESTAMP) — data de criação
    • active (BOOLEAN) — se está ativo
    • department_level (INT64) — nível hierárquico (1=topo, 2=sub, etc.)
  3. rel_role_attribute_{company} (cargos/funções)
    • role_name (STRING) — nome do cargo
    • role_code (STRING) — código do cargo
    • creation_user (STRING) — usuário que criou o registro
    • creation_date (TIMESTAMP) — data de criação
    • active (BOOLEAN) — se está ativo
  4. rel_region_attribute_{company} (regiões/localidades)
    • region_name (STRING) — nome da região
    • region_code (STRING) — código da região
    • creation_user (STRING) — usuário que criou o registro
    • creation_date (TIMESTAMP) — data de criação
    • active (BOOLEAN) — se está ativa
  5. rel_hierarchy_employee_associative_{company} (vínculo colaborador × hierarquia)
    • fk_department_code (STRING) — código do departamento
    • fk_process_code (STRING) — código do processo
    • fk_role_code (STRING) — código do cargo
    • fk_region_code (STRING) — código da região
    • employee (STRING) — email/username do colaborador
    • employee_alias (STRING) — nome de exibição
    • department_path (STRING) — caminho hierárquico (códigos separados por _)
    • process_path (STRING) — caminho de processos
    • vacation_active (BOOLEAN) — se está em férias
    • vacation_date_in (TIMESTAMP) — início das férias
    • vacation_date_out (TIMESTAMP) — fim das férias
    • creation_user (STRING) — usuário que criou o registro
    • creation_date (TIMESTAMP) — data de criação
    • search_date (TIMESTAMP) — data de vigência da associação
    • active (BOOLEAN) — se a associação está ativa
    • Entry_Weekday (STRING) — horário de entrada (dia útil)
    • Exit_Weekday (STRING) — horário de saída (dia útil)
    • Entry_Saturday (STRING) — horário de entrada (sábado)
    • Exit_Saturday (STRING) — horário de saída (sábado)
    • Entry_Sunday (STRING) — horário de entrada (domingo)
    • Exit_Sunday (STRING) — horário de saída (domingo)
  6. rel_hierarchy_domain_associative_{company} (domínios/apps × hierarquia)
    • fk_department_code (STRING) — código do departamento
    • fk_process_code (STRING) — código do processo
    • fk_role_code (STRING) — código do cargo
    • fk_region_code (STRING) — código da região
    • domain (STRING) — domínio ou aplicativo
    • category (STRING) — categoria do domínio
    • department_path (STRING) — caminho hierárquico
    • process_path (STRING) — caminho de processos
    • creation_user (STRING) — usuário que criou o registro
    • creation_date (TIMESTAMP) — data de criação
    • search_date (TIMESTAMP) — data de vigência
    • active (BOOLEAN) — se a associação está ativa
    • waterfall_classification (STRING) — classificação (produtivo, neutro, improdutivo)
  7. time_extension_request_{company} (solicitações de extensão de jornada)
    • request_date (DATE) — data da solicitação
    • request_timestamp (TIMESTAMP) — timestamp exato da solicitação
    • company (STRING) — empresa
    • username (STRING) — colaborador solicitante
    • time_requested (INT64) — tempo extra solicitado (minutos)
    • dh_insert (TIMESTAMP) — data/hora de inserção no BQ
    • accepted (BOOLEAN) — se foi aceita
    • accepted_timestamp (TIMESTAMP) — quando foi aceita/rejeitada
    • manager_accepted (STRING) — gestor que aprovou/rejeitou
    • date_limit (DATE) — data limite da extensão
    • justification (STRING) — justificativa
    • original_requested (INT64) — tempo originalmente solicitado
    • request_manager (STRING) — gestor para quem foi enviada
  8. time_tracking_{company} (registros de ponto)
    • action_date (DATE) — data do registro
    • action_id (STRING) — ID da ação
    • username (STRING) — colaborador
    • hostname (STRING) — máquina de origem
    • beginDay (TIMESTAMP) — início da jornada
    • beginLunch (TIMESTAMP) — início do almoço
    • endLunch (TIMESTAMP) — fim do almoço
    • endDay (TIMESTAMP) — fim da jornada
    • source (STRING) — origem (manual, sistema, etc.)
    • action_datetime_sent (TIMESTAMP) — quando foi enviado
    • action_datetime_bq_insert (TIMESTAMP) — quando foi inserido no BQ
    • locationType (STRING) — tipo de local (escritório, home, etc.)
    • lastUpdate (TIMESTAMP) — última atualização
  9. dim_employee_daily_{company} (dimensão de colaborador por dia)
    • employee (STRING) — email/username (lowercase)
    • p2o_date (DATE) — data de referência
    • employee_alias (STRING) — nome de exibição (uppercase)
    • role_name (STRING) — cargo vigente na data
    • Region (STRING) — região vigente na data
    • department_name (STRING) — departamento mais profundo
    • Department_1 (STRING) — depto nível 1
    • Department_2 (STRING) — depto nível 2
    • Department_3 (STRING) — depto nível 3
    • Department_4 (STRING) — depto nível 4
    • Department_5 (STRING) — depto nível 5
    • Department_6 (STRING) — depto nível 6
    • Department_7 (STRING) — depto nível 7
    • Department_8 (STRING) — depto nível 8
    • Department_9 (STRING) — depto nível 9
    • Department_10 (STRING) — depto nível 10 (mais profundo)

Tabelas de suporte

  1. network_raw_latest (redes corporativas)
    • DATA (STRING/JSON) — documento com $.network (array de SSIDs corporativos) e $.company
  2. text_clearing_rules (regras de limpeza/anônimização)
    • regex_rule (STRING) — expressão regular
    • replace_value (STRING) — valor de substituição
    • active (BOOLEAN) — se está ativa
    • company (STRING) — empresa ou GENERAL_ALL
  3. app_classificationV2 (regras de classificação de apps/sites)
    • company (STRING) — empresa ou GENERAL_ALL
    • app_name_en (STRING) — nome do app (inglês)
    • regex_rule (STRING) — regex para matching em título/URL
    • app_regex (STRING) — regex adicional
    • app_class (STRING) — classe do app
    • app_class_id (INT64) — ID numérico da classe
    • rnk (INT64) — prioridade da regra
    • active (BOOLEAN) — se está ativa

UDFs (User Defined Functions)

Consulta de referência (para aplicar regras e preparar a camada curada)

A consulta abaixo pode ser usada como base para o job agendado do cliente.

Revisão 2026-08-05 — hierarquia do colaborador. A consulta passou a relacionar cada evento com a hierarquia cadastrada do login, saindo de 23 para 43 colunas. São duas fontes com semânticas diferentes: de dim_employee_daily_{company} vêm employee_alias, department_name, role_name, region_name e Department_1..10, com os valores vigentes na data do evento (histórico correto); de rel_hierarchy_employee_associative_{company} vêm fk_department_code, fk_role_code, fk_region_code, fk_process_code, department_path e process_path, que refletem o cadastro atual — esse cadastro é um snapshot por login, sem versionamento. Para análise histórica por departamento ou cargo, use os campos de nome; os códigos servem para integrar com cadastros seus. Aderência verificada em dia útil (900.789 linhas): nenhuma linha sem departamento, cargo ou nome, e zero divergência de departamento, cargo e região nos 342 colaboradores.
Revisão 2026-08-04 — duas correções. (1) Filtro de duração: incluída a condição AND e.p2o_total_seconds > 0 no CTE filtered_events — sem ela, eventos de duração zero (sinal de estação bloqueada) sobrevivem ao filtro, porque 0 >= 0 é verdadeiro, e a extensão de intervalo os converte em tempo de atividade. (2) Janela de jornada: a consulta passou a montar a jornada por colaborador a partir dos eventos desbloqueados (CTEs unlocked_seqjourney_by_day), a manter somente eventos dentro da janela [entrada, saída] e a usar a data de entrada da jornada como p2o_date — em vez do dia-calendário do evento. Tempo de máquina ligada fora da jornada não é atividade.
Escopo e aderência desta consulta. Ela reproduz a cadeia de tratamento (deduplicação, janela de jornada, classificação de aplicativo, modo de trabalho) e serve para montar a camada curada do cliente. Aderência medida contra o pipeline interno da Fhinck, em dois ambientes e dois perfis de dia: em dia útil, +1,2% e +1,4% na contagem de linhas e +3,2% e +2,5% no tempo total; em dia de baixa atividade (sábado), +7,5% e +8,7% nas linhas e +11,7% e +23,1% no tempo. O resíduo vem de etapas que a consulta não modela — detecção de bloqueio por grupo de eventos contíguos, ajuste fino de ociosidade e correção de página de login. Portanto: já é adequada para análise (aplicativo, classe, colaborador, jornada); para relatórios cujo número precise coincidir exatamente com o dashboard, consulte a Fhinck antes de publicar.
SQL

      WITH
      
      config AS (
        SELECT
          'EMPRESA' AS company_name,
          DATE_SUB(CURRENT_DATE(), INTERVAL 15 DAY) AS date_start,
          DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY) AS date_end
      ),
      
      corporate_networks AS (
        SELECT
          ARRAY_AGG(DISTINCT LOWER(TRIM(REPLACE(network, '"', '')))) AS networks
        FROM `fnk-bi-empresa.EMPRESA.network_raw_latest`,
        UNNEST(JSON_EXTRACT_ARRAY(DATA, "$.network")) AS network
        WHERE JSON_EXTRACT_SCALAR(DATA, "$.company") = 'EMPRESA'
      ),
      
      text_clearing_rules AS (
        SELECT
          ARRAY_AGG(STRUCT(
            regex_rule AS regex,
            replace_value AS replacement
          )) AS clearing_array
        FROM `fnk-bi-empresa.EMPRESA.text_clearing_rules`
        WHERE active = TRUE
      ),
      
      app_classification_rules AS (
        SELECT
          ARRAY_AGG(STRUCT<
            company STRING,
            app_name STRING,
            regex_rule STRING,
            app_regex STRING,
            app_class STRING,
            app_class_id INT64,
            rnk INT64
          >(
            company,
            app_name_en,
            regex_rule,
            app_regex,
            app_class,
            app_class_id,
            rnk
          ) ORDER BY
            CASE WHEN company != 'GENERAL_ALL' THEN 0 ELSE 1 END,
            CASE WHEN app_class = 'web_browser__class' THEN 1 ELSE 0 END,
            rnk
          ) AS rules_array
        FROM `fnk-bi-empresa.EMPRESA.app_classificationV2`
        WHERE active = TRUE
          AND (company = 'EMPRESA' OR company = 'GENERAL_ALL')
      ),
      
      filtered_events AS (
        SELECT
          e.*,
          DATE(e.p2o_datetime_in) AS p2o_date
        FROM `fnk-bi-empresa.EMPRESA.events_EMPRESA` e,
        config c
        WHERE
          DATE(e.p2o_datetime_in) BETWEEN c.date_start AND c.date_end
          AND LOWER(e.p2o_username) != 'fhinck'
          AND e.p2o_total_seconds >= e.p2o_total_idle
          AND e.p2o_total_seconds > 0
          AND (e.p2o_source IS NULL OR UPPER(IFNULL(e.p2o_source, '')) != 'CORRUPTED')
          AND (
            e.odd_behavior IS NULL
            OR (
              e.odd_behavior NOT LIKE '%Invalid total_seconds%'
              AND e.odd_behavior NOT LIKE '%Unexpected Time Change%'
              AND e.odd_behavior NOT LIKE '%Corrupted File%'
            )
          )
      ),
      
      windowed_events AS (
        SELECT
          *,
          LAG(p2o_title_windows, 8) OVER w AS lag_title_windows,
          LEAD(p2o_title_windows, 8) OVER w AS lead_title_windows,
          COUNTIF(p2o_title_windows = 'LockingWindow')
            OVER (w ROWS BETWEEN 7 PRECEDING AND CURRENT ROW) -
            IF(p2o_title_windows = 'LockingWindow', 1, 0) AS cnt_prc_lockwindow,
          COUNTIF(p2o_title_windows = 'LockingWindow')
            OVER (w ROWS BETWEEN CURRENT ROW AND 7 FOLLOWING) -
            IF(p2o_title_windows = 'LockingWindow', 1, 0) AS cnt_flw_lockwindow,
          LAG(p2o_url) OVER w AS lag_p2o_url,
          LEAD(CONCAT(IFNULL(p2o_app_exe, ''), IFNULL(p2o_url, ''))) OVER w AS lead_app_url
        FROM filtered_events
        WINDOW w AS (PARTITION BY p2o_username, p2o_hostname ORDER BY p2o_datetime_in, p2o_datetime_out)
      ),
      
      deduplicated_events AS (
        SELECT * EXCEPT(row_num)
        FROM (
          SELECT
            *,
            ROW_NUMBER() OVER (
              PARTITION BY bigquery_id
              ORDER BY p2o_datetime_last_insert DESC, p2o_datetime_in DESC
            ) AS row_num
          FROM windowed_events
        )
        WHERE row_num = 1
      ),
      
      communication_detection AS (
        SELECT
          *,
          CASE
            WHEN REGEXP_CONTAINS(LOWER(IFNULL(p2o_app_exe_fullname, p2o_app_exe)),
                 r'(teams|microsoft.*teams|zoom|skype|webex|cisco.*webex|google.*meet|gotomeeting|bluejeans|jabber|cisco.*jabber|chime|amazon.*chime|hangouts|google.*hangouts|ringcentral|8x8|fuze|avaya|polycom|lifesize|starleaf|pexip|vidyo|adobe.*connect|logmein|citrix.*gotomeeting|join\.me|lync|skype.*for.*business|skype4b|sfb|s4b|zoom.*phone|3cx|aircall|dialpad|vonage|ooma|mitel|cisco.*spark|webex.*teams|ms.*teams)')
            THEN TRUE
            WHEN REGEXP_CONTAINS(LOWER(IFNULL(p2o_url_domain, '')),
                 r'(teams\.microsoft\.com|zoom\.us|skype\.com|webex\.com|meet\.google\.com|gotomeeting\.com|bluejeans\.com|hangouts\.google\.com)')
            THEN TRUE
            ELSE FALSE
          END AS is_communication_app
        FROM deduplicated_events
      ),
      
      locked_detection AS (
        SELECT
          * EXCEPT(p2o_IsWorkstationLocked),
          CASE
            WHEN LOWER(IFNULL(p2o_app_exe, '')) LIKE '%winmainf%'
              OR LOWER(IFNULL(p2o_app_exe, '')) LIKE '%netsendf%'
              OR LOWER(IFNULL(p2o_app_exe, '')) LIKE '%pcf%'
              OR LOWER(IFNULL(p2o_app_exe, '')) LIKE '%p2o%'
              OR LOWER(IFNULL(p2o_app_exe, '')) LIKE '%push2do%'
              OR LOWER(IFNULL(p2o_app_exe, '')) = '[system process]'
              OR p2o_app_exe = 'No app info'
              OR LOWER(IFNULL(p2o_app_exe, '')) LIKE 'lockapp%'
            THEN TRUE
            WHEN p2o_total_idle > 3000
              AND SAFE_DIVIDE(p2o_total_idle, p2o_total_seconds) > 0.9
              AND NOT is_communication_app
            THEN TRUE
            WHEN REGEXP_CONTAINS(LOWER(IFNULL(lag_p2o_url, '')), r'(screen saver)|(windows is being)')
              AND REGEXP_CONTAINS(LOWER(IFNULL(lead_app_url, '')), r'(screen saver)|(windows is being)|(lockapp)')
            THEN TRUE
            WHEN p2o_title_windows = 'LockingWindow'
            THEN TRUE
            WHEN IFNULL(p2o_mouse_hooks, '') = ''
              AND IFNULL(p2o_keyboard_hooks, '') = ''
              AND NOT(lag_title_windows IS NULL AND lead_title_windows IS NULL)
              AND (IFNULL(lag_title_windows, 'LockingWindow') = 'LockingWindow' OR cnt_prc_lockwindow > 0)
              AND (IFNULL(lead_title_windows, 'LockingWindow') = 'LockingWindow' OR cnt_flw_lockwindow > 0)
            THEN TRUE
            ELSE IFNULL(p2o_IsWorkstationLocked, FALSE)
          END AS p2o_IsWorkstationLocked
        FROM communication_detection
      ),
      
      employees_vacation AS (
        SELECT DISTINCT
          employee,
          LAST_VALUE(vacation_date_in IGNORE NULLS) OVER (
            PARTITION BY employee
            ORDER BY creation_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
          ) AS vacation_date_in,
          LAST_VALUE(vacation_date_out IGNORE NULLS) OVER (
            PARTITION BY employee
            ORDER BY creation_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
          ) AS vacation_date_out,
          LAST_VALUE(vacation_active IGNORE NULLS) OVER (
            PARTITION BY employee
            ORDER BY creation_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
          ) AS vacation_active
        FROM `fnk-bi-empresa.EMPRESA.rel_hierarchy_employee_associative_EMPRESA`
        WHERE vacation_date_in IS NOT NULL
          AND vacation_active IS TRUE
      ),
      
      events_no_vacation AS (
        SELECT e.*
        FROM locked_detection e
        LEFT JOIN employees_vacation vac
          ON LOWER(e.p2o_username) = LOWER(vac.employee)
          AND e.p2o_datetime_in >= vac.vacation_date_in
          AND e.p2o_datetime_out <= IFNULL(vac.vacation_date_out, TIMESTAMP('2099-12-31'))
        WHERE vac.employee IS NULL
      ),
      
      -- ===================================================================
      -- JANELA DE JORNADA — porte do pipeline interno da Fhinck
      --
      -- A jornada e definida POR USUARIO a partir dos eventos DESBLOQUEADOS
      -- com duracao > 0 (nao por usuario+maquina). Blocos contiguos sao unidos;
      -- ha quebra de jornada quando o intervalo desde o ultimo desbloqueio
      -- passa de 4h. A data do evento passa a ser a data de ENTRADA da jornada
      -- (nao DATE(p2o_datetime_in)), e SOMENTE eventos dentro da janela
      -- [entrada, saida] permanecem — tempo de maquina ligada fora da jornada
      -- nao e atividade.
      -- ===================================================================
      unlocked_seq AS (
        SELECT
          p2o_username, p2o_datetime_in, p2o_datetime_out,
          LAST_VALUE(p2o_datetime_out IGNORE NULLS) OVER (
            PARTITION BY p2o_username
            ORDER BY p2o_datetime_in, p2o_datetime_out
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
          ) AS last_unlocked
        FROM events_no_vacation
        WHERE p2o_IsWorkstationLocked = FALSE
          AND p2o_total_seconds > 0
      ),
      
      contiguous_blocks AS (
        SELECT
          p2o_username, p2o_datetime_in, p2o_datetime_out, last_unlocked,
          SUM(IF(last_unlocked = p2o_datetime_in, 0, 1)) OVER (
            PARTITION BY p2o_username
            ORDER BY p2o_datetime_in, p2o_datetime_out
          ) AS gp_nr
        FROM unlocked_seq
      ),
      
      journey_blocks AS (
        SELECT
          p2o_username, gp_nr,
          MIN(p2o_datetime_in) AS gp_entry,
          MAX(p2o_datetime_out) AS gp_exit,
          MIN(last_unlocked) AS gp_last_unlocked
        FROM contiguous_blocks
        GROUP BY p2o_username, gp_nr
      ),
      
      journeys AS (
        SELECT
          p2o_username, gp_entry, gp_exit,
          SUM(IF(TIMESTAMP_DIFF(gp_entry, gp_last_unlocked, SECOND) > 14400, 1, 0)) OVER (
            PARTITION BY p2o_username
            ORDER BY gp_entry, gp_exit
          ) AS journey_nr
        FROM journey_blocks
      ),
      
      journey_ranges AS (
        SELECT
          p2o_username,
          MIN(gp_entry) AS journey_entry,
          MAX(gp_exit) AS journey_exit
        FROM journeys
        GROUP BY p2o_username, journey_nr
      ),
      
      journey_by_day AS (
        SELECT
          jr.p2o_username AS jw_user,
          DATE(jr.journey_entry) AS jw_date,
          MIN(jr.journey_entry) AS jw_entry,
          MAX(jr.journey_exit) AS jw_exit
        FROM journey_ranges jr, config c
        WHERE DATE(jr.journey_entry) BETWEEN c.date_start AND c.date_end
        GROUP BY jw_user, jw_date
      ),
      
      entry_exit_calc AS (
        SELECT
          e.* EXCEPT(p2o_date),
          w.jw_date AS p2o_date,
          w.jw_entry AS p2o_entry,
          w.jw_exit AS p2o_exit
        FROM events_no_vacation e
        JOIN journey_by_day w
          ON e.p2o_username = w.jw_user
          AND e.p2o_datetime_in >= w.jw_entry
          AND e.p2o_datetime_out <= w.jw_exit
      ),
      
      
      gap_detection AS (
        SELECT
          *,
          LEAD(p2o_datetime_in) OVER (
            PARTITION BY p2o_username, p2o_hostname, p2o_date
            ORDER BY p2o_datetime_in, p2o_datetime_out
          ) AS next_event_start,
          TIMESTAMP_DIFF(
            LEAD(p2o_datetime_in) OVER (
              PARTITION BY p2o_username, p2o_hostname, p2o_date
              ORDER BY p2o_datetime_in, p2o_datetime_out
            ),
            p2o_datetime_out,
            SECOND
          ) AS gap_seconds
        FROM entry_exit_calc
      ),
      
      events_extended AS (
        SELECT
          * EXCEPT(p2o_datetime_out, p2o_total_seconds, p2o_total_idle, gap_seconds, next_event_start),
          CASE
            WHEN gap_seconds > 0
              AND gap_seconds <= 14400
              AND next_event_start IS NOT NULL
            THEN next_event_start
            ELSE p2o_datetime_out
          END AS p2o_datetime_out,
          CASE
            WHEN gap_seconds > 0
              AND gap_seconds <= 14400
              AND next_event_start IS NOT NULL
            THEN p2o_total_seconds + gap_seconds
            ELSE p2o_total_seconds
          END AS p2o_total_seconds,
          CASE
            WHEN gap_seconds > 0
              AND gap_seconds <= 14400
              AND next_event_start IS NOT NULL
            THEN p2o_total_idle + (gap_seconds * SAFE_DIVIDE(p2o_total_idle, p2o_total_seconds))
            ELSE p2o_total_idle
          END AS p2o_total_idle
        FROM gap_detection
      ),
      
      classified_events AS (
        SELECT
          e.*,
          `fnk-bi-empresa.EMPRESA.clean_textV2`(
            e.p2o_title_windows,
            '{"cleanTitle": true}',
            tcr.clearing_array
          ) AS title_windows_clean,
          `fnk-bi-empresa.EMPRESA.get_app_classification`(
            acr.rules_array,
            [
              `fnk-bi-empresa.EMPRESA.clean_textV2`(e.p2o_title_windows, '{"cleanTitle": true}', tcr.clearing_array),
              LEFT(IFNULL(e.p2o_url, ''), 1500),
              IFNULL(e.p2o_url_domain, ''),
              IFNULL(e.p2o_app_exe, ''),
              IFNULL(e.p2o_app_exe_fullname, '')
            ],
            IFNULL(e.p2o_url_domain, IFNULL(e.p2o_app_exe_fullname, e.p2o_app_exe))
          ) AS app_classification_result
        FROM events_extended e
        CROSS JOIN app_classification_rules acr
        CROSS JOIN text_clearing_rules tcr
      ),
      
      events_with_classification AS (
        SELECT
          * EXCEPT(app_classification_result, p2o_url_domain_corporate),
          IFNULL(app_classification_result.app_name, IFNULL(p2o_url_domain, IFNULL(p2o_app_exe_fullname, p2o_app_exe))) AS app_name,
          IFNULL(app_classification_result.app_class, 'other') AS app_class,
          IFNULL(app_classification_result.app_class_id, 0) AS app_class_id,
          CASE
            WHEN p2o_app_exe IS NOT NULL
              AND LOWER(p2o_app_exe) NOT IN (
                'chrome.exe', 'firefox.exe', 'safari.exe', 'msedge.exe',
                'iexplore.exe', 'opera.exe', 'brave.exe', 'chromium.exe',
                'vivaldi.exe', 'waterfox.exe', 'seamonkey.exe', 'palemoon.exe',
                'maxthon.exe', 'slimbrowser.exe', 'avant.exe', 'tor.exe',
                'epic.exe', 'yandex.exe', 'edge.exe'
              )
            THEN 55
            WHEN app_classification_result.app_class_id IS NOT NULL
              AND app_classification_result.app_class_id > 0
            THEN app_classification_result.app_class_id
            WHEN p2o_url_domain IS NOT NULL
              AND LOWER(p2o_url_domain) LIKE '%fhinck%'
            THEN 1
            WHEN p2o_url_domain IS NULL
              OR TRIM(IFNULL(p2o_url_domain, '')) = ''
            THEN 55
            WHEN NOT REGEXP_CONTAINS(IFNULL(p2o_url_domain, ''),
              r'^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$')
            THEN 55
            WHEN REGEXP_CONTAINS(LOWER(IFNULL(p2o_url, '')), r'\.(pdf|jpg|png|doc|docx|xls|xlsx|xml)$')
            THEN 55
            ELSE 0
          END AS p2o_url_domain_corporate
        FROM classified_events
      ),
      
      network_extracted AS (
        SELECT
          e.*,
          cn.networks AS corporate_networks_list,
          (
            SELECT
              LOWER(TRIM(SPLIT(network_part, '|')[SAFE_OFFSET(0)]))
            FROM UNNEST(SPLIT(IFNULL(e.p2o_network, ''), ';')) AS network_part
            WHERE ARRAY_LENGTH(SPLIT(network_part, '|')) >= 2
              AND LOWER(TRIM(SPLIT(network_part, '|')[SAFE_OFFSET(1)])) = 'true'
              AND LOWER(TRIM(SPLIT(network_part, '|')[SAFE_OFFSET(0)])) NOT IN (
                'identificando', 'rede nao identificada', 'rede não identificada', '', 'null'
              )
            LIMIT 1
          ) AS detected_network
        FROM events_with_classification e
        CROSS JOIN corporate_networks cn
      ),
      
      network_status AS (
        SELECT
          ne.*,
          LAG(detected_network) OVER (
            PARTITION BY p2o_username, p2o_hostname, p2o_date
            ORDER BY p2o_datetime_in
          ) AS prev_network,
          CASE
            WHEN detected_network IS NOT NULL
              AND detected_network IN UNNEST(corporate_networks_list)
            THEN TRUE
            ELSE FALSE
          END AS is_current_corporate,
          CASE
            WHEN LAG(detected_network) OVER (
                  PARTITION BY p2o_username, p2o_hostname, p2o_date
                  ORDER BY p2o_datetime_in
                 ) IS NOT NULL
              AND LAG(detected_network) OVER (
                  PARTITION BY p2o_username, p2o_hostname, p2o_date
                  ORDER BY p2o_datetime_in
                 ) IN UNNEST(corporate_networks_list)
            THEN TRUE
            ELSE FALSE
          END AS is_prev_corporate
        FROM network_extracted ne
      ),
      
      initial_status AS (
        SELECT
          *,
          CASE
            WHEN is_current_corporate = TRUE
              AND is_prev_corporate = FALSE
              AND prev_network IS NOT NULL
            THEN 'VPN'
            WHEN is_current_corporate = TRUE
            THEN 'Corp'
            ELSE 'Outro'
          END AS initial_network_status
        FROM network_status
      ),
      
      vpn_propagation AS (
        SELECT
          *,
          MAX(CASE WHEN initial_network_status = 'VPN' THEN p2o_datetime_in END) OVER (
            PARTITION BY p2o_username, p2o_hostname, p2o_date
            ORDER BY p2o_datetime_in
            ROWS UNBOUNDED PRECEDING
          ) AS vpn_start_time
        FROM initial_status
      ),
      
      network_final AS (
        SELECT
          *,
          CASE
            WHEN vpn_start_time IS NOT NULL
              AND p2o_datetime_in >= vpn_start_time
              AND is_current_corporate = TRUE
            THEN 'VPN'
            ELSE initial_network_status
          END AS final_network_status
        FROM vpn_propagation
      ),
      
      work_mode_event AS (
        SELECT
          *,
          CASE
            WHEN final_network_status IN ('VPN', 'Outro') THEN 'Home-Office'
            WHEN final_network_status = 'Corp' THEN 'Empresa'
            ELSE 'Indeterminado'
          END AS place_event
        FROM network_final
      ),
      
      events_with_block_count AS (
        SELECT
          *,
          CAST(CEILING(p2o_total_seconds / 600.0) AS INT64) AS num_blocks,
          p2o_total_seconds AS original_total_seconds,
          p2o_total_idle AS original_total_idle
        FROM work_mode_event
      ),
      
      split_blocks AS (
        SELECT
          e.* EXCEPT(p2o_datetime_in, p2o_datetime_out, p2o_total_seconds, p2o_total_idle),
          TIMESTAMP_ADD(e.p2o_datetime_in, INTERVAL (600 * block_idx) SECOND) AS p2o_datetime_in,
          LEAST(
            e.p2o_datetime_out,
            TIMESTAMP_ADD(e.p2o_datetime_in, INTERVAL (600 * (block_idx + 1)) SECOND)
          ) AS p2o_datetime_out,
          block_idx,
          TIMESTAMP_DIFF(
            LEAST(
              e.p2o_datetime_out,
              TIMESTAMP_ADD(e.p2o_datetime_in, INTERVAL (600 * (block_idx + 1)) SECOND)
            ),
            TIMESTAMP_ADD(e.p2o_datetime_in, INTERVAL (600 * block_idx) SECOND),
            SECOND
          ) AS p2o_total_seconds
        FROM events_with_block_count e,
        UNNEST(GENERATE_ARRAY(0, GREATEST(0, num_blocks - 1))) AS block_idx
        WHERE TIMESTAMP_ADD(e.p2o_datetime_in, INTERVAL (600 * block_idx) SECOND) < e.p2o_datetime_out
      ),
      
      events_split AS (
        SELECT
          * EXCEPT(original_total_seconds, original_total_idle, num_blocks, block_idx),
          LEAST(
            CAST(p2o_total_seconds AS FLOAT64),
            GREATEST(
              0.0,
              original_total_idle * SAFE_DIVIDE(CAST(p2o_total_seconds AS FLOAT64), CAST(original_total_seconds AS FLOAT64))
            )
          ) AS p2o_total_idle,
          CASE
            WHEN num_blocks > 1
            THEN CONCAT(bigquery_id, '-', CAST(block_idx AS STRING))
            ELSE bigquery_id
          END AS new_bigquery_id
        FROM split_blocks
      ),
      
      place_day_calc AS (
        SELECT
          e.*,
          SUM(CASE WHEN place_event = 'Home-Office' AND p2o_IsWorkstationLocked = FALSE
                   THEN p2o_total_seconds ELSE 0 END) OVER (
            PARTITION BY p2o_username, p2o_date
          ) AS total_remote_seconds,
          SUM(CASE WHEN place_event = 'Empresa' AND p2o_IsWorkstationLocked = FALSE
                   THEN p2o_total_seconds ELSE 0 END) OVER (
            PARTITION BY p2o_username, p2o_date
          ) AS total_presential_seconds,
          SUM(CASE WHEN p2o_IsWorkstationLocked = FALSE
                   THEN p2o_total_seconds ELSE 0 END) OVER (
            PARTITION BY p2o_username, p2o_date
          ) AS total_active_seconds
        FROM events_split e
      ),
      
      events_with_place_day AS (
        SELECT
          *,
          CASE
            WHEN SAFE_DIVIDE(total_remote_seconds, total_active_seconds) > 0.10
              AND SAFE_DIVIDE(total_presential_seconds, total_active_seconds) > 0.10
            THEN 'Hibrida'
            WHEN total_remote_seconds > total_presential_seconds
            THEN 'Home-Office'
            ELSE 'Empresa'
          END AS place_day
        FROM place_day_calc
      ),
      
      -- ===================================================================
      -- HIERARQUIA DO COLABORADOR
      --
      -- Duas fontes, com semanticas DIFERENTES — nao sao intercambiaveis:
      --
      --   dim_employee_daily  -> nomes VIGENTES NA DATA (historico correto).
      --       Uma linha por colaborador por dia. E daqui que saem employee_alias,
      --       role_name, region_name, department_name e os niveis Department_1..10.
      --
      --   rel_hierarchy_employee_associative -> CODIGOS e PATHS da configuracao
      --       ATUAL (o cadastro no data lake e um snapshot por login, sem
      --       versionamento). Portanto fk_department_code, fk_role_code,
      --       fk_region_code, fk_process_code, department_path e process_path
      --       refletem o cadastro de HOJE, nao o da data do evento. Para analise
      --       historica por departamento, use os campos de nome (dim), nao os codigos.
      -- ===================================================================
      employee_daily AS (
        SELECT
          employee,
          p2o_date,
          employee_alias,
          role_name,
          Region AS region_name,
          department_name,
          Department_1, Department_2, Department_3, Department_4, Department_5,
          Department_6, Department_7, Department_8, Department_9, Department_10
        FROM `fnk-bi-empresa.EMPRESA.dim_employee_daily_EMPRESA`
      ),
      
      employee_registry AS (
        SELECT * EXCEPT(rn)
        FROM (
          SELECT
            LOWER(employee) AS employee_key,
            fk_department_code,
            fk_process_code,
            fk_role_code,
            fk_region_code,
            department_path,
            process_path,
            active AS cadastro_ativo,
            ROW_NUMBER() OVER (
              PARTITION BY LOWER(employee)
              ORDER BY search_date DESC, creation_date DESC
            ) AS rn
          FROM `fnk-bi-empresa.EMPRESA.rel_hierarchy_employee_associative_EMPRESA`
        )
        WHERE rn = 1
      )
      
      SELECT
        new_bigquery_id AS event_id,
        p2o_username,
        p2o_hostname,
        -- hierarquia vigente na data (dim_employee_daily)
        d.employee_alias,
        d.department_name,
        d.role_name,
        d.region_name,
        d.Department_1, d.Department_2, d.Department_3, d.Department_4, d.Department_5,
        d.Department_6, d.Department_7, d.Department_8, d.Department_9, d.Department_10,
        -- codigos e paths do cadastro atual (rel_hierarchy_employee_associative)
        r.fk_department_code,
        r.fk_role_code,
        r.fk_region_code,
        r.fk_process_code,
        r.department_path,
        r.process_path,
        e.p2o_date,
        FORMAT_DATE('%Y-%m', e.p2o_date) AS mes,
        CASE EXTRACT(DAYOFWEEK FROM e.p2o_date)
          WHEN 1 THEN 'Domingo'
          WHEN 2 THEN 'Segunda-feira'
          WHEN 3 THEN 'Terça-feira'
          WHEN 4 THEN 'Quarta-feira'
          WHEN 5 THEN 'Quinta-feira'
          WHEN 6 THEN 'Sexta-feira'
          WHEN 7 THEN 'Sábado'
        END AS dia_semana,
        p2o_datetime_in,
        p2o_datetime_out,
        p2o_entry,
        p2o_exit,
        app_name,
        app_class,
        app_class_id,
        title_windows_clean,
        p2o_url_domain,
        p2o_app_exe,
        p2o_app_exe_fullname,
        p2o_IsWorkstationLocked,
        p2o_url_domain_corporate,
        place_day,
        CAST(p2o_total_seconds AS INT64) AS p2o_total_seconds,
        CAST(p2o_total_idle AS INT64) AS p2o_total_idle,
        CASE
          WHEN REGEXP_CONTAINS(
            IF(p2o_IsWorkstationLocked, 'OutPC',
               IF(p2o_url_domain_corporate < 55, p2o_url_domain,
                  IFNULL(NULLIF(p2o_app_exe_fullname, ''), LOWER(p2o_app_exe)))),
            r'(?i)excel|powerpoint|word|docs.google|analytics.google')
          THEN 'Analítico'
          WHEN IF(p2o_IsWorkstationLocked, 'OutPC',
                 IF(p2o_url_domain_corporate < 55, p2o_url_domain,
                    IFNULL(NULLIF(p2o_app_exe_fullname, ''), LOWER(p2o_app_exe)))) = 'OutPC'
          THEN 'Relacionamento'
          WHEN REGEXP_CONTAINS(
            IF(p2o_IsWorkstationLocked, 'OutPC',
               IF(p2o_url_domain_corporate < 55, p2o_url_domain,
                  IFNULL(NULLIF(p2o_app_exe_fullname, ''), LOWER(p2o_app_exe)))),
            r'(?i)whatsapp|mail|outlook|chat|hangout|teams|jabber|skype|zoom|meet|telegram|whereby|discord|bluejeans|webex')
          THEN 'Comunicação'
          WHEN REGEXP_CONTAINS(
            IF(p2o_IsWorkstationLocked, 'OutPC',
               IF(p2o_url_domain_corporate < 55, p2o_url_domain,
                  IFNULL(NULLIF(p2o_app_exe_fullname, ''), LOWER(p2o_app_exe)))),
            r'(?i)rstudio|datastudio|qlik|tableau|embarcadero|power bi|looker|visual studio|jupyter|colab.research|notepad\+\+|atom|github|comandos|command|sourcetree|powershell|developers|cloud.google|aws|localhost')
            OR REGEXP_CONTAINS(IFNULL(p2o_title_windows, ''), r'(?i)power bi|jupyter')
          THEN 'Data e Desenvolvimento'
          ELSE 'Transacional'
        END AS profile_name_event
      
      FROM events_with_place_day e
      LEFT JOIN employee_daily d
        ON LOWER(e.p2o_username) = d.employee
        AND e.p2o_date = d.p2o_date
      LEFT JOIN employee_registry r
        ON LOWER(e.p2o_username) = r.employee_key
      

📖 Dicionário de Dados — Data Lake BI