"""Chaves naturais de conta/linha do Relatório Contábil. Python puro (sem ORM), como todo o resto deste pacote, porque a mesma regra precisa servir a três consumidores que não podem divergir entre si: a sincronização do reprocessamento (`_contabil_sincroniza_*()` em `views.py`), o histórico de observações (`ContabilObservacao.alvo_chave`) e os mapas de âncora do relatório HTML. Quando essas três definições de "é a mesma linha" divergem, o sintoma é silencioso: linha duplicada que sobrevive ao reprocessamento, ou observação que aparece numa conta que não é a dela. **A chave da DRE/Análise Vertical inclui o caminho na árvore**, não só a descrição e o nível: o mesmo rótulo aparece em ramos diferentes no mesmo nível (caso real: "DESPESAS COM PESSOAL" sob "DESPESAS DE VENDAS" e sob "DESPESAS ADMINISTRATIVAS"), e o par `(descricao, nivel)` sozinho não distinguia os dois. Mesmo espírito da descrição ter entrado na chave do Balancete quando se descobriu que o Questor reaproveita o mesmo código de classificação entre contas analíticas irmãs. """ from typing import Protocol, Sequence # Separador entre os rótulos dos grupos ancestrais dentro do caminho. Só # precisa ser algo que não apareça numa descrição de conta do Questor; o # `|` já é o separador entre caminho e nível. SEPARADOR_CAMINHO = " > " class LinhaComNivel(Protocol): """Qualquer linha de DRE/Análise Vertical — serve tanto para os models (`ContabilLinhaDre`/`ContabilLinhaAnaliseVertical`) quanto para as dataclasses de extração (`modelos.LinhaDreExtraida`/ `LinhaAnaliseVerticalExtraida`), que não compartilham base nenhuma.""" descricao: str nivel: int def nivel_normalizado(nivel: int) -> int: """Nível nunca negativo — o parser deriva o nível da posição horizontal do primeiro caractere (ver `parser.py`), então uma linha ligeiramente à esquerda da raiz poderia sair em -1. `views.py` e `dashboard-contabil.js` já normalizavam com `max(0, ...)` ao montar a árvore; a chave precisa da mesma normalização para não depender do arredondamento.""" return max(0, nivel) def caminhos_linhas(linhas: Sequence[LinhaComNivel]) -> list[str]: """Caminho de cada linha na árvore, alinhado por posição com `linhas` (que precisa vir na ordem de leitura do PDF, `ordem` crescente). O caminho é a descrição dos grupos ancestrais mais a da própria linha, unidas por `SEPARADOR_CAMINHO` — a mesma pilha de níveis usada por `regras._indices_descendentes_de_conta_redutora()`, e não comparação de prefixo de texto. Um nível pulado (o PDF vai do 0 direto para o 2) deixa um buraco na pilha, descartado do caminho: o que importa é a sequência de ancestrais reais, não a profundidade numérica. """ caminhos: list[str] = [] pilha: list[str] = [] for linha in linhas: nivel = nivel_normalizado(linha.nivel) del pilha[nivel:] while len(pilha) < nivel: pilha.append("") pilha.append(linha.descricao) caminhos.append(SEPARADOR_CAMINHO.join(parte for parte in pilha if parte)) return caminhos def chave_conta(codigo: str, descricao: str) -> str: """Chave natural de uma conta do Balancete. `codigo` sozinho **não** é único (o Questor reaproveita a mesma classificação entre contas analíticas de mesma natureza, ex. seis bancos diferentes sob o código de "Depósitos Bancários à Vista"), por isso a descrição entra junto.""" return f"{codigo}|{descricao}" def chave_linha(caminho: str, nivel: int) -> str: """Chave natural de uma linha da DRE/Análise Vertical, a partir do caminho devolvido por `caminhos_linhas()`. O nível continua na chave mesmo sendo quase sempre derivável do caminho: um mesmo rótulo pode aparecer duas vezes no mesmo ramo em níveis diferentes.""" return f"{caminho}|{nivel_normalizado(nivel)}" def chaves_linhas(linhas: Sequence[LinhaComNivel]) -> list[str]: """`chave_linha()` de cada linha, alinhada por posição com `linhas` — o atalho que todos os chamadores usam, já que a chave de uma linha nunca é calculável isoladamente, só no contexto da árvore inteira.""" caminhos = caminhos_linhas(linhas) return [chave_linha(caminho, linha.nivel) for caminho, linha in zip(caminhos, linhas)]