Especificação · Admin

Centro de Custo

A árvore que responde quem paga. A v1.0 acertou a mecânica da hierarquia e errou o que fica em volta dela: um gestor num campo, sem vigência, sem empresa, sem conta contábil e sem rateio. Esta versão preserva a mecânica inteira e reconstrói o entorno antes que exista documento transacional para migrar.

substitui NEXIO_Admin_CentroDeCusto_v1.0 base para Fluxo de Aprovação alinhada a Empresa e Estabelecimento 2 agregados · 1 value object · 4 ganchos declarados v2.15 · 13/09/2026

Ponto de partida

Sete decisões

Decisão 1 · Herança

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 guarda contra move concorrente, 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.

Decisão 2 · Responsável

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.

Decisão 3 · Empresa

O nó pertence a uma Company, ou é corporativo. companyId 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.

Decisão 4 · Vigência

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.

Decisão 5 · Lançamento

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.

Decisão 6 · Rateio

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.

Decisão 7 · Aprovação

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.

Veredicto item a item
Da v1.0VeredictoPor quê
Caminho materializadomantémSubá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 normalizadomantémÉ o que torna o caminho estável. Se o código mudasse, todo path da subárvore mudaria junto.
Duas portas de statusmantémAtivar 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ênciasubstituiSó protegia move: código é imutável, então path só muda quando o nó ou um ancestral se move, e dois PUT de renome leem e enviam o mesmo path — o segundo sobrescrevia o primeiro em silêncio, inclusive vigência e conta. O token de toda escrita rastreada é version int + expectedVersion, o padrão que Item e Fornecedor já usam; path fica como segunda guarda, específica de move.
Uma auditoria por cascatamantémRegistro no nó disparado com affectedCount e pathPrefix. Cinquenta mil descendentes continuam custando um registro.
404 para id de outro tenantmantém403 confirmaria a existência do registro.
Profundidade livremantémLivre no armazenamento, teto opcional na política do tenant — padrão Oracle. E o catálogo seguiu este, não o contrário: o maxDepth do CategoryNode virou política, e a mecânica inteira da árvore é a mesma.
managerUserIdsubstituiVira 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"substituiPassa 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 agregadoremoveDivergê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 isActiveremoveDivergência 4: está presente em 100% das linhas sempre que o filtro existe. Não destaca nada.
Ausência de FK para approval_flowscorrigeDivergência 3. Como a configuração de aprovação é reescrita do zero, a FK nasce junto — e o advisory lock por empresa dá lugar à trava das linhas da subárvore (por exemplo, SELECT … FOR UPDATE, ou equivalente).
manager: null ambíguocorrigeDivergê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 detalhecorrigeDivergê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

CostCenterPath estático · regras ForRoot, ForChild, AncestorPaths, Codes. Não é entidade: é o vocabulário da coluna materializada.
CostCenterAssignment novo · N por nó Responsável com papel, vigência e alçada opcional. Substitui o campo managerUserId.
Company especificada · 1 por nó Empresa dona do nó. Nulo = corporativo. Especificada em Empresa e Estabelecimento — aponta para a empresa, nunca para o estabelecimento.
CategoryNode eixo ortogonal A outra árvore do produto. Nunca se deriva uma da outra — e as duas devem usar a mesma mecânica de caminho.
CostCenter aggregate root Auto-relacionado por ParentId, caminho materializado, escopado por empresa e por vigência.
GLAccount especificado · eixo irmão Conta contábil. Sugerida pelo item, depois pela categoria, e escolhida na linha. Não é filha do centro de custo nem o contrário. Especificada em Empresa e Estabelecimento.
CostAllocation value object · da linha O rateio: N fatias de {centro de custo, conta, percentual} numa linha de documento. Vive na linha, não aqui.
ApprovalPolicy agregado irmão Aponta para um nó e vale para a subárvore. Com FK — a dependência é num sentido só.
Budget futuro · gancho Verba por empresa × nó × conta × período, com empenho. Declarado para que a chave nasça certa; não especificado 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 uma exclusão de faixas sobre (TenantId =, CostCenterId =, UserId =, Role =, período &&) — EXCLUDE com btree_gist como exemplo de implementação, com verificação na aplicação onde o banco não oferecer equivalente —, que é o que impede dois vínculos sobrepostos da mesma pessoa no mesmo papel e 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

CampoTipoRegra
idGuidgeradoAtribuído pelo agregado, não pelo banco.
tenantIdGuidgeradoInjetado a partir do token. Nunca aceito do cliente.
codestring(20)obrigTrim e maiúsculas, apenas [A-Za-z0-9_-], único por tenant, imutável. Herdado sem mudança.
namestring(160)obrigTrim. Vazio é 400.
descriptionstring(500)opcBranco é normalizado para nulo.
externalCodestring(40)opcNovo. O código do mesmo centro de custo no ERP do cliente. Único por (tenant, empresa) quando preenchido, nulo colidindo com nulo — dois nós corporativos não repetem código (RN-CC-38). É por aqui que a integração casa as duas bases sem depender de nome.

Posição, escopo e vigência

CampoTipoRegra
parentIdGuid?opcNulo na criação = raiz. Alterado apenas por PATCH /{id}/parent.
pathvarchargeradoCadeia de códigos com separador nas duas pontas: /TI/INFRA/. Guarda de move concorrente; o token de escrita é version.
depthintgeradoRaiz é 1; publicado como level = depth - 1.
companyIdGuid?opcEmpresa 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. Imutável depois do primeiro uso — filho, vínculo ou uso declarado por outro módulo (RN-CC-36).
validFromdategeradoNovo. Default: data da criação. Não pode ser anterior ao validFrom do pai.
validTodate?opcNovo. Nulo = sem prazo. Não pode ser posterior ao validTo do pai. Encerrar um pai encerra a subárvore na mesma data, em cascata.
allowsPostingboolgeradoNovo. Nasce true em folha e false quando o nó ganha o primeiro filho. Editável por exceção. Só nó com allowsPosting aceita rateio.
SearchTexttextgeradoColuna GENERATED … STORED com índice GIN trigram como exemplo; onde não houver, coluna comum mantida pela aplicação. Sombra no modelo. Herdada.

Estado e controle

CampoTipoRegra
isActiveboolgeradoNasce true. Muda só por PATCH /activate e DELETE, em cascata. O corpo do PUT não o carrega.
versionintgeradoNasce 1 e incrementa a cada escrita rastreada. Token de concorrência otimista de toda escrita — PUT, PATCH /parent, /activate, DELETE e as escritas de vínculo exigem expectedVersion no corpo; divergência é 409 COST_CENTER_CONCURRENT_CHANGE_CONFLICT. Coluna inteira comum, sem tipo específico de banco. É o mesmo mecanismo de Item, Supplier, GLAccount, CategoryNode e Budget.
isDeletedboolgeradoFiltro global. Nenhuma rota o liga; includeDeleted=true é recusado.
createdAt / updatedAtdatetimegeradoEscritos pelo agregado e explicitamente pelas cascatas.

Campos que só existem na resposta

CampoOndeO que é
ownerslistagem, detalheSubstitui manager. Lista de {userId, name, role, status, approvalLimit, approvalLimitCurrency, hasUnlimitedAuthority, validFrom, validTo} dos vínculos vigentes na data da consulta. As três colunas de alçada vão sempre juntas: quem lê não precisa inferir de um nulo o que ele significa. status é resolved, inactive ou unresolved — explícito, sem inferência na tela.
effectiveOwnersdetalheNovo. 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 / childrenCountlistagem, detalheFilhos diretos, em leitura agrupada por página. Herdado.
descendantsCountdetalheSubárvore inteira sem contar o nó. Herdado.
path / pathLabeldetalhe, buscaBreadcrumb como lista de {id, code, name, isActive} e como texto. Herdado.
resultMode / level / matchedFieldslistagem, buscaHerdados. matchedFields deixa de incluir isActive.
affectedCountactivate, delete, moveMudanç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, e na v1 não há teto: a profundidade é livre. O teto existe como gancho declarado — costCenterMaxDepth, opcional, campo deste documento —, validado na criação e no move, levantável sem migração. É o padrão Oracle, e é o que o catálogo adotou, trocando o maxDepth de invariante para política. Por que gancho e não configuração pronta: o único consumidor do teto é a validação desta página, nenhum cliente pediu limite, e o padrão é "sem limite" — uma entidade de configuração para um campo que ninguém preenche custa rota, permissão e tela para não mudar nada. A entidade nasce quando o primeiro cliente pedir o teto; até lá o campo tem nome e dono, e o comportamento é o de hoje. TenantSettings não existe na plataforma, e este parâmetro não é do fluxo de aprovação nem está na tabela "por tenant" dele.

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 resolveu o lado do centro de custo antes de a Company existir, porque é a chave que precisa nascer certa. A entidade já está especificada na Empresa, e o campo aqui não mudou de forma — só de nome.

Como o escopo se propaga
Situação do nóFilho permitidoEfeito
Corporativo (companyId nulo)Corporativo ou de qualquer empresaO filho define o próprio escopo. É assim que "Administrativo" corporativo tem "Administrativo · Matriz" e "Administrativo · Filial SP" abaixo.
De uma empresaSó da mesma empresa422 COST_CENTER_COMPANY_MISMATCH_UNPROCESSABLE. Um nó de empresa não volta a ser corporativo depois de ter filhos.
Qualquer—Uma fatia de rateio só aponta para nó corporativo ou nó da empresa do documento. Não há rateio entre empresas na v1: compra centralizada opera no estabelecimento de cada empresa, por escopo. Validado fatia a fatia, com o companyId efetivo lido do nó (RN-CC-34).

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.

Campos
CampoTipoRegra
costCenterIdGuidobrigFK real. Apagar o centro de custo não é possível — o vínculo acompanha a inativação.
userIdGuidobrigDuas validações, na escrita. (1) O usuário existe e pertence a este tenant — conferido contra o diretório do GrydAuth; usuário de outro tenant é 422, e conta inativa do próprio tenant é aceita, porque quem volta de licença volta com os vínculos. (2) o usuário alcança a empresa do nó — companyOf(nodeId) tem de estar no conjunto resolvido de UserCompanyScope daquela pessoa; nó corporativo exige escopo completo — linha vigente em cada empresa ativa do tenant, conferida no momento do ato (RN-EMP-27) —, porque ele recebe rateio de qualquer empresa do tenant. Fora disso é 422 COST_CENTER_ASSIGNMENT_OUT_OF_COMPANY_SCOPE_UNPROCESSABLE (RN-CC-30).
roleenumobrigOwner · 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.
approvalLimitnumeric(19,2)?opcAlçada própria da pessoa neste nó, na moeda declarada em approvalLimitCurrency. Nulo = sem alçada — a pessoa não cobre valor nenhum por este vínculo, e o motor sobe a árvore. Nulo lido como "sem teto" daria a todo vínculo criado sem preencher a alçada — importação, migração, admin apressado — autoridade infinita; Ariba, Coupa e Protheus tratam limite ausente como zero, e é o que vale aqui: autoridade ilimitada exige ato de cadastro, não campo em branco (RN-CC-31). A comparação é na moeda do limite (approvalLimitCurrency), com a taxa congelada na submissão. É a ponte para o motor por alçada acumulada.
approvalLimitCurrencychar(3)?condObrigatória quando approvalLimit está preenchido, nula quando ele é nulo. ISO 4217. Vale inclusive no nó corporativo, que não tem empresa e portanto não tem moeda funcional de onde herdar — era exatamente ali que a comparação ficava silenciosa. A moeda funcional é BRL na v1, mas o limite de alçada pode estar em qualquer ISO 4217. A comparação da alçada converte o valor da fatia para esta moeda, com a taxa congelada na submissão, e a taxa e a data usadas entram no evento de aprovação. Par sem cotação vigente é recusa declarada, nunca comparação silenciosa (RN-CC-32).
hasUnlimitedAuthorityboolobrigPadrão false. Verdadeiro = a pessoa aprova qualquer valor neste nó, e então approvalLimit e approvalLimitCurrency têm de estar nulos: as duas coisas juntas seriam duas respostas para a mesma pergunta. É a única forma de existir alçada sem teto, e ela aparece na tela, na trilha e na resposta de /owners como um valor explícito — nunca como ausência de valor (RN-CC-31).
validFrom / validTodate / date?obrig / opcPeríodo fechado à esquerda e aberto à direita. Nulo = vigente sem prazo.
endedReasonstring(200)?opcPreenchido no encerramento antecipado. Aparece na trilha.

Como o responsável é resolvido

#PassoResultado
1Owners vigentes no próprio nó, na dataSe 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.
2Deputies vigentes no próprio nóSó consultados quando o passo 1 volta vazio. Substituto declarado não concorre com titular.
3Sobe ao pai e repeteAté a raiz. A resposta carrega inheritedFrom com o nó que de fato respondeu — a tela mostra "responsável herdado de Tecnologia".
4Nada em toda a cadeiaDevolve 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 do mesmo papel com períodos que se cruzam no mesmo nó. A garantia é uma exclusão de faixas sobre (TenantId, CostCenterId, UserId, Role, período) — EXCLUDE com btree_gist é o exemplo, e onde o banco não oferecer equivalente a verificação é da aplicação, sob trava. Checagem em memória sem trava perde a corrida de dois POST simultâneos, que é exatamente o defeito já identificado na configuração de aprovação da v1.0.

Role está na tupla. Sem Role, ninguém consegue ser Deputy de uma pessoa e depois Owner do mesmo nó com os períodos se tocando — a transição de gestão normal.

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çãoAceita lançamento novoAparece no seletorResolve histórico
Ativo, dentro da vigênciasimsimsim
Ativo, fora da vigêncianão — 422não, salvo busca explícitasim
Inativonãonãosim
Sem allowsPostingnão — só consolidasim, como nó de navegaçãosim

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 trava as linhas da subárvore até o fim da transação, em vez de um advisory lock por empresa.

Gancho declarado

Eixo contábil

O plano de contas está especificado na Empresa (GLAccount e GLAccountCompany), e a conta sugerida pela categoria, no Item (CategoryNode.defaultGlAccountId). O que este documento fixa é a relação entre as duas dimensões da fatia, para que a linha de documento nasça com a chave certa.

A regra

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 sugestão

A conta é sugerida em cascata e escolhida por gente, pelas regras da Empresa: o ponto de partida é CategoryNode.defaultGlAccountId, que sobe a árvore de categorias até o nó mais próximo com conta. Quem edita a linha troca a conta, sem permissão própria, e a troca fica na trilha do documento; a conta escolhida precisa estar habilitada na empresa do documento (GLAccountCompany) e aceitar lançamento.

A destinação

CAPEX ou OPEX sai do predominantUse do item, já decidido no Cadastro de Item — é o único campo fiscal que nasce no comprador. Na v1 ele não restringe as contas oferecidas: nenhum campo liga conta a destinação, e restringir sem dado seria regra sem fonte — fica como gancho. Nunca escolhe o centro de custo.

O que fica proibido

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.

Fatia com identidade

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.

Campos da fatia
CampoTipoRegra
allocationIdGuidIdentidade estável da fatia, gerada na criação e preservada em toda edição do documento. É a coluna que entra na chave de idempotência do razão orçamentário (RN-O34): chaveada por order, a fatia reeditada de 70/30 para 50/50 reusava order = 1, 2 e fazia o movimento corretivo colidir no índice único e ser descartado em silêncio (RN-CC-33).
orderint1..N. Define a ordem estável de exibição e o desempate do arredondamento. Não é identidade — é reusado quando o rateio é reeditado.
costCenterIdGuidNó com allowsPosting, ativo, vigente e da empresa do documento — ou corporativo (RN-CC-34).
glAccountIdGuid?Sugerida pela categoria da linha; pode diferir entre fatias. Nula só em rascunho: a submissão do documento exige conta em toda fatia, com allowsPosting, senão 422 ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE. É o que garante a tupla (costCenterId, glAccountId) que o razão orçamentário exige em todo movimento.
percentnumeric(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.
amountnumeric(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%, 33,333333% e 33,333334% — a soma tem de ser exatamente 100% (RN-CC-22) — sobre R$ 100,00 dão R$ 33,33 cada e sobra R$ 0,01, que vai à primeira fatia pelo desempate: R$ 33,34 + 33,33 + 33,33. 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 — o menor entre allocationMaterialityPercent do valor do documento e allocationMaterialityAmount — abaixo do qual a fatia não gera passo próprio e é absorvida pela maior. O limiar não é deste documento: é um por tenant, mora na configuração de tenant do fluxo de aprovação e vale para todos os processos. Aqui ele é citado, porque materialidade muda a cadeia, não o rateio. E não alcança o orçamento — com as mesmas palavras que o Orçamento usa (RN-O38): materialidade é regra de roteamento, nunca de orçamento; o orçamento sempre vê a decomposição integral, e Block numa fatia absorvida recusa a submissão do mesmo jeito. Um pedido de R$ 400 mil com 3% em /MARKETING/ não gera aprovador de Marketing e consome a verba de Marketing pelos R$ 12 mil, com a política de Marketing valendo inteira. A alternativa — deixar passar porque o motor ignorou a fatia — cria a marca que dispara no funcionamento normal, e controladoria que vê marca todo dia aprende a ignorá-la. 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.

Conta obrigatória na submissão, nunca no rascunho

A cascata de sugestão da conta — item, categoria, vazio — pode terminar vazia, e um rascunho pode ficar assim o tempo que for. Submeter não pode: o razão orçamentário grava todo movimento pela tupla (centro de custo, conta), inclusive onde não há verba, e o semáforo de POST /evaluate:budget-state já lê a conta por fatia na digitação. Uma fatia sem conta não tem sinal nem movimento. Por isso "vazio" na cascata significa o usuário escolhe, e a submissão recusa a fatia sem conta com 422 ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE, nomeando a linha e o order da fatia. É a saída mais simples e a que Oracle e SAP praticam; a alternativa — conta anulável no razão fora de controle orçamentário — contrariaria a convenção de que nulo nunca significa "não sei".

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ínioRelaçãoRegra
Itemnenhuma diretaO item não tem centro de custo. Quem tem é a linha do documento. Um item comprado por cinco áreas continua sendo um item.
ItemTypeindiretaDirige a destinação padrão — CAPEX, OPEX, serviço — que restringe as contas oferecidas na linha. Nunca o centro de custo.
CategoryNodeeixo ortogonalCategoria 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. O catálogo adota esta, por inteiro.
UnitOfMeasurenenhumaUnidade é propriedade da linha e do item. Não há conversão por centro de custo, nem unidade padrão por área.
Fornecedor · EstabelecimentonenhumaRestriçã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.
ItemSuppliernenhumaPreço não muda por centro de custo. Se mudar, é contrato — outra entidade.
Companydono do nócompanyId no nó, nulo = corporativo. Ver Empresa e escopo.
GLAccounteixo irmãoA linha carrega os dois. A conta é sugerida pela categoria e obrigatória na submissão (RN-CC-29); nenhuma das duas manda na outra.
Budgetchave compostaA verba é por (empresa, nó?, conta?, período). Linha num nó intermediário enxerga o consumo de toda a subárvore, lido pela tupla gravada no movimento; não existe verba consolidada calculada por cima (Orçamento, RN-O31).
ApprovalPolicyconsomeCrité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 · NotafuturoToda 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.

CapacidadeAssinaturaComo é servida
Casar por subárvorematches(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áveiseffectiveOwners(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çadaauthorityFor(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 empresacompanyOf(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 precisa impedir que o nó referenciado seja desativado ou removido até o fim da transação dela (trava compartilhada da linha do nó), e a desativação em cascata trava com exclusividade as linhas da subárvore antes de conferir as políticas. A trava é requisito: FOR KEY SHARE e FOR UPDATE do PostgreSQL são exemplos, e qualquer equivalente que dê a mesma exclusão serve. 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.

Ordenação topológica no servidor

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.

Dry-run obrigatório na tela

POST /cost-centers:import?dryRun=true 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.

Idempotência por código

Reenviar o mesmo arquivo atualiza nome e descrição dos nós existentes e cria os que faltam; responsáveis nunca são sobrescritos — o vínculo que saiu do arquivo é encerrado e o que entrou é aberto (RN-CC-37). 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.

Responsáveis no mesmo arquivo

Colunas opcionais de e-mail do responsável e vigência criam os CostCenterAssignment junto — e exigem também create:cost-center-assignments, porque criam aprovador (RN-CC-37). Vínculo importado nasce sem alçada. 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.

Árvore
RotaPermissãoRetornoErros
GET /cost-centersread:cost-centersPagedResult<CostCenterDto>400
GET /cost-centers/lookupread:cost-centersarray puro400
GET /cost-centers/{id}read:cost-centersCostCenterDetailDto404
GET /cost-centers/{id}/childrenread:cost-centersPagedResult<CostCenterDto>400 · 404
GET /cost-centers/{id}/historyread:cost-centersPagedResult<AuditLogDto>400 · 404
POST /cost-centerscreate:cost-centersCostCenterDetailDto400 · 409 · 422
PUT /cost-centers/{id}update:cost-centersCostCenterDetailDto400 · 404 · 409 · 422
PATCH /cost-centers/{id}/parentupdate:cost-centersCostCenterDetailDto400 · 404 · 409 · 422
PATCH /cost-centers/{id}/activateupdate:cost-centersCostCenterStatusChangeDto404 · 422
PATCH /cost-centers/{id}/deactivatedelete:cost-centersCostCenterDetailDto404 · 409
Vínculos, responsáveis, importação e políticas
RotaPermissãoO que faz
GET /cost-centers/{id}/assignmentsread:cost-centersVínculos do nó. ?at= para uma data, ?includeEnded=true para o histórico completo.
GET /cost-centers/{id}/ownersread:cost-centersResponsáveis efetivos com rollup e inheritedFrom. É o que o motor de aprovação chama.
POST /cost-centers/{id}/assignmentscreate:cost-center-assignmentsAbre um vínculo. Sobreposição do mesmo usuário e papel é 409.
PATCH /cost-centers/{id}/assignments/{assignmentId}/enddelete:cost-center-assignmentsEncerra com data e motivo. Data no passado é 422.
PATCH /cost-centers/{id}/assignments/{assignmentId}/authorityupdate:cost-center-assignmentsAltera approvalLimit, approvalLimitCurrency e hasUnlimitedAuthority do vínculo vigente, com expectedVersion. Papel, pessoa e vigência não mudam por aqui — trocar qualquer um deles continua sendo encerrar e abrir (RN-CC-16). A alteração fica na trilha com o valor anterior (RN-CC-35)
PATCH /cost-centers/{id}/assignments/{assignmentId}/reactivateupdate:cost-center-assignmentsReabre um vínculo encerrado com validFrom igual ou posterior a hoje, refazendo a checagem de alcance da empresa do nó (RN-CC-30) e a de sobreposição. Sem ela, a RN-CC-30 falava de "criar ou reativar" uma operação que nenhum endpoint oferecia (RN-CC-35)
POST /cost-centers:importcreate:cost-centers?dryRun=true devolve o relatório linha a linha sem escrever; sem o parâmetro, executa. Tudo ou nada, nunca parcial.
GET /cost-centers/{id}/policiesread:cost-centersSubstitui /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: o mapeador do Core reconhece dez sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · _UNAVAILABLE → 503 · sem sufixo → 400. Código exato declarado no mapeador vence o sufixo.

CódigoHTTPQuando
COST_CENTER_NOT_FOUND404Não existe neste tenant — inclusive quando existe em outro
COST_CENTER_CODE_ALREADY_EXISTS409Código já usado. A mensagem carrega o código
COST_CENTER_DEPENDENCIES_CONFLICT409Desativação com política de aprovação ativa na subárvore
COST_CENTER_CONCURRENT_CHANGE_CONFLICT409expectedVersion divergente do version atual — ou, num move, o path mudou desde a leitura
COST_CENTER_ASSIGNMENT_OVERLAP_CONFLICT409Novo. Mesmo usuário e papel com períodos sobrepostos no mesmo nó
COST_CENTER_PARENT_UNPROCESSABLE422Pai informado não existe
COST_CENTER_CYCLIC_PARENT_UNPROCESSABLE422Destino é o próprio nó ou está dentro da sua subárvore
COST_CENTER_PARENT_INACTIVE_UNPROCESSABLE422Criar, mover ou ativar sob ramo inativo
COST_CENTER_DEPTH_EXCEEDED_UNPROCESSABLE422Novo. Acima do teto declarado pelo tenant. Política, não estrutura
COST_CENTER_COMPANY_MISMATCH_UNPROCESSABLE422Filho de empresa diferente do pai, ou subárvore movida para pai de empresa diferente — a mensagem lista os códigos que não batem (RN-CC-36). Fatia de rateio apontando para nó de outra empresa é validação do documento e pertence à Requisição, contra a empresa do documento (RN-CC-34)
COST_CENTER_COMPANY_IMMUTABLE_CONFLICT409companyId alterado em nó que já tem filho, vínculo ou uso declarado por outro módulo (RN-CC-36)
COST_CENTER_EXTERNAL_CODE_ALREADY_EXISTS409Código externo já usado na mesma empresa, ou em outro nó corporativo (RN-CC-38)
COST_CENTER_IMPORT_ASSIGNMENTS_FORBIDDEN403Arquivo com colunas de responsável sem create:cost-center-assignments. O lote inteiro é recusado (RN-CC-37)
COST_CENTER_VALIDITY_OUTSIDE_PARENT_UNPROCESSABLE422Novo. Vigência do filho excede a do pai
COST_CENTER_NOT_EFFECTIVE_UNPROCESSABLE422Novo. Lançamento em nó fora da vigência
COST_CENTER_POSTING_NOT_ALLOWED_UNPROCESSABLE422Novo. Rateio apontando para nó de consolidação
COST_CENTER_ASSIGNMENT_USER_UNPROCESSABLE422Usuário não é membro deste tenant, ou não existe. As duas coisas de propósito. Quem checa o alcance da empresa é o COST_CENTER_ASSIGNMENT_OUT_OF_COMPANY_SCOPE_UNPROCESSABLE (RN-CC-30), e os dois erros existem porque são duas perguntas diferentes
COST_CENTER_ASSIGNMENT_PERIOD_UNPROCESSABLE422Novo. Encerramento com data no passado, ou validTo antes de validFrom. Também na reativação com validFrom no passado
COST_CENTER_ASSIGNMENT_AUTHORITY_UNPROCESSABLE422Alçada inconsistente: approvalLimit preenchido sem approvalLimitCurrency, moeda sem limite, ou hasUnlimitedAuthority = true com limite preenchido. A mensagem diz qual das três combinações chegou
COST_CENTER_IMPORT_CYCLE_UNPROCESSABLE422Novo. Ciclo no arquivo. A mensagem nomeia os códigos do ciclo
ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE422Submissão de documento com fatia sem conta contábil. A mensagem nomeia a linha e o order da fatia
COST_CENTER_ASSIGNMENT_OUT_OF_COMPANY_SCOPE_UNPROCESSABLE422Vínculo para quem não alcança a empresa do nó. A mensagem nomeia a empresa do nó e o conjunto resolvido do usuário, para que o admin veja o que falta conceder
COST_CENTER_CODE_REQUIRED · _TOO_LONG · _NOT_ALPHANUMERIC400Validações de código, herdadas
COST_CENTER_NAME_REQUIRED · _TOO_LONG400Nome em branco ou acima de 160
COST_CENTER_DESCRIPTION_TOO_LONG400Acima de 500
COST_CENTER_SEARCH_TERM_TOO_LONG400Filtro de texto acima de 100 caracteres
SORT_FIELD_INVALID · INCLUDE_DELETED_UNSUPPORTED · SEARCH_UNSUPPORTED400Parâ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ãoSituaçãoCobre
read:cost-centersherdadaListagem, árvore, detalhe, autocomplete, histórico, vínculos, responsáveis efetivos e políticas que alcançam o nó
create:cost-centersherdadaCriação de nó
update:cost-centersherdadaEdição, move e ativação
delete:cost-centersherdadaDesativação em cascata
create:cost-center-assignmentsnovaAbrir 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
create:cost-centersnovaImportação em lote. Cria e altera centenas de nós numa chamada — merece guarda própria. Arquivo com responsáveis exige também create:cost-center-assignments (RN-CC-37)

Um endpoint declara exatamente uma permissão e nada é implicado por outra. A permissão genérica de gestão da v1.0 continua removida.

Rastreabilidade

Regras de negócio

RN-CC-01

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.

RN-CC-02

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.

RN-CC-03

Profundidade é livre no armazenamento e limitada por política

costCenterMaxDepth é opcional, validado na criação e no move, e pode ser levantado sem migração. É gancho declarado, não configuração da v1: o campo é deste documento, o padrão é nulo — profundidade livre — e a entidade de configuração nasce quando o primeiro cliente pedir o teto. Enquanto o valor for nulo não há o que validar, e nenhuma rota o expõe.

RN-CC-04

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.

RN-CC-05

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.

RN-CC-06

isActive muda por exatamente duas rotas

PATCH /activate e DELETE. O corpo do PUT não carrega o campo.

RN-CC-07

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.

RN-CC-08

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. A garantia é a trava exclusiva das linhas da subárvore, obtida antes de conferir as políticas e mantida até o fim da transação, contra a trava compartilhada que a criação de política toma no nó — SELECT … FOR UPDATE é um exemplo, e qualquer equivalente serve.

RN-CC-09

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.

RN-CC-10

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.

E leva o consumo orçamentário junto. O Orçamento lê o consumo pela árvore vigente (RN-O50): as linhas de orçamento do pai antigo deixam de enxergar o gasto já gravado da subárvore, e as do pai novo passam a enxergá-lo, sem movimento no razão. O move não é recusado por isso. Antes de confirmar, a tela do move consulta o Orçamento — GET /budget-lines/move-impact — e mostra as linhas afetadas com o sinal de cada uma antes e depois; o Centro de Custo continua sem conhecer orçamento, e a dependência segue num sentido só.

RN-CC-11

Escrita a partir de uma versão vencida é 409

version int é o token de toda escrita rastreada, inclusive um PUT que só troca o nome: o cliente envia o expectedVersion que leu, e a divergência responde 409. path continua conferido em move, como segunda guarda. A resposta é reler e repetir. Só o path não bastaria: ele muda só em move, e renome, vigência e conta concorrentes passariam sem 409.

RN-CC-12

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.

RN-CC-13

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.

RN-CC-14

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.

RN-CC-15

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ó.

RN-CC-16

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.

RN-CC-17

Vínculos sobrepostos do mesmo usuário e papel são recusados no banco

A tupla é (TenantId, CostCenterId, UserId, Role, período) — exclusão de faixas, com EXCLUDE + btree_gist como exemplo de implementação e verificação na aplicação, sob trava, onde o banco não oferecer equivalente. Checagem em memória sem trava perde a corrida de dois POST simultâneos.

RN-CC-18

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".

RN-CC-19

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.

RN-CC-20

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.

RN-CC-21

Usuário de outro tenant é 422; conta inativa do próprio tenant é aceita

Inativo é estado da conta, não referência errada. Recusar tornaria um nó insalvável por causa do login de alguém. O que esta regra checa é pertencer ao tenant. Alcançar a empresa do nó é a RN-CC-30, que é outra checagem, na mesma escrita.

RN-CC-22

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.

RN-CC-23

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.

RN-CC-24

Fatia abaixo do limiar de materialidade não gera aprovador próprio

Esta regra cita, não declara. O limiar é um só por tenant, em percentual e em valor — vale o menor dos dois, e o valor absoluto é o teto do limiar —, e o dono dele é a RN-AP-15, na configuração de tenant do fluxo de aprovação: materialidade muda a cadeia e não o rateio, e pelo motor universal nenhum módulo tem parâmetro próprio de aprovação. O que este documento garante é o efeito sobre o rateio: a fatia imaterial é absorvida pela maior para efeito de alçada — e continua contabilizada integralmente. "Integralmente" quer dizer que o orçamento sempre vê a decomposição integral e que Block numa fatia absorvida recusa a submissão do mesmo jeito. Materialidade é roteamento, nunca orçamento — as mesmas palavras da RN-O38. Mesma disciplina com que a RN-CC-23 virou a regra única do resíduo: mesma regra em dois lugares é uma regra; duas regras parecidas é uma armadilha.

RN-CC-25

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.

RN-CC-26

Id fora do escopo responde 404

Vale para toda rota, e para os dois escopos: id de outro tenant e id de empresa que o usuário não alcança. 403 confirmaria que o registro existe em algum lugar.

RN-CC-27

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.

RN-CC-28

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.

RN-CC-29

Fatia sem conta contábil não submete

glAccountId é anulável só em rascunho. Na submissão, toda fatia tem conta com allowsPosting; a falta responde 422 ALLOCATION_GL_ACCOUNT_REQUIRED_UNPROCESSABLE. Alinhada ao razão orçamentário.

RN-CC-30

Vínculo exige que a pessoa alcance a empresa do nó

Criar ou reativar um CostCenterAssignment exige que companyOf(nodeId) esteja no conjunto de empresas resolvido para aquele usuário em UserCompanyScope. Nó corporativo (sem empresa) exige escopo completo — linha vigente de UserCompanyScope em cada empresa ativa do tenant, no instante da verificação (RN-EMP-27); ausência de linha é nenhuma empresa, e não existe concessão "todas as empresas": um nó sem empresa recebe rateio de qualquer empresa do tenant, então quem responde por ele vai receber documento de todas, e escopo parcial faria a RN-AP-39 derrubar a cadeia justamente nos casos que o nó corporativo existe para cobrir. Exigir o conjunto apenas "não vazio" seria autorizar o vínculo que o motor depois recusa. Por quê: fora de escopo, a empresa responde 404 para aquela pessoa — ela não conseguiria nem abrir o documento que o vínculo a manda aprovar. Sem esta regra o vínculo nasce inerte, e o defeito só aparece meses depois, na primeira requisição, quando o nível resolve vazio e a decisão cai no aprovador de reserva. O erro passa a acontecer na tela do admin, no momento em que ele tem contexto para corrigi-lo.

Isto não concede privilégio. Escopo não é permissão: dar escopo numa empresa não autoriza nada que o token da pessoa já não autorize — só diz onde o que ela já pode fazer tem efeito. Por isso a exigência é barata: o admin concede o escopo e o vínculo passa a valer, sem que ninguém ganhe um verbo novo. A revogação posterior do escopo não apaga o vínculo — deixa-o dormente, e é aí que a defesa em profundidade do motor entra (RN-AP-39).

RN-CC-31

approvalLimit nulo é ausência de alçada; ilimitado é ato de cadastro

Nulo = sem alçada: o vínculo não cobre valor nenhum e o motor sobe a árvore. Autoridade sem teto existe só com hasUnlimitedAuthority = true, e nesse caso approvalLimit e approvalLimitCurrency são nulos. As três colunas nunca são lidas em separado.

Por quê: com o campo opcional, nulo lido como "sem teto" faria todo vínculo criado sem preencher a alçada nascer com autoridade infinita, que é falha aberta no ponto exato em que o dinheiro é decidido. Ariba, Coupa e Protheus tratam limite ausente como zero.

RN-CC-32

Limite tem moeda declarada, inclusive no nó corporativo

approvalLimitCurrency é obrigatória quando há approvalLimit e nula quando não há. O nó corporativo não tem empresa e por isso não herda moeda funcional de lugar nenhum — ele declara a dele como qualquer outro. A comparação da alçada converte o valor da fatia para esta moeda, com a taxa congelada na submissão, e registra taxa e data no evento de aprovação; par sem cotação vigente é recusa declarada.

Por quê: limite de alçada pode estar em qualquer ISO 4217 — o de um diretor regional em USD, o de um gerente em BRL —, e um limite sem moeda faz o motor comparar BRL com USD em silêncio — e comparação silenciosa de moedas no ponto da alçada erra por um fator de cinco sem avisar ninguém.

RN-CC-33

A fatia tem identidade estável

CostAllocation.allocationId é gerado na criação da fatia e preservado em toda edição do documento. order é ordem de exibição e desempate de arredondamento (RN-CC-23), nunca identidade.

Por quê: a chave de idempotência do razão orçamentário usa a fatia (RN-O34). Chaveada por order, a reedição do rateio de 70/30 para 50/50 reusava order = 1, 2 e fazia o movimento corretivo colidir no índice único e ser descartado em silêncio.

RN-CC-34

A fatia é da empresa do documento

CostAllocation não carrega empresa. A regra "fatia só aponta para nó corporativo ou nó da empresa A" é apurada contra a empresa do documento, e o Budget consumido é o dessa empresa (RN-O40). Não há rateio entre empresas na v1; compra centralizada opera no estabelecimento de cada empresa, por escopo.

Por quê: matriz e filial são a mesma Company, então empresa na fatia só importaria entre empresas diferentes — o caso que a v1 não tem, e uma coluna que só pode receber um valor é campo que ninguém testa. Rateio entre empresas, quando vier, é coluna nova preenchida a partir do documento, sem migração de chave no razão, que já guarda companyId.

RN-CC-35

Alçada e reativação têm rota própria

PATCH …/assignments/{id}/authority altera as três colunas de alçada do vínculo vigente, com expectedVersion e registro do valor anterior na trilha. PATCH …/assignments/{id}/reactivate reabre vínculo encerrado com validFrom igual ou posterior a hoje, refazendo as checagens de sobreposição (RN-CC-17) e de alcance da empresa do nó (RN-CC-30). Papel, pessoa e vigência continuam mudando só por encerrar e abrir (RN-CC-16).

Por quê: a RN-CC-30 fala em "criar ou reativar" um vínculo, e reativar não existia em endpoint nenhum; e approvalLimit não era declarado imutável nem tinha rota que o alterasse — corrigir a alçada de alguém obrigava a encerrar o vínculo e abrir outro, o que reescreve a resposta de "quem tinha autoridade" por um motivo que não é troca de responsável.

RN-CC-36

companyId é imutável depois do primeiro uso, e o move valida a subárvore

companyId só muda por PUT enquanto o nó não tem filho, vínculo (CostCenterAssignment) nem uso declarado por outro módulo — fatia de documento submetido, linha ou política de orçamento, política de aprovação. Cada módulo consumidor implementa a consulta de uso definida no .Contracts do Centro de Custo, que pergunta sem conhecer o consumidor. Depois do primeiro uso, 409 COST_CENTER_COMPANY_IMMUTABLE_CONFLICT. No PATCH /parent para pai de empresa X, todo nó da subárvore movida precisa ser de X; um só que não seja recusa o move inteiro com 422 COST_CENTER_COMPANY_MISMATCH_UNPROCESSABLE, listando os códigos. Pai corporativo aceita qualquer subárvore.

Por quê: trocar a empresa de um nó usado mudaria a pessoa jurídica de fatias já aprovadas e a verba que elas consomem; e validar só o nó movido deixaria entrar, por baixo dele, filhos de outra empresa sob um pai que não os admite.

RN-CC-37

Importar responsáveis exige a permissão de responsáveis, e reimportar só encerra e abre

Arquivo com coluna de responsável exige create:cost-centers e update:cost-center-assignments; sem a segunda, o lote inteiro é recusado com 403 COST_CENTER_IMPORT_ASSIGNMENTS_FORBIDDEN. Para cada nó presente no arquivo com a coluna preenchida, o conjunto de responsáveis do arquivo é o desejado: vínculo que saiu é encerrado (validTo = hoje) e vínculo que entrou é aberto, pelas regras de sempre (RN-CC-16, RN-CC-17, RN-CC-18, RN-CC-30). Nó ausente do arquivo, ou com a coluna vazia, não é tocado. Vínculo importado nasce sem alçada; alçada só pela rota própria (RN-CC-35).

Por quê: update:cost-center-assignments é separada de update de propósito, e o Owner é o aprovador mais usado; quem só importa não pode criar aprovador, e reimportar não pode reescrever o histórico de quem respondia pelo nó.

RN-CC-38

Código externo é único por empresa, e nulo colide com nulo

externalCode, quando preenchido, é único por (tenant, companyId), e nó corporativo (companyId nulo) conta como uma empresa só: dois corporativos não têm o mesmo código externo. Violação: 409 COST_CENTER_EXTERNAL_CODE_ALREADY_EXISTS.

Por quê: a integração casa as bases por esse código; com nulo distinto de nulo, dois corporativos com o mesmo código fariam o ERP lançar no nó errado sem erro nenhum.

Execução

Migração da v1.0

Não há dado transacional. O que existe é a árvore de desenvolvimento e os testes.

MudançaTipoNota
cost_center_assignmentstabela novaUm vínculo Owner com validFrom = data da migração para cada managerUserId preenchido. Depois a coluna cai.
companyId, validFrom, validTo, allowsPosting, externalCode, versioncolunas novasEmpresa nula, vigência aberta, allowsPosting calculado da contagem de filhos, version = 1.
FK approval_policies.cost_center_idrestrição novaNasce 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çãorestrição novaExige btree_gist no Postgres; onde o banco não oferecer restrição de exclusão, a guarda equivalente é a verificação na aplicação, como esta página já descreve. As extensões pg_trgm e unaccent já eram o preço da busca — no mesmo regime de exemplo, não de premissa.
CostCenter.Activate() / Deactivate()remoçãoDivergência 2. Os testes que os usam como fixture passam a montar o estado pelo repositório.
manager no DTOquebra de contratoVira owners[] e effectiveOwners[]. O frontend já vai ser reescrito pela poda — é o momento sem custo.
GET /{id}/approval-flowsrenomeadaVira /policies e passa a incluir as políticas herdadas de ancestrais.

Limites

Fora de escopo

Fora, com gancho declarado
  • Plano de contas — GLAccount e o mapping set da categoria. A chave já nasce na linha.
  • Orçamento e empenho — Budget por empresa × nó × conta × período. A árvore já permite o rollup da verba.
  • Empresa e filial — Company e Establishment, especificadas. O companyId do nó é a ligação.
  • Centro de lucro e resultado por área — outro eixo, outra árvore, outro documento.
Fora, por decisão
  • 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. isDeleted segue 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.

companyId no nó aponta para a empresa, nunca para o estabelecimento. Apontar para o estabelecimento seria mais preciso fiscalmente e multiplicaria a árvore por CNPJ — que é exatamente o que o nó corporativo existe para evitar. A precisão fiscal não se perde: a linha do documento carrega o establishmentId, e a empresa dele tem que bater com a do nó.

Registro

Histórico de revisões

VersãoDataO que mudou
v2.1630/10/2026Permissões padronizadas em cinco verbos (decisão do Roger, 30/10/2026). De/para: deactivate:cost-centers → delete:cost-centers; import:cost-centers → create:cost-centers (a importação cria); manage:cost-center-assignments → create:cost-center-assignments (abrir vínculo), update:cost-center-assignments (alçada e reativação) e delete:cost-center-assignments (encerrar); a importação com responsáveis exige agora create:cost-center-assignments (RN-CC-37). GET /cost-centers/lookup passa a exigir lookup:cost-centers, e read não o abre. Nenhuma regra de negócio muda.
v2.1513/09/2026Validação da onda 1, rodada 7. B18: saem do corpo as versões nos títulos das tabelas de rotas — “Novos nesta versão” e “Árvore — herdados da v1.0” passam a nomear o que a tabela lista.
v2.1411/09/2026Validação da onda 1, rodada 3. Saem do corpo a data "(08/09/2026)" do teto de profundidade e a nota de que o parâmetro "saiu da tabela por tenant" do Fluxo em 08/09
v2.1311/09/2026Validação da onda 1, rodada 2. Materialidade pelo menor entre percentual e valor (RN-CC-24, como a RN-AP-15); dez sufixos de erro, com _UNAVAILABLE → 503 vigente; nó corporativo exige escopo completo (RN-CC-30, RN-EMP-27) — sai "escopo irrestrito"; travas da desativação em cascata e da criação de política escritas como requisito, com o recurso de banco como exemplo (RN-CC-08); exemplo de resíduo com fatias que somam 100%. Saem do corpo as datas "Regra de 03/09/2026" (conta obrigatória na submissão, RN-CC-29) e "passou a existir em 01/09/2026" (Empresa)
v2.1211/09/2026Validação de 10/09. Sem rateio entre empresas na v1: sai CostAllocation.companyId e a invariante da fatia volta a ler a empresa do documento (RN-CC-34). companyId do nó imutável depois do primeiro uso e move que valida a subárvore inteira (RN-CC-36, COST_CENTER_COMPANY_IMMUTABLE_CONFLICT). Importar responsáveis exige create:cost-center-assignments e reimportar só encerra e abre vínculo (RN-CC-37, COST_CENTER_IMPORT_ASSIGNMENTS_FORBIDDEN). Código externo único por empresa com nulo colidindo com nulo (RN-CC-38, COST_CENTER_EXTERNAL_CODE_ALREADY_EXISTS). Eixo contábil remete à Empresa e ao Item; sai a permissão de exceção do plano de contas; predominantUse não restringe contas na v1. Move leva o consumo orçamentário junto (RN-CC-10). Comparação de alçada na moeda do limite, com funcional BRL na v1. Notas de revisão saíram do corpo
v2.9 – v2.1109/09/2026Três colunas de alçada (approvalLimit nulo = sem alçada, approvalLimitCurrency, hasUnlimitedAuthority); Role na tupla de sobreposição de vínculos; rotas de alçada e de reativação; allocationId como identidade estável da fatia; materialidade não alcança o orçamento (RN-CC-24)
v2.3 – v2.803/09 a 08/09/2026version inteiro como token de toda escrita; limite na moeda da empresa do documento; vínculo exige alcance da empresa do nó (RN-CC-30); RN-CC-24 passa a citar; teto de profundidade como gancho declarado; textos de "empresa" herdados da v1.0 separados de "tenant"
v2.030/08/2026Nova especificação sobre a implementação v1.0: CostCenterAssignment com papel e vigência, resolução por rollup, empresa no nó, vigência separada de isActive, allowsPosting, rateio, código externo e importação em lote