Ponto de partida
Sete decisões
A mecânica da árvore da v1.0 fica inteira. Caminho materializado com separador nas duas pontas, código imutável, ancestrais derivados sem consulta, duas portas de status com cascata assimétrica, path como token de concorrência otimista, uma linha de auditoria por cascata. Isso está acima do que Oracle, Coupa e Protheus entregam. Refazer seria destruir a única parte que já estava certa.
managerUserId morre; nasce CostCenterAssignment. Papel, vigência, múltiplos responsáveis e alçada opcional. Um campo único não sabe responder quem era o gestor em março — e essa é exatamente a pergunta que uma auditoria de aprovação faz.
O nó pertence a uma LegalEntity, ou é corporativo. legalEntityId nulo significa "vale para todas as empresas do tenant". Contabilidade, orçamento e nota fiscal são por CNPJ; sem esse campo, grupo econômico duplica a árvore inteira — que é o que a v1.0 registrou como limitação e nunca resolveu.
O centro de custo tem prazo. validFrom e validTo. Obra, projeto, evento e safra terminam, e ninguém desativa na data certa. Vigência é fato de calendário; isActive continua sendo decisão administrativa. São eixos diferentes e ambos existem.
Só folha recebe lançamento, por padrão. allowsPosting nasce verdadeiro em folha e falso em nó com filhos. É a regra contábil de qualquer razão: nó intermediário existe para consolidar, não para receber. O tenant pode abrir exceção, nó a nó, e assumir o custo do relatório que não fecha.
Rateio nasce agora, mesmo sem documento para ratear. CostAllocation é value object da linha, com regra de arredondamento fechada. Rateio acrescentado depois obriga a reescrever toda linha de requisição, pedido, recebimento e nota — e no Brasil ele não é opcional.
A regra de aprovação guarda o nó e casa por prefixo de path. Regra em /TI/ alcança /TI/INFRA/REDE/ por rollup, com override no descendente. A coluna que torna isso uma comparação de string em vez de uma recursão já existe — a v1.0 só nunca a ofereceu ao motor.
Continuidade
O que sobrevive da v1.0
Validado contra nexio-service · 17ed568. Nada aqui é reescrito por gosto.
| Da v1.0 | Veredicto | Por quê |
|---|---|---|
| Caminho materializado | mantém | Subárvore, ancestrais e breadcrumb sem recursão, com o padrão do LIKE escapado e igualdade para ancestrais. Continua sendo a decisão de engenharia mais forte do produto. |
| Código imutável e normalizado | mantém | É o que torna o caminho estável. Se o código mudasse, todo path da subárvore mudaria junto. |
| Duas portas de status | mantém | Ativar verifica para cima, desativar verifica para baixo. A assimetria é o que impede galho ativo inalcançável e fluxo apontando para nó desligado. |
path como token de concorrência | mantém | Resolve dois moves simultâneos sem coluna de versão. Mantém-se, e agora protege também as vigências. |
| Uma auditoria por cascata | mantém | Registro no nó disparado com affectedCount e pathPrefix. Cinquenta mil descendentes continuam custando um registro. |
| 404 para id de outro tenant | mantém | 403 confirmaria a existência do registro. |
| Profundidade livre | mantém | Livre no armazenamento, teto opcional na política do tenant — padrão Oracle. E o catálogo deve seguir este, não o contrário: o maxDepth do CategoryNode passa a ser política, não invariante. |
managerUserId | substitui | Vira CostCenterAssignment. Sem vigência não há histórico, e sem histórico a trilha de aprovação mente sobre quem tinha autoridade na data. |
| Herança de gestor "por padrão" | substitui | Passa a ser resolução por rollup, nunca cópia. Cópia faz a troca do responsável da área não alcançar as sub-áreas, em silêncio. |
Activate() / Deactivate() no agregado | remove | Divergência 2 da validação: nenhum handler os chama, são assimétricos entre si e sobrevivem só como fixture. A transição real é set-based no repositório. |
matchedFields com isActive | remove | Divergência 4: está presente em 100% das linhas sempre que o filtro existe. Não destaca nada. |
Ausência de FK para approval_flows | corrige | Divergência 3. Como a configuração de aprovação é reescrita do zero, a FK nasce junto — e o advisory lock por empresa volta a ser um FOR UPDATE comum sobre a subárvore. |
manager: null ambíguo | corrige | Divergência 5. A resposta passa a trazer owners[] com estado explícito por responsável. A inferência sai da tela. |
| POST e PUT com forma diferente do detalhe | corrige | Divergência 6. Toda escrita responde o detalhe completo, como o PATCH /parent já fazia. |
Fundamentos
Seis princípios
1. O centro de custo responde uma pergunta só
Quem paga. Ele não é conta contábil, não é projeto, não é verba, não é aprovador e não é unidade de negócio. Cada uma dessas é um eixo com dono próprio, e fundi-las é exatamente o que faz o cadastro de centro de custo de ERP brasileiro chegar a quarenta campos que ninguém preenche.
2. Quem paga vem da estrutura, nunca do item
A categoria sugere a conta contábil. O ItemType e o predominantUse sugerem a destinação — CAPEX ou OPEX. Nenhum dos dois sugere centro de custo. Ele vem do requisitante, do local de entrega ou de um rateio declarado. Deduzir centro de custo do que está sendo comprado é como deduzir o departamento a partir da marca do notebook.
3. Toda referência a pessoa tem vigência
Um vínculo entre nó e usuário nasce com validFrom e termina com validTo. Nunca se edita para o passado: trocar de responsável é encerrar um vínculo e abrir outro. É o que permite responder "quem podia aprovar isto naquela data" sem reconstruir o estado do sistema.
4. A árvore é lida da linha, não caminhada
Herdado da v1.0 e inegociável. path guarda a cadeia de códigos, depth guarda o nível. Ancestrais saem da própria string; descendentes saem de um prefixo indexado. Nenhuma leitura de árvore custa uma consulta por nível.
5. Herança se resolve, não se copia
Responsável ausente no nó significa pergunte ao pai, subindo até a raiz. Copiar o valor na criação parece equivalente e não é: no dia em que o responsável da área muda, as sub-áreas continuam apontando para quem saiu, e nada no dado denuncia isso.
6. O motor de aprovação não caminha na árvore
O centro de custo publica quatro coisas — path, empresa, responsáveis vigentes e alçada acumulada — e o motor consome. A dependência é num sentido só: ApprovalPolicy conhece CostCenter, e CostCenter nunca conhece aprovação. É isso que permite a FK e mata o advisory lock.
Modelo
Mapa de entidades
ForRoot, ForChild, AncestorPaths, Codes. Não é entidade: é o vocabulário da coluna materializada.
managerUserId.
ParentId, caminho materializado, escopado por empresa e por vigência.
{centro de custo, conta, percentual} numa linha de documento. Vive na linha, não aqui.
Duas tabelas: admin.cost_centers e admin.cost_center_assignments. Os cinco índices da v1.0 permanecem; entram um único parcial em (TenantId, CostCenterId, UserId) filtrado por vínculo vigente e um EXCLUDE com btree_gist sobre (TenantId =, CostCenterId =, UserId =, período &&), que é o que impede dois vínculos sobrepostos da mesma pessoa no mesmo nó sem depender de checagem em memória.
Domínio · CostCenter
Campos
Legenda: obrig sempre · cond conforme a operação · opc opcional · gerado calculado pelo sistema ou pelo banco.
Identificação e conteúdo
| Campo | Tipo | Regra | |
|---|---|---|---|
| id | Guid | gerado | Atribuído pelo agregado, não pelo banco. |
| tenantId | Guid | gerado | Injetado a partir do token. Nunca aceito do cliente. |
| code | string(20) | obrig | Trim e maiúsculas, apenas [A-Za-z0-9_-], único por tenant, imutável. Herdado sem mudança. |
| name | string(160) | obrig | Trim. Vazio é 400. |
| description | string(500) | opc | Branco é normalizado para nulo. |
| externalCode | string(40) | opc | Novo. O código do mesmo centro de custo no ERP do cliente. Único por (tenant, empresa) quando preenchido. É por aqui que a integração casa as duas bases sem depender de nome. |
Posição, escopo e vigência
| Campo | Tipo | Regra | |
|---|---|---|---|
| parentId | Guid? | opc | Nulo na criação = raiz. Alterado apenas por PATCH /{id}/parent. |
| path | varchar | gerado | Cadeia de códigos com separador nas duas pontas: /TI/INFRA/. Token de concorrência. |
| depth | int | gerado | Raiz é 1; publicado como level = depth - 1. |
| legalEntityId | Guid? | opc | Novo. Empresa dona do nó. Nulo = corporativo, usável por todas. Um nó com empresa não aceita filho de empresa diferente; um nó corporativo aceita filho de qualquer empresa, e o filho passa a ser o dono do escopo. |
| validFrom | date | gerado | Novo. Default: data da criação. Não pode ser anterior ao validFrom do pai. |
| validTo | date? | opc | Novo. Nulo = sem prazo. Não pode ser posterior ao validTo do pai. Encerrar um pai encerra a subárvore na mesma data, em cascata. |
| allowsPosting | bool | gerado | Novo. Nasce true em folha e false quando o nó ganha o primeiro filho. Editável por exceção. Só nó com allowsPosting aceita rateio. |
| SearchText | text | gerado | Coluna GENERATED … STORED com índice GIN trigram. Sombra no modelo. Herdada. |
Estado e controle
| Campo | Tipo | Regra | |
|---|---|---|---|
| isActive | bool | gerado | Nasce true. Muda só por PATCH /activate e DELETE, em cascata. O corpo do PUT não o carrega. |
| isDeleted | bool | gerado | Filtro global. Nenhuma rota o liga; includeDeleted=true é recusado. |
| createdAt / updatedAt | datetime | gerado | Escritos pelo agregado e explicitamente pelas cascatas. |
Campos que só existem na resposta
| Campo | Onde | O que é |
|---|---|---|
| owners | listagem, detalhe | Substitui manager. Lista de {userId, name, role, status, approvalLimit, validFrom, validTo} dos vínculos vigentes na data da consulta. status é resolved, inactive ou unresolved — explícito, sem inferência na tela. |
| effectiveOwners | detalhe | Novo. Quem de fato responde por este nó, já com o rollup aplicado, mais o inheritedFrom apontando o nó de origem quando não é o próprio. É esta lista que o motor de aprovação consome. |
| hasChildren / childrenCount | listagem, detalhe | Filhos diretos, em leitura agrupada por página. Herdado. |
| descendantsCount | detalhe | Subárvore inteira sem contar o nó. Herdado. |
| path / pathLabel | detalhe, busca | Breadcrumb como lista de {id, code, name, isActive} e como texto. Herdado. |
| resultMode / level / matchedFields | listagem, busca | Herdados. matchedFields deixa de incluir isActive. |
| affectedCount | activate, delete, move | Mudanças, não nós. Herdado. |
Estrutura
Hierarquia e caminho
Seção herdada da v1.0 sem alteração de mecânica. Registrada aqui porque é o que sustenta as decisões 5, 6 e 7.
Separador nas duas pontas
/TI/ não é prefixo de /TIME/…, enquanto /TI seria. Sem o separador final, desativar TI derrubaria TIME junto. É seguro porque a normalização do código admite letras, dígitos, hífen e sublinhado — e nada mais.
Igualdade para ancestrais, LIKE escapado para descendentes
AncestorPaths("/TI/INFRA/REDE/") devolve /TI/ e /TI/INFRA/ sem tocar no banco. Descendentes são LIKE '/TI/INFRA/%' com o padrão escapado — _ é caractere legal num código, e sem escape um /TI_SUL/ inativo bloquearia o não relacionado /TIASUL/X/.
Profundidade livre no armazenamento, teto na política
Não há limite estrutural. O tenant pode declarar um teto em TenantSettings.costCenterMaxDepth, que é validado na criação e no move e pode ser levantado sem migração. É o padrão Oracle — e é o que o catálogo deve adotar, trocando o maxDepth de invariante para política.
O move não renumera código
Mover FIN-CP para dentro de TI leva FIN-CP-FORN junto e não muda um único código. Uma empresa cujos códigos imitam a hierarquia termina com códigos que leem errado — é aviso do diálogo de confirmação e regra de onboarding, não algo que a API possa consertar sem destruir a estabilidade do caminho.
Novo
Empresa e escopo
A v1.0 registrou como limitação: "TenantSettings tem um único CNPJ; grupo econômico com N CNPJs duplica a árvore inteira". Esta versão resolve o lado do centro de custo antes de a LegalEntity existir, porque é a chave que precisa nascer certa.
| Situação do nó | Filho permitido | Efeito |
|---|---|---|
Corporativo (legalEntityId nulo) | Corporativo ou de qualquer empresa | O filho define o próprio escopo. É assim que "Administrativo" corporativo tem "Administrativo · Matriz" e "Administrativo · Filial SP" abaixo. |
| De uma empresa | Só da mesma empresa | 422 LEGAL_ENTITY_MISMATCH_UNPROCESSABLE. Um nó de empresa não volta a ser corporativo depois de ter filhos. |
| Qualquer | — | Uma linha de documento da empresa A só pode apontar para nó corporativo ou nó da empresa A. Validado na linha, com o legalEntityId efetivo lido do nó. |
Por que não uma árvore por empresa. Grupo econômico brasileiro tem estrutura de custo quase idêntica entre CNPJs — mesma diretoria, mesmas áreas, códigos espelhados. Uma árvore por empresa multiplica manutenção por N e faz o relatório consolidado depender de casar códigos por convenção. Um nó corporativo com folhas por empresa entrega consolidação por construção, e é o desenho que Oracle e SAP usam para hierarquia de centro de custo em multi-empresa.
Novo · agregado filho
Responsáveis
CostCenterAssignment é a ligação entre um nó e uma pessoa, com papel e prazo. É a entidade que faz a aprovação por gestor de centro de custo ser auditável em vez de aproximada.
| Campo | Tipo | Regra | |
|---|---|---|---|
| costCenterId | Guid | obrig | FK real. Apagar o centro de custo não é possível — o vínculo acompanha a inativação. |
| userId | Guid | obrig | Validado contra a associação do usuário à empresa, como na v1.0. Usuário de outro tenant é 422; conta inativa da própria empresa é aceita. |
| role | enum | obrig | Owner · Deputy · Watcher. Owner responde e aprova; Deputy só entra quando não há Owner vigente no mesmo nó; Watcher recebe notificação e não aprova nunca. |
| approvalLimit | numeric(19,2)? | opc | Alçada própria da pessoa neste nó, na moeda do tenant. Nulo = sem limite próprio; quem decide é a política. É a ponte para o motor por alçada acumulada. |
| validFrom / validTo | date / date? | obrig / opc | Período fechado à esquerda e aberto à direita. Nulo = vigente sem prazo. |
| endedReason | string(200)? | opc | Preenchido no encerramento antecipado. Aparece na trilha. |
Como o responsável é resolvido
| # | Passo | Resultado |
|---|---|---|
| 1 | Owners vigentes no próprio nó, na data | Se houver um ou mais, é a resposta. Dois ou mais = co-gestão; quem decide se basta um é o modo do nível de aprovação, não o centro de custo. |
| 2 | Deputies vigentes no próprio nó | Só consultados quando o passo 1 volta vazio. Substituto declarado não concorre com titular. |
| 3 | Sobe ao pai e repete | Até a raiz. A resposta carrega inheritedFrom com o nó que de fato respondeu — a tela mostra "responsável herdado de Tecnologia". |
| 4 | Nada em toda a cadeia | Devolve lista vazia. Não é erro aqui — é o motor de aprovação que decide o que fazer, caindo no aprovador de reserva do processo. Recusar aqui travaria o cadastro de uma árvore recém-importada. |
Nunca se edita vigência para o passado
Trocar de responsável é encerrar um vínculo e abrir outro, nunca um UPDATE no userId. O PATCH /{assignmentId}/end aceita apenas data igual ou posterior a hoje. Editar o passado reescreveria a resposta de "quem tinha autoridade quando isto foi aprovado", que é a única pergunta que essa tabela existe para responder.
Sobreposição do mesmo papel é recusada no banco
A mesma pessoa não pode ter dois vínculos com períodos que se cruzam no mesmo nó. A garantia é uma restrição EXCLUDE com btree_gist sobre (TenantId, CostCenterId, UserId, período) — checagem em memória perde a corrida de dois POST simultâneos, que é exatamente o defeito já identificado na configuração de aprovação da v1.0.
Alçada não soma entre pessoas; soma entre níveis
Dois Owners com R$ 50 mil cada não aprovam R$ 100 mil juntos — cada um cobre até 50. O que acumula é a subida: se o nó não tem quem cubra o valor, o motor sobe ao pai e busca lá. Somar entre co-gestores criaria uma alçada que ninguém concedeu.
Delegação é outra coisa e continua sendo
CostCenterAssignment é quem responde pelo nó. ApprovalDelegation é quem cobre minhas férias. Uma é estrutura, a outra é ausência temporária de uma pessoa em todos os nós dela. Modelá-las na mesma tabela obrigaria a inventar um vínculo por centro de custo toda vez que alguém viaja.
Ciclo
Vigência e estado
São dois eixos que a v1.0 tinha colapsado num booleano. Vigência é fato de calendário — a obra acaba em dezembro. Estado é decisão administrativa — este centro de custo foi criado errado e não deve mais aparecer. Um nó pode estar ativo e fora de vigência, e a resposta certa é diferente em cada caso.
| Combinação | Aceita lançamento novo | Aparece no seletor | Resolve histórico |
|---|---|---|---|
| Ativo, dentro da vigência | sim | sim | sim |
| Ativo, fora da vigência | não — 422 | não, salvo busca explícita | sim |
| Inativo | não | não | sim |
Sem allowsPosting | não — só consolida | sim, como nó de navegação | sim |
Nenhuma das quatro combinações apaga o passado. Um documento fechado contra um centro de custo encerrado continua legível, continua somando no relatório do período em que foi lançado e continua resolvendo nome e caminho. O que muda é só a porta de entrada.
As duas portas de status da v1.0 permanecem, com uma precondição a mais em cada: PATCH /activate exige a cadeia de ancestrais ativa e vigente; DELETE continua bloqueado por política de aprovação apontando para a subárvore — agora com FK, então a checagem é um FOR UPDATE sobre a subárvore em vez de um advisory lock por empresa.
Gancho declarado
Eixo contábil
Este documento não especifica o plano de contas — isso é do módulo de controle financeiro. O que ele fixa é a forma da relação, para que a linha de documento nasça com a chave certa.
A linha de documento carrega dois identificadores contábeis: costCenterId (quem paga) e glAccountId (natureza do gasto). São perguntas diferentes e não se deduzem uma da outra. Um notebook comprado pelo RH e um notebook comprado pelo TI têm a mesma conta e centros de custo diferentes.
A conta é sugerida pela categoria, via um mapping set CategoryNode → GLAccount por tenant, amarrável em qualquer nível da árvore e valendo por rollup — padrão Oracle. Sugerida, não imposta: quem tem update:accounting pode trocar na linha, e a troca fica na trilha.
CAPEX ou OPEX sai do predominantUse do item, já decidido no Cadastro de Item — é o único campo fiscal que nasce no comprador. Ele restringe o conjunto de contas oferecidas; não escolhe o centro de custo.
Derivar centro de custo de conta contábil, de NCM, de categoria ou de item. Todo ERP que tentou isso terminou com uma tabela de exceções maior que a regra. O centro de custo vem de quem pede, de onde entrega ou de um rateio declarado — sempre de uma decisão humana registrada.
Novo · value object
Rateio
CostAllocation é uma fatia de uma linha de documento. A linha carrega de uma a N fatias; uma linha sem rateio explícito tem exatamente uma fatia de 100%, o que faz o caso simples e o caso rateado percorrerem o mesmo código.
| Campo | Tipo | Regra |
|---|---|---|
| order | int | 1..N. Define a ordem estável de exibição e o desempate do arredondamento. |
| costCenterId | Guid | Nó com allowsPosting, ativo, vigente e da empresa do documento (ou corporativo). |
| glAccountId | Guid? | Sugerida pela categoria da linha. Pode diferir entre fatias. |
| percent | numeric(9,6) | A soma das fatias é exatamente 100.000000. Seis casas cobrem rateio por área construída e por headcount, que são os dois que produzem dízima. |
| amount | numeric(19,2) | gerado Derivado do percentual sobre o valor da linha, com a regra de resíduo abaixo. Congelado na linha. |
O resíduo vai para a maior fatia, sempre
Três fatias de 33,333333% sobre R$ 100,00 dão R$ 33,33 cada e sobra R$ 0,01. A regra é: calcular cada fatia com ROUND_HALF_UP, apurar a diferença contra o total da linha e somá-la à fatia de maior amount — empate resolvido pelo menor order. É determinístico, o total sempre fecha, e a mesma linha recalculada dá o mesmo resultado. Distribuir o resíduo "por igual" produz centavos diferentes conforme a ordem de iteração, que é como nasce a diferença de um centavo entre o pedido e o razão.
Percentual é a fonte; valor é derivado e congelado
O usuário rateia em percentual — é assim que a decisão é tomada e é o que sobrevive a uma mudança de quantidade. O valor de cada fatia é calculado e gravado na linha. Se o valor da linha mudar, as fatias são recalculadas e o evento fica na trilha; se o percentual mudar depois de aprovado, a cadeia de aprovação é reavaliada.
A alçada é avaliada sobre a fatia, com limiar de materialidade
Um pedido de R$ 400 mil rateado 90/10 entre duas áreas não deve exigir o diretor da área que ficou com R$ 40 mil se a alçada dela cobre esse valor. Mas gerar um aprovador para uma fatia de 0,5% transforma toda compra num carrossel de assinaturas. Por isso existe um limiar por tenant — allocationMaterialityPercent e allocationMaterialityAmount, o que for maior — abaixo do qual a fatia não gera passo próprio e é absorvida pela maior. Nenhum concorrente pesquisado faz isso: o Protheus dispara a alçada pelo valor total, e Coupa e Ariba tratam rateio como dado contábil sem efeito no fluxo.
O rateio é da linha, não do documento
Linhas diferentes rateiam diferente — é o caso normal em pedido de material de consumo para três áreas. Rateio no cabeçalho parece mais simples e obriga a quebrar o pedido em três, que é o workaround que o comprador inventa e que destrói o histórico de negociação com o fornecedor.
Fronteiras
Relação com os demais domínios
Metade desta tabela é ausência de relação, e está escrita para que ninguém a invente depois.
| Domínio | Relação | Regra |
|---|---|---|
| Item | nenhuma direta | O item não tem centro de custo. Quem tem é a linha do documento. Um item comprado por cinco áreas continua sendo um item. |
| ItemType | indireta | Dirige a destinação padrão — CAPEX, OPEX, serviço — que restringe as contas oferecidas na linha. Nunca o centro de custo. |
| CategoryNode | eixo ortogonal | Categoria responde o que é e de que mercado vem; centro de custo responde quem paga. Nunca derivar um do outro. Mas os dois são árvores e devem compartilhar a mecânica: caminho materializado, rollup e match por prefixo. Hoje o produto tem duas modelagens para a mesma forma — e a boa é esta. |
| UnitOfMeasure | nenhuma | Unidade é propriedade da linha e do item. Não há conversão por centro de custo, nem unidade padrão por área. |
| Fornecedor · Estabelecimento | nenhuma | Restrição de fornecedor é por categoria e por homologação. "Esta área só compra do fornecedor X" é regra de aprovação, não vínculo de cadastro. |
| ItemSupplier | nenhuma | Preço não muda por centro de custo. Se mudar, é contrato — outra entidade. |
| LegalEntity | dono do nó | legalEntityId no nó, nulo = corporativo. Ver Empresa e escopo. |
| GLAccount | eixo irmão | A linha carrega os dois. A conta é sugerida pela categoria; nenhuma das duas manda na outra. |
| Budget | chave composta | A verba é por (empresa, nó, conta?, período). O nó entra na chave; a árvore permite consolidar verba de sub-área na área por rollup. |
| ApprovalPolicy | consome | Critério por nó com rollup, responsável resolvido por rollup, alçada acumulada na subida. Ver a seção seguinte. |
| Requisição · Cotação · Pedido · Recebimento · Nota | futuro | Toda linha carrega CostAllocation[]. O cabeçalho carrega a empresa. Nenhum documento aponta para centro de custo fora do rateio. |
Contrato
O que a aprovação consome
O centro de custo expõe quatro capacidades ao motor, e nenhuma delas obriga o motor a caminhar na árvore. Esta é a fronteira que substitui o GET /{id}/approval-flows da v1.0, onde a dependência corria no sentido errado.
| Capacidade | Assinatura | Como é servida |
|---|---|---|
| Casar por subárvore | matches(policyNodePath, lineNodePath) | Comparação de prefixo sobre o path já materializado, com o padrão escapado. A política guarda o nó, e sub-áreas criadas depois passam a ser cobertas sem tocar na regra. |
| Resolver responsáveis | effectiveOwners(nodeId, at) | Owners vigentes na data, com rollup e inheritedFrom. Uma consulta: os caminhos ancestrais são derivados da string e resolvidos por igualdade. |
| Acumular alçada | authorityFor(nodeId, amount, at) | Sobe a cadeia devolvendo o primeiro nó cujos Owners vigentes cobrem o valor, ou vazio. É o que permite o nível "sobe até quem tem alçada" sem N consultas. |
| Declarar a empresa | legalEntityOf(nodeId) | Do próprio nó, ou nulo para corporativo. Entra no critério da política e na validação da linha. |
A FK que faltava nasce aqui. Com ApprovalPolicy.costCenterId → cost_centers.id, a inserção de uma política toma FOR KEY SHARE na linha do nó, e a desativação em cascata passa a poder tomar FOR UPDATE sobre a subárvore. O pg_advisory_xact_lock por empresa da v1.0 — que só funcionava porque as escritas de aprovação também o tomavam, e que era decorativo se alguém esquecesse essa segunda metade — deixa de ser necessário.
Novo
Importação
A v1.0 registrou como fora de escopo: uma árvore grande entra por POST, um por nó, respeitando a ordem pai-antes-do-filho. Na prática isso significa que o onboarding de um cliente com 800 centros de custo é um script que alguém escreve, erra e repete.
O arquivo chega em qualquer ordem, com o pai referenciado por código, não por id — que é a única coluna que o cliente tem na planilha dele. O servidor ordena, detecta ciclo e recusa o lote inteiro nomeando o ciclo.
POST /cost-centers/import?mode=validate devolve o relatório linha a linha sem escrever nada. A tela só habilita a execução depois de um dry-run limpo. Importação parcial não existe: ou o lote inteiro entra, ou nada entra.
Reenviar o mesmo arquivo atualiza nome, descrição e responsáveis dos nós existentes e cria os que faltam. Como o código é imutável, ele é a chave natural de conciliação — e é também o que permite reimportar depois de corrigir a planilha.
Colunas opcionais de e-mail do responsável e vigência criam os CostCenterAssignment junto. Importar a árvore e depois amarrar 800 responsáveis à mão é o passo em que todo onboarding trava.
Camada API
Endpoints
Prefixo api/v{version}/nexio. Todas exigem autenticação e declaram exatamente uma permissão.
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /cost-centers | read:cost-centers | PagedResult<CostCenterDto> | 400 |
| GET /cost-centers/lookup | read:cost-centers | array puro | 400 |
| GET /cost-centers/{id} | read:cost-centers | CostCenterDetailDto | 404 |
| GET /cost-centers/{id}/children | read:cost-centers | PagedResult<CostCenterDto> | 400 · 404 |
| GET /cost-centers/{id}/history | read:cost-centers | PagedResult<AuditLogDto> | 400 · 404 |
| POST /cost-centers | create:cost-centers | CostCenterDetailDto | 400 · 409 · 422 |
| PUT /cost-centers/{id} | update:cost-centers | CostCenterDetailDto | 400 · 404 · 409 · 422 |
| PATCH /cost-centers/{id}/parent | update:cost-centers | CostCenterDetailDto | 400 · 404 · 409 · 422 |
| PATCH /cost-centers/{id}/activate | update:cost-centers | CostCenterStatusChangeDto | 404 · 422 |
| DELETE /cost-centers/{id} | delete:cost-centers | CostCenterStatusChangeDto | 404 · 409 |
| Rota | Permissão | O que faz |
|---|---|---|
| GET /cost-centers/{id}/assignments | read:cost-centers | Vínculos do nó. ?at= para uma data, ?includeEnded=true para o histórico completo. |
| GET /cost-centers/{id}/owners | read:cost-centers | Responsáveis efetivos com rollup e inheritedFrom. É o que o motor de aprovação chama. |
| POST /cost-centers/{id}/assignments | cost-center.assignment:manage | Abre um vínculo. Sobreposição do mesmo usuário e papel é 409. |
| PATCH /cost-centers/{id}/assignments/{assignmentId}/end | cost-center.assignment:manage | Encerra com data e motivo. Data no passado é 422. |
| POST /cost-centers/import | cost-center:import | ?mode=validate ou ?mode=commit. Tudo ou nada. |
| GET /cost-centers/{id}/policies | read:cost-centers | Substitui /approval-flows. Resumo das políticas que alcançam este nó — inclusive as herdadas de ancestrais, que é a informação que a v1.0 não conseguia dar. |
Duas correções de contrato da v1.0 entram aqui. O 409 CONCURRENT_CHANGE_CONFLICT passa a ser declarado no OpenAPI de todas as escritas rastreadas — a divergência 1 fazia um cliente gerado desconhecer um caminho que a API de fato responde. E POST e PUT passam a devolver o detalhe completo, com path, level, owners e pathLabel, em vez de uma forma que obrigava a tela a buscar de novo.
Contrato
Contrato de erros
O status HTTP é derivado do sufixo do código, nunca da mensagem. _NOT_FOUND → 404 · _CONFLICT e _ALREADY_EXISTS → 409 · _UNPROCESSABLE → 422 · sem sufixo → 400.
| Código | HTTP | Quando |
|---|---|---|
| COST_CENTER_NOT_FOUND | 404 | Não existe neste tenant — inclusive quando existe em outro |
| COST_CENTER_CODE_ALREADY_EXISTS | 409 | Código já usado. A mensagem carrega o código |
| COST_CENTER_DEPENDENCIES_CONFLICT | 409 | Desativação com política de aprovação ativa na subárvore |
| COST_CENTER_CONCURRENT_CHANGE_CONFLICT | 409 | A linha não carrega mais o path em que foi lida |
| COST_CENTER_ASSIGNMENT_OVERLAP_CONFLICT | 409 | Novo. Mesmo usuário e papel com períodos sobrepostos no mesmo nó |
| COST_CENTER_PARENT_UNPROCESSABLE | 422 | Pai informado não existe |
| COST_CENTER_CYCLIC_PARENT_UNPROCESSABLE | 422 | Destino é o próprio nó ou está dentro da sua subárvore |
| COST_CENTER_PARENT_INACTIVE_UNPROCESSABLE | 422 | Criar, mover ou ativar sob ramo inativo |
| COST_CENTER_DEPTH_EXCEEDED_UNPROCESSABLE | 422 | Novo. Acima do teto declarado pelo tenant. Política, não estrutura |
| COST_CENTER_LEGAL_ENTITY_MISMATCH_UNPROCESSABLE | 422 | Novo. Filho de empresa diferente do pai, ou linha apontando para nó de outra empresa |
| COST_CENTER_VALIDITY_OUTSIDE_PARENT_UNPROCESSABLE | 422 | Novo. Vigência do filho excede a do pai |
| COST_CENTER_NOT_EFFECTIVE_UNPROCESSABLE | 422 | Novo. Lançamento em nó fora da vigência |
| COST_CENTER_POSTING_NOT_ALLOWED_UNPROCESSABLE | 422 | Novo. Rateio apontando para nó de consolidação |
| COST_CENTER_ASSIGNMENT_USER_UNPROCESSABLE | 422 | Novo. Usuário não é membro desta empresa, ou não existe. As duas coisas de propósito |
| COST_CENTER_ASSIGNMENT_PERIOD_UNPROCESSABLE | 422 | Novo. Encerramento com data no passado, ou validTo antes de validFrom |
| COST_CENTER_IMPORT_CYCLE_UNPROCESSABLE | 422 | Novo. Ciclo no arquivo. A mensagem nomeia os códigos do ciclo |
| COST_CENTER_CODE_REQUIRED · _TOO_LONG · _NOT_ALPHANUMERIC | 400 | Validações de código, herdadas |
| COST_CENTER_NAME_REQUIRED · _TOO_LONG | 400 | Nome em branco ou acima de 160 |
| COST_CENTER_DESCRIPTION_TOO_LONG | 400 | Acima de 500 |
| COST_CENTER_SEARCH_TERM_TOO_LONG | 400 | Filtro de texto acima de 100 caracteres |
| SORT_FIELD_INVALID · INCLUDE_DELETED_UNSUPPORTED · SEARCH_UNSUPPORTED | 400 | Parâmetros que não podem ser honrados são recusados, nunca ignorados |
O erro continua sem payload estruturado — code, traceId, type e a mensagem resolvida pelo servidor. Onde a tela precisa de dados para agir, existe endpoint que os entrega antes: /policies antes de desativar, /owners antes de aprovar, dry-run antes de importar.
Autorização
Permissões
| Permissão | Situação | Cobre |
|---|---|---|
| read:cost-centers | herdada | Listagem, árvore, detalhe, autocomplete, histórico, vínculos, responsáveis efetivos e políticas que alcançam o nó |
| create:cost-centers | herdada | Criação de nó |
| update:cost-centers | herdada | Edição, move e ativação |
| delete:cost-centers | herdada | Desativação em cascata |
| cost-center.assignment:manage | nova | Abrir e encerrar vínculos de responsável. Separada de update de propósito: quem organiza a árvore não é necessariamente quem decide quem aprova o dinheiro dela |
| cost-center:import | nova | Importação em lote. Cria e altera centenas de nós numa chamada — merece guarda própria |
Um endpoint declara exatamente uma permissão e nada é implicado por outra. A permissão manage:cost-centers da v1.0 continua removida.
Rastreabilidade
Regras de negócio
Código normalizado, único por tenant e imutável
Trim, maiúsculas, apenas [A-Za-z0-9_-], até 20 caracteres, único por tenant. Nenhuma rota o altera depois da criação.
Sem pai é raiz; com pai é nó interno
Não há tipo e não há flag: a distinção é parentId nulo. Nenhuma nomenclatura fixa de "área" e "sub-área" no domínio — a tela nomeia como o cliente chama.
Profundidade é livre no armazenamento e limitada por política
TenantSettings.costCenterMaxDepth é opcional, validado na criação e no move, e pode ser levantado sem migração.
O caminho é derivado do pai, na criação e no move
O agregado recebe a entidade pai, não o id. Nenhum outro caminho escreve path e depth.
Um nó nasce ativo e vigente
Não há rascunho nem aprovação de cadastro. Criar sob ramo inativo ou fora de vigência é 422, com a cadeia inteira verificada.
isActive muda por exatamente duas rotas
PATCH /activate e DELETE. O corpo do PUT não carrega o campo.
Ativar exige a cadeia de ancestrais ativa e vigente
Verificada até a raiz por igualdade sobre os caminhos derivados. A recusa não nomeia o ancestral — o breadcrumb já carrega o estado de cada passo.
Desativar cascateia e é bloqueado por política ativa na subárvore
Só política ativa bloqueia. A recusa é 409 com a contagem, e nada é escrito. Com a FK, a garantia é FOR UPDATE sobre a subárvore.
affectedCount conta mudanças, não nós
Um nó já no estado alvo não é tocado nem contado. Ativar uma árvore já ativa responde zero.
O move leva a subárvore e não muda código nenhum
Nem o do nó, nem o dos descendentes. Mover para dentro da própria subárvore é 422; mover para o pai atual responde 200 sem escrever nem auditar.
Escrita a partir de um caminho vencido é 409
path é token de concorrência, então vale para toda escrita rastreada — inclusive um PUT que só troca o nome. A resposta é reler e repetir.
O nó pertence a uma empresa ou é corporativo
Nó com empresa não aceita filho de empresa diferente. Nó corporativo aceita filho de qualquer empresa, e o filho define o escopo dali para baixo.
Vigência do filho não excede a do pai
Validado na criação, na edição e no move. Encerrar um pai encerra a subárvore na mesma data, em cascata, com um registro de auditoria no nó disparado.
Fora da vigência não entra lançamento novo, mas o histórico continua legível
Vigência é fato de calendário e isActive é decisão administrativa. Nenhuma das duas apaga documento fechado.
Só nó com allowsPosting recebe rateio
Nasce verdadeiro em folha, falso ao ganhar o primeiro filho. O tenant pode abrir exceção nó a nó.
Responsável é vínculo com papel e vigência, nunca campo
Owner, Deputy ou Watcher. Vários por nó. Trocar de responsável é encerrar um vínculo e abrir outro.
Vínculos sobrepostos do mesmo usuário e papel são recusados no banco
Restrição EXCLUDE com btree_gist. Checagem em memória perde a corrida de dois POST simultâneos.
Vigência não se edita para o passado
Encerramento aceita apenas data igual ou posterior a hoje. É o que preserva a resposta de "quem tinha autoridade naquela data".
Responsável ausente resolve subindo a árvore
Owners do nó; se vazio, Deputies do nó; se vazio, o pai, até a raiz. A resposta carrega inheritedFrom. Cadeia inteira vazia devolve lista vazia — não é erro aqui.
Alçada não soma entre pessoas do mesmo nó
Dois Owners de R$ 50 mil cobrem até 50 cada. O que acumula é a subida na árvore.
Usuário de outra empresa é 422; conta inativa da própria empresa é aceita
Inativo é estado da conta, não referência errada. Recusar tornaria um nó insalvável por causa do login de alguém.
A soma das fatias de rateio é exatamente 100%
Percentual com seis casas. Linha sem rateio explícito tem uma fatia de 100% — o caso simples e o rateado percorrem o mesmo código.
O resíduo do arredondamento vai para a maior fatia
ROUND_HALF_UP em cada fatia, diferença apurada contra o total da linha e somada à fatia de maior valor; empate pelo menor order. Determinístico, e o total sempre fecha.
Fatia abaixo do limiar de materialidade não gera aprovador próprio
Limiar por tenant, em percentual e em valor, o que for maior. A fatia imaterial é absorvida pela maior para efeito de alçada — e continua contabilizada integralmente.
Uma cascata escreve um registro de auditoria, no nó disparado
Com operation, affectedCount e pathPrefix — mais previousPath num move e endedOn numa cascata de vigência. Descendente varrido não ganha registro próprio.
Id de outra empresa responde 404
Vale para toda rota. 403 confirmaria que o registro existe em algum lugar.
Importação é tudo ou nada, com dry-run antes
Ordenação topológica no servidor, pai referenciado por código, idempotente por código, ciclo recusado nomeando os códigos envolvidos.
Parâmetro que não pode ser honrado é recusado, não ignorado
includeDeleted=true, search onde nada o consome, sortBy fora do mapa e managerUserId que não é GUID continuam sendo 400.
Execução
Migração da v1.0
Não há dado transacional. O que existe é a árvore de desenvolvimento e os testes.
| Mudança | Tipo | Nota |
|---|---|---|
cost_center_assignments | tabela nova | Um vínculo Owner com validFrom = data da migração para cada managerUserId preenchido. Depois a coluna cai. |
legalEntityId, validFrom, validTo, allowsPosting, externalCode | colunas novas | Empresa nula, vigência aberta, allowsPosting calculado da contagem de filhos. |
FK approval_policies.cost_center_id | restrição nova | Nasce com a tabela de políticas. O levantamento de órfãos que a v1.0 esperava deixa de ser necessário — a tabela antiga é descartada. |
EXCLUDE de sobreposição | restrição nova | Exige btree_gist. As extensões pg_trgm e unaccent já eram o preço da busca. |
CostCenter.Activate() / Deactivate() | remoção | Divergência 2. Os testes que os usam como fixture passam a montar o estado pelo repositório. |
manager no DTO | quebra de contrato | Vira owners[] e effectiveOwners[]. O frontend já vai ser reescrito pela poda — é o momento sem custo. |
GET /{id}/approval-flows | renomeada | Vira /policies e passa a incluir as políticas herdadas de ancestrais. |
Limites
Fora de escopo
- Plano de contas —
GLAccounte o mapping set da categoria. A chave já nasce na linha. - Orçamento e empenho —
Budgetpor empresa × nó × conta × período. A árvore já permite o rollup da verba. - Empresa e filial —
LegalEntity. OlegalEntityIdjá está no nó. - Centro de lucro e resultado por área — outro eixo, outra árvore, outro documento.
- Projeto como entidade própria. Obra e projeto são centros de custo com vigência. Uma entidade separada duplicaria a árvore, o rateio e a alçada por um ganho que só aparece em gestão de portfólio.
- Exclusão física e restauração.
isDeletedsegue no armazenamento e nada o expõe. - Renumeração de código no move. Destruiria a estabilidade do caminho. É aviso de tela e regra de onboarding.
- Múltiplas hierarquias sobre os mesmos nós. Oracle mantém três; o Nexio mantém uma até haver um segundo consumidor real.
Pendência que fica registrada: se a LegalEntity for especificada com estrutura própria de filiais, decidir se legalEntityId no nó aponta para a empresa ou para o estabelecimento. Apontar para o estabelecimento é mais preciso fiscalmente e multiplica a árvore por CNPJ; apontar para a empresa mantém a árvore enxuta e empurra a dimensão de filial para o documento. A recomendação inicial é empresa no nó, estabelecimento no documento — mas é decisão a tomar junto com aquela spec, não antes.