Ponto de partida
Decisões tomadas antes desta especificação
Cinco escolhas foram fechadas com o time e são premissa de tudo abaixo. Cada uma altera o spec v2.0 do Catálogo.
Entidade única com ItemType. Um Item para material, serviço, ativo e assinatura, com campos condicionais validados no domínio. É o que Coupa e Precoro fazem — os dois produtos da amostra que são P2P puro, como o Nexio. O SAP separa material e serviço porque é ERP e precisa de MRP e valoração; nós não.
CategoryNode auto-relacionado. Substitui Category + Subcategory. Profundidade livre no armazenamento, teto por configuração do tenant (maxDepth, default 2). A UI segue com dois selects; o terceiro nível vira um INSERT em vez de migração multi-tenant.
Cobertura BR completa, em regime de "esperado". NCM, CEST, GTIN para mercadorias; LC 116, cTribNac e NBS para serviços; destinação predominante para o crédito de IBS/CBS. Nenhum campo fiscal é obrigatório para criar item, e nenhum é fonte de verdade fiscal — são referências de conferência.
Fora do escopo, com ganchos previstos. O item não carrega saldo, mínimo, ponto de reposição nem localização. O que fica previsto no modelo: controlsStock no tipo, lote e validade como grupo condicional desligado, e a unidade base já sendo a unidade de estoque futura.
A entidade se chama Item, e as linhas de documento se chamam Line. Catalog.CatalogItem gagueja — o namespace já diz o módulo. O único argumento a favor do prefixo era a ambiguidade com "item do pedido", e ela desaparece ao nomear as linhas como RequisitionLine, QuotationLine, PurchaseOrderLine e ReceiptLine. Essa convenção é parte da decisão, não consequência dela: se uma linha de documento voltar a se chamar "item", a ambiguidade volta junto — e aí o prefixo faria falta.
Fundamentos
Sete princípios que governam o cadastro
1. A descrição é gerada, não digitada
A identidade de um item é nome básico + conjunto de (característica, valor). O cadastrador preenche atributos; o sistema monta a descrição canônica. É o modelo do PDM do CATMAT, das properties do eCl@ss e dos attribute groups da Oracle — quatro instâncias independentes do mesmo padrão. É também a única defesa estrutural contra "PARAF. M8 40MM INOX" e "Parafuso sextavado M8x40" coexistirem como itens diferentes.
2. O que é do fornecedor não fica no item
Preço, prazo de entrega, quantidade mínima, múltiplo de compra, código do item no fornecedor e unidade de compra vivem em ItemSupplier. Coupa, SAP, Oracle e Ariba são unânimes nisso, e o motivo é simples: nada disso é verdade sobre o item — é verdade sobre uma relação. O primeiro segundo fornecedor quebra qualquer modelo que tenha colocado isso no item.
3. O cadastro guarda o que decide a compra, não o que apura tributo
Campos fiscais existem para conferir a nota do fornecedor e para classificar a intenção da compra. CST, CFOP, alíquotas, base reduzida, código de benefício e cClassTrib são responsabilidade do emitente e do ERP fiscal. Guardá-los aqui produz dado que estará errado numa fração relevante das operações.
4. Nenhum campo fiscal é obrigatório para criar um item
O item nasce de uma necessidade de compra, frequentemente antes de existir fornecedor. Exigir NCM no cadastro faz o usuário digitar 0000.00.00 — e dado errado é pior que dado ausente. Na UI, o rótulo é explícito: "NCM esperado — usado para conferir a nota do fornecedor".
5. A taxonomia classifica; ela não carrega atributos nem políticas
"Requer laudo", "exige DPO", "criticidade" e "com dado pessoal" nunca viram nós da árvore. Viram atributo, flag ou documento. O eCl@ss consegue ser fixo em quatro níveis porque escoa granularidade em 23 mil propriedades; sem esse escape, a granularidade vira nome de subcategoria e a árvore apodrece.
6. Uma unidade nunca basta
Nenhum dos seis produtos pesquisados tem "a UoM do item" — Coupa tem seis papéis de unidade, D365 e NetSuite têm três. E a conversão é por item: "1 CX = 12 UN" não é verdade sobre "CX", é verdade sobre este parafuso. Conversão global de classe quebra no primeiro tenant que compra caixa de 12 e caixa de 24 do mesmo produto.
7. Buscar antes de criar
A tela de novo item começa como busca, não como formulário em branco. É o passo mais barato e mais eficaz do fluxo do SAP MDG, e é onde a duplicidade morre — antes de nascer, não numa rotina de limpeza depois.
Modelo
Mapa de entidades
Sete agregados novos, um deles de configuração. O Item é o centro; tudo que varia por fornecedor, por categoria ou por operação fica fora dele.
CategoryAttribute.
ItemAttributeValue, ItemUomConversion, ItemPhoto, ItemKeyword, ItemDocument, ItemCompliance.
O CostCenter não aparece no cadastro de item — a amarração contábil acontece na requisição, não no catálogo. Ver Impacto na aprovação.
Convenção de nomes. O documento é em português; os identificadores são em inglês — entidades, atributos, valores de enum, rotas, permissões e códigos de erro. A exceção são os dados de negócio brasileiros, que ficam como são no mundo real: identificadores fiscais (cnpj, cpf, primaryCnae), nomes de regime tributário (SimplesNacional, LucroReal), siglas de unidade (UN, CX, KG), tipos de certidão e os exemplos de taxonomia — esses são conteúdo, não código.
Configuração · ItemType
Tipo de item
ItemType não é um enum: é entidade de configuração por tenant, seguindo o padrão do material type do SAP e do item model group do D365. O tipo dirige field selection — quais grupos de campos aparecem, quais são obrigatórios e o que o domínio recusa.
Campos do ItemType
| Campo | Tipo | Efeito |
|---|---|---|
| code / name | string(30) / string(80) | Código único por tenant, imutável. Nome exibido. |
| isSystem | bool | Tipos-semente não podem ser excluídos, só desativados ou renomeados. |
| requiresGoodsTaxCode | bool | Libera o grupo fiscal de mercadoria (NCM, CEST, GTIN) e o grupo físico (peso, volume, dimensões). |
| requiresServiceTaxCode | bool | Libera o grupo fiscal de serviço (LC 116, cTribNac, NBS). Mutuamente exclusivo com o anterior. |
| allowsRecurrence | bool | Libera periodicidade e unidade de cobrança. |
| isCapex | bool | Liga requiresAssetTag por default e habilita vida útil. |
| controlsStock | bool | gancho Reservado. Hoje sempre false; libera lote e validade quando estoque entrar. |
| allowedUomClasses | UomClass[] | Impede "hora" em matéria-prima e "quilograma" em serviço de consultoria. |
| defaultPredominantUse | enum | Destinação sugerida no cadastro (ver Fiscal). |
| requiredFields | string[] | Field selection: campos opcionais no domínio que este tipo torna obrigatórios. |
| isActive | bool | Tipo inativo não aceita novos itens; itens existentes não são afetados. |
Tipos-semente por tenant
| Código | Fiscal | Recorrência | CAPEX | Classes de unidade | Destinação padrão |
|---|---|---|---|---|---|
| RAW_MATERIAL | Mercadoria | — | — | Count, Weight, Volume, Length, Area | ProductionInput |
| FINISHED_GOOD | Mercadoria | — | — | Count, Weight, Volume | Resale |
| MRO | Mercadoria | — | — | Count, Weight, Volume, Length | ConsumableUse |
| PACKAGING | Mercadoria | — | — | Count, Weight, Area | ProductionInput |
| CAPEX_ASSET | Mercadoria | — | sim | Count | FixedAsset |
| SERVICE | Serviço | — | — | Time, Service, Area | ConsumableUse |
| RECURRING_SERVICE | Serviço | sim | — | Time, Service | ConsumableUse |
| SOFTWARE_SUBSCRIPTION | Serviço | sim | — | Service, Time | ConsumableUse |
| RENTAL | Serviço | sim | — | Time, Service, Count | ConsumableUse |
Por que tipo configurável e não enum. O tipo de um item não é estável: MRO vira contrato de manutenção, software perpétuo vira assinatura. Migrar entre entidades é caro; mudar um ItemTypeId é barato — desde que o domínio revalide os campos condicionais na troca (ver RN-ITM-06).
Domínio · Item
Campos do item
Legenda de obrigatoriedade: obrig sempre · cond conforme ItemType ou categoria · opc opcional · gerado calculado pelo sistema.
Identificação
| Campo | Tipo | Regra | |
|---|---|---|---|
| internalCode | string(16) | gerado | Formato CAT-{YYYY}-{NNNNN}, único por tenant, imutável. Gerado por IItemCodeService. |
| externalCode | string(100) | opc | Código legado ou do ERP. Não é único — chave de upsert na importação. |
| itemTypeId | Guid | obrig | Deve referenciar tipo ativo do tenant. |
| gtin | string(14) | opc | 8, 12, 13 ou 14 dígitos com dígito verificador válido. Único por tenant quando informado. Recusado se requiresServiceTaxCode. |
| manufacturerName | string(160) | opc | Fabricante ou marca. Não confundir com fornecedor. |
| mpn | string(100) | opc | Part number do fabricante. O par (manufacturerName, mpn) normalizado é único por tenant. |
Classificação e descrição
| Campo | Tipo | Regra | |
|---|---|---|---|
| categoryNodeId | Guid | obrig | Deve ser nó folha ativo (sem filhos ativos). Ver RN-ITM-11. |
| basicName | string(60) | obrig | Nome básico do item, equivalente ao INC do CATMAT. Sem modificadores: PARAFUSO, não PARAFUSO M8 INOX. |
| standardDescription | string(200) | gerado | Montada a partir de basicName + atributos marcados como usedInDescription, na ordem definida. Recalculada a cada alteração de atributo. Digitável apenas quando a categoria não tem esquema de atributos. |
| technicalDescription | string(1000) | opc | Texto livre complementar. Nunca canônico, nunca usado em matching. |
| unspscCode | string(8) | opc | Mapeamento para taxonomia externa. Só para benchmark e exportação — não participa de nenhuma regra. |
| keywords | string(50)[] | opc | Máx. 10. Entram na busca full-text. |
Unidades e características físicas
| Campo | Tipo | Regra | |
|---|---|---|---|
| baseUomId | Guid | obrig | Unidade canônica do item. Toda comparação de preço normaliza para ela. A classe da unidade deve constar em ItemType.allowedUomClasses. Imutável após o primeiro pedido. |
| defaultPurchaseUomId | Guid | opc | Default = base. Precisa ter conversão cadastrada para a base. |
| conversions | ItemUomConversion[] | opc | Ver Unidades e conversão. |
| netWeight / netWeightUomId | decimal(18,6) | cond | Só quando requiresGoodsTaxCode. Unidade da classe Peso. |
| grossWeight / grossWeightUomId | decimal(18,6) | cond | Idem. Usado em frete e carga. |
| length / width / height / dimensionUomId | decimal(18,6) | cond | Idem. Unidade da classe Comprimento, comum às três medidas. |
Comercial
| Campo | Tipo | Regra | |
|---|---|---|---|
| referencePrice | decimal(18,4) | opc | Benchmark exibido no comparativo de cotações. Exige catalog.price:manage. |
| referencePriceCurrency | string(3) | cond | ISO 4217. Obrigatório quando há preço. Deve estar entre as moedas ativas do tenant. |
| referencePriceUomId | Guid | cond | Em qual unidade o preço está. Default = base. Preço sem unidade é número sem significado. |
| referencePriceUpdatedAt | datetime | gerado | Exibido junto ao preço. Preço de referência de dois anos atrás é ruído, não benchmark. |
Recorrência — quando allowsRecurrence
| Campo | Tipo | Regra | |
|---|---|---|---|
| defaultBillingPeriod | enum | cond | Monthly | Quarterly | SemiAnnual | Annual | OnDemand. |
| billingUnit | string(40) | opc | Base de cobrança: usuário-mês, licença, host, GB. Texto controlado por lista do tenant. |
Vigência real, data de renovação, aviso prévio e regra de reajuste não ficam no item — ficam no contrato e são copiados para a linha do pedido. A mesma licença tem vigências diferentes por fornecedor; renewalDate no item master é dado que nasce errado.
Política de compra
| Campo | Tipo | Regra | |
|---|---|---|---|
| isPurchasable | bool | obrig | Separado de "existe". Item pode existir no catálogo e não ser requisitável hoje. |
| requiresInspection | bool | opc | Marca o recebimento como sujeito a inspeção. |
| requiresAssetTag | bool | opc | Default herdado de ItemType.isCapex, sobrescrevível. |
| requiresContract | bool | opc | Item só pode virar pedido sob contrato vigente. |
| allowsPartialReceipt | bool | opc | Default true. |
| requiresNationalContent | bool | opc | Flag de negócio para política de conteúdo nacional. Não é o código de origem da NF-e. |
| criticality | enum? | opc | Strategic | Leverage | Bottleneck | Routine (matriz de Kraljic). Alimenta analytics e pode entrar como critério de aprovação. |
| accessGroupIds | Guid[] | opc | Vazio = visível a todos. Restringe quem enxerga e requisita o item. |
Ciclo de vida e controle
| Campo | Tipo | Regra | |
|---|---|---|---|
| approvalStatus | enum | gerado | Draft | PendingApproval | Approved | Rejected. Muda só por comandos de transição. |
| rejectionReason | string(500) | cond | Obrigatório na rejeição, mínimo 20 caracteres. |
| lifecyclePhaseId | Guid | obrig | Fase configurável por tenant. Traz a matriz processo × política. |
| processPolicyOverrides | map | opc | Sobrescreve a política da fase para este item específico. |
| successorItemId | Guid? | opc | Item substituto. Exibido ao requisitante quando o item está em descontinuação. |
| validFrom / validTo | date? | opc | Vigência do cadastro. Fora da vigência, o item não aparece na requisição. |
| predominantUse | enum | obrig | Ver Fiscal. Default vem do ItemType. |
| version | int | gerado | Nasce 1, incrementa a cada update. Token de concorrência do PUT. |
| completenessScore | int | gerado | Percentual de atributos obrigatórios e recomendados preenchidos. Indicador de governança, exibido na listagem. |
Coleções filhas
| Coleção | Limite | Regra |
|---|---|---|
| attributeValues | esquema da categoria | Um valor por AttributeDefinition, ou N quando o atributo é multivalorado. |
| conversions | 20 | Uma por unidade de destino. Não pode conter a unidade base. |
| photos | 5 | JPG, PNG, WEBP, 5 MB cada. IObjectStorageService, path tenants/{tid}/catalog/{iid}/photos/{uuid}.{ext}, URL pré-assinada com TTL de 1h. |
| documents | 20 | FISPQ, certificado, laudo, manual. Tipo, versão, data de emissão, validade e arquivo. Documento vencido gera alerta e pode bloquear a compra. |
| compliances | 10 | Registro regulatório: órgão, número, validade. Ver Regulatório. |
| keywords | 10 | 50 caracteres cada. |
Fornecedores preferidos não são mais uma coleção do item — viraram o agregado ItemSupplier, com preço, prazo e status próprios.
Domínio · CategoryNode
Taxonomia
Um único tipo, auto-relacionado por parentId. Nenhuma taxonomia de referência de compras tem dois níveis — UNSPSC, eCl@ss, GPC e CATMAT têm quatro; as spend taxonomies corporativas convergem em três a quatro. E nenhum produto pesquisado usa profundidade fixa: Ariba, Coupa, Dynamics e Oracle usam todos lista de adjacência.
| Campo | Tipo | Regra |
|---|---|---|
| parentId | Guid? | Nulo = raiz. Ciclo é recusado. Profundidade validada contra TenantSettings.catalogMaxCategoryDepth. |
| code | string(30) | Único por tenant. Estável — é o que a importação e as regras de aprovação referenciam. |
| name / description | string(120) / string(500) | Nome único entre irmãos. |
| path | ltree | gerado Caminho materializado, usado para consulta de ancestrais e descendentes em uma query. Recalculado em cascata ao mover um nó. |
| level | int | gerado 1-based, derivado do path. |
| sortOrder | int | Ordem de exibição entre irmãos. |
| suggestedNcm | string(8)? | Pré-preenche o NCM esperado dos itens da categoria. Sugestão, nunca imposição. |
| defaultItemTypeId | Guid? | Pré-seleciona o tipo ao criar item nesta categoria. |
| requiredComplianceKinds | enum[] | Exigências regulatórias herdadas por todos os itens da subárvore. Ex.: categoria "EPI" exige CA. |
| isActive | bool | Desativação em cascata para a subárvore. Categoria inativa não aceita novos itens; itens existentes não são afetados. |
Profundidade livre no armazenamento, teto na política
maxDepth é configuração do tenant, default 2 — a UI segue exibindo dois selects encadeados e nada muda para quem já usa. Regras de aprovação e mapeamentos contábeis operam sobre qualquer nível. É o padrão da Oracle, que permite criar mais de dez níveis mas só amarra política nos dez primeiros: desacopla "quantos níveis o cliente desenha" de "quantos o motor entende".
Herança para baixo, override no filho
Esquema de atributos, NCM sugerido, tipo default e exigências regulatórias são herdados do ancestral mais próximo que os define. O nó filho pode acrescentar, nunca remover o que o pai marcou como obrigatório.
Item só classifica em folha
Nó com filhos ativos não aceita item. Sem essa regra, metade do catálogo fica pendurada em nós intermediários e a agregação por categoria mente. Ao criar um filho sob um nó que já tem itens, o sistema exige a reclassificação desses itens antes de concluir — com sugestão automática por atributos.
Migração a partir de Category / Subcategory
Cada Category vira nó nível 1 com parentId = null; cada Subcategory vira nó nível 2 apontando para ela. Item.subcategoryId vira categoryNodeId; categoryId é descartado, já que o path o reconstrói. Categoria sem subcategorias ganha um filho GERAL para satisfazer a regra da folha. Migração determinística, sem decisão humana.
Migration · substitui o agregado Category e a entidade Subcategory
Domínio · AttributeDefinition
Atributos por categoria
É o mecanismo que faz "parafuso precisa de bitola, rosca e material" e "notebook precisa de processador, RAM e tela" conviverem sem explodir a taxonomia. Quatro implementações independentes chegaram ao mesmo desenho — eCl@ss properties, SAP characteristics, Oracle attribute groups por item class e o PDM do CATMAT.
Definição do atributo
| Campo | Tipo | Regra |
|---|---|---|
| code / name | string(40) / string(80) | Código único por tenant, imutável. |
| dataType | enum | Text | LocalizedText | Integer | Decimal | Measure | Currency | Boolean | Date | List | MultiList | Reference. Imutável após o primeiro valor gravado. |
| uomClassId | Guid? | Obrigatório em Measure. É o que faz 3/8 pol e 9,525 mm serem comparáveis. |
| minValue / maxValue / decimals | decimal? / int | Validação de faixa para tipos numéricos. |
| values | AttributeValue[] | Lista de valores codificados, para List e MultiList. |
| isRestrictive | bool | Lista fechada (só os valores cadastrados) ou sugerida (aceita valor novo, que entra na lista). "Cor da tinta" é fechada; "acabamento" pode ser aberta. |
| isMultiValued | bool | Permite N valores no mesmo atributo. Ex.: certificações: ISO 9001, ISO 14001. |
Vínculo categoria × atributo
| Campo | Tipo | Regra |
|---|---|---|
| categoryNodeId / attributeDefinitionId | Guid | Chave composta. |
| isRequired | bool | Item não é aprovado sem os obrigatórios preenchidos. |
| usedInDescription | bool | Participa da descrição gerada. |
| descriptionOrder | int? | Posição na descrição gerada. Obrigatório quando usedInDescription. |
| displayOrder | int | Ordem no formulário. |
categoria FIXADORES › PARAFUSOS › SEXTAVADOS
basicName PARAFUSO
atributos tipo_cabeca = Sextavado (ordem 1)
diametro = M8 (ordem 2, Measure)
comprimento = 40 mm (ordem 3, Measure)
material = Aço inox 304 (ordem 4, List restritiva)
rosca = Parcial (ordem 5, List restritiva)
standardDescription → "PARAFUSO SEXTAVADO M8 X 40MM AÇO INOX 304 ROSCA PARCIAL"
attributeSignature → sha256("tipo_cabeca=SEXTAVADO|diametro=8MM|…")
A assinatura é o hash normalizado dos atributos obrigatórios. Duas pessoas preenchendo os mesmos valores produzem a mesma assinatura — e a duplicata é detectada antes de existir.
Categoria sem esquema continua funcionando
Nem todo tenant vai desenhar PDM no primeiro dia. Categoria sem atributos vinculados aceita standardDescription digitada, e o completenessScore reflete isso. O esquema é uma evolução da qualidade do cadastro, não um pré-requisito de adoção.
Atributo não vira coluna
Valores vivem em ItemAttributeValue com colunas tipadas (valueText, valueNumber, valueBool, valueDate, valueListId) mais uomId. Nenhum produto pesquisado modela atributo como coluna do item — com taxonomias distintas por tenant, seria inviável.
Domínio · UnitOfMeasure
Unidades e conversão
Esta é a seção que paga ou cobra no comparativo de cotações. Se o fornecedor A cota R$ 120 / CX(12 UN) e o B cota R$ 11 / UN, o comprador precisa ver R$ 10,00/UN vs R$ 11,00/UN sem calculadora.
Classes de unidade
| Classe | Base sugerida | Convertível | Observação |
|---|---|---|---|
| Count | UN | sim | CX, DZ, MIL, PC, PAR — conversão sempre por item. |
| Weight | KG | sim | G, TON. Conversão padrão global. |
| Volume | L | sim | ML, M3. |
| Length | M | sim | MM, CM, KM, POL. |
| Area | M2 | sim | CM2, HA. |
| Time | H | sim | MIN, DIA, MES. Conversão global só onde é inequívoca. |
| Service | VB | não | Verba, entrega, marco, ponto de função. Classe não conversível — ver abaixo. |
Conversão por item, com numerador e denominador
ItemUomConversion (itemId, uomId, numerator, denominator) sobrepõe a conversão global da classe. O par inteiro evita o erro de arredondamento que um fator decimal introduz — 1 MIL = 1000 UN em inteiros, não 0,001. O NetSuite escolheu conversão global no nível do Units Type e documenta as consequências: não é possível editar o tipo depois de atribuído nem trocar o tipo de um item. SAP (MARM) e Oracle fazem por item, e a Oracle é explícita ao exigir que o item exista antes de criar conversões.
A classe Serviço não converte, nem entre si
Não existe "1 mês = X horas" universal. Duas propostas de consultoria — uma em horas, outra em verba global — não têm denominador comum. O sistema recusa a comparação numérica e sinaliza, em vez de estimar. Forçar preço por unidade base em serviço produz número sem significado.
A cotação guarda o original e o normalizado
A linha de cotação carrega (priceAmount, qty, quotedUomId, declaredFactor) e o sistema deriva pricePerBaseUom. Guardar só o normalizado perde a informação original e impede auditoria; guardar só o original impede comparação. O declaredFactor é o que o fornecedor afirma caber na embalagem — se divergir do cadastro do item, o comparativo sinaliza em vez de calcular errado em silêncio.
Sem conversão cadastrada, sem comparação
Se um fornecedor cota em KG e outro em UN e não há conversão interclasse para aquele item, o comparativo exibe as duas propostas lado a lado sem preço normalizado, com aviso explícito. Estimar seria pior que não comparar.
A unidade base é imutável depois do primeiro pedido
Trocar a base reescreve o significado de todo o histórico de preço e de todo o comparativo já emitido. Se for realmente necessário, o caminho é criar item sucessor e marcar o atual como substituído.
Domínio · ItemSupplier
Item × Fornecedor
Agregado próprio. Coupa (/supplier_items), SAP (purchasing info record), Oracle (Approved Supplier List) e Ariba (o próprio arquivo CIF) são unânimes: essa relação é uma entidade, não uma lista no item.
| Campo | Tipo | Regra | |
|---|---|---|---|
| itemId / supplierId | Guid | obrig | Fornecedor precisa existir e não estar bloqueado. |
| establishmentId | Guid? | opc | Compõe a chave. Nulo = vale para todos os estabelecimentos do fornecedor. É o eixo supplier site da ASL da Oracle, sem obrigar todo cliente a modelar filiais. |
| shipToLocationId | Guid? | gancho | Reservado. Preço e prazo variam por destino; o campo entra na chave quando houver cliente com várias filiais compradoras. |
| supplierPartNumber | string(100) | obrig | Código do item no fornecedor. Vai para o pedido e é a chave do matching da nota. |
| supplierAuxPartNumber | string(100) | opc | Diferencia variante ou opção de entrega sob o mesmo part number. |
| purchaseUomId + numerator/denominator | Guid, int, int | obrig | A unidade de compra é do fornecedor, não do item. O fornecedor A vende em CX(12); o B, em CX(24). |
| priceAmount / currency | decimal(19,10) / char(3) | opc | Moeda ativa no tenant. Dez casas decimais — ver o documento de Unidade de Medida. |
| priceUomId / priceQty | Guid / decimal(19,6) | cond | Pode diferir da unidade de compra — compra-se em SC e cobra-se por TN. priceQty é a quantidade de precificação (preço "por 1000"), obrigatória junto com o preço. |
| supplierUomLabel | string(40) | opc | Como o fornecedor chama a embalagem. Só conferência humana contra a proposta — nunca participa de cálculo. |
| priceTiers | tier[] | opc | Faixas por quantidade: (minQty, priceAmount). Máx. 10. |
| validFrom / validTo | date? | opc | Vigência do preço. Preço vencido não entra em comparativo sem aviso. |
| leadTimeDays | int? | opc | Entra no comparativo ao lado do preço — prazo é decisão, não detalhe. |
| moq / orderIncrement / standardQuantity | decimal? | opc | Quantidade mínima, múltiplo e quantidade padrão de pedido, na unidade de compra. |
| incoterm / deliveryLocation | string(3) / string(160) | opc | Relevante para importação e para comparar propostas com frete embutido. |
| contractId | Guid? | opc | Quando presente, o contrato sobrepõe preço, prazo e múltiplo. |
| status | enum | obrig | New | UnderQualification | Approved | Blocked. Não é rótulo: cada valor carrega allowsQuotation e allowsOrder. |
| isPreferred / priority | bool / int | opc | Ordem de sugestão ao abrir RFQ. Máx. 10 preferidos por item. |
| overTolerancePct / underTolerancePct | decimal? | opc | Tolerância de recebimento. Precedência: SupplierEstablishment.receiptTolerance → ItemSupplier → linha do pedido. Avaliada sempre em unidade base. |
| reviewByDate | date? | opc | Revisão planejada da relação. Vencida, entra no painel de saúde do catálogo. |
Precedência de valores na linha do pedido: contrato → ItemSupplier → Item. O item só fornece o que ninguém mais forneceu (unidade base, descrição, atributos). Preço e prazo vindos do item são sempre referência, nunca compromisso.
Quando o status do fornecedor muda para bloqueado no módulo Fornecedores, o handler de SupplierStatusChangedEvent marca os ItemSupplier como Blocked — sem apagar o registro. Desativação destrutiva é o erro documentado da Coupa, onde Active = No destrói os supplier items.
Brasil
Fiscal e regulatório
O Nexio é o lado do comprador: a maioria dos atributos fiscais não é dado que se cadastra, é dado que chega na nota. Cada campo abaixo está em um de três baldes.
Atributo estável do item, necessário antes de comprar — para especificar, comparar, exigir ou bloquear.
Vem no XML da nota. O Nexio armazena como dado observado e usa para conferência. Não se cadastra.
Responsabilidade do ERP fiscal. Guardar aqui produz dado errado e cria duas fontes de verdade.
Mercadorias — quando requiresGoodsTaxCode
| Campo | Balde | Regra no Nexio |
|---|---|---|
| expectedNcm | cadastrar | 8 dígitos, validado contra a tabela NCM espelhada. Rotulado como NCM esperado. Pré-preenchido pelo suggestedNcm da categoria. Divergência contra a nota vira alerta de conferência. |
| expectedCest | cadastrar | 7 dígitos, opcional, exige NCM preenchido. Relevante só para tenants que revendem. A relação NCM→CEST é 1:N e se resolve pela descrição — o autocomplete sugere, o humano escolhe. |
| gtin | cadastrar | Justificado por antiduplicidade, matching de linha de nota e leitura por coletor. A obrigação fiscal é do emitente; aqui é valor de negócio. |
| Origem da mercadoria (0–8) | esperar | Depende de como o emitente adquiriu — o mesmo produto sai com origem 1 pelo importador e 2 pelo distribuidor. É atributo do par item×fornecedor observado, não do item. Para política de conteúdo nacional existe a flag requiresNationalContent. |
| cEANTrib, uTrib, qTrib | não carregar | Campos de emissão. O conceito — unidade de compra × unidade base × fator — está modelado em Unidades; o campo fiscal, não. |
| cProdANP | esperar | Setorial (combustíveis). Vem na nota. |
Serviços — quando requiresServiceTaxCode
| Campo | Balde | Regra no Nexio |
|---|---|---|
| serviceCodeLc116 | cadastrar | Subitem da lista da LC 116/2003 (ex.: 7.02). Estrutura de dois níveis, ~200 subitens ativos após as alterações até a LC 218/2025. |
| cTribNac | cadastrar | Código-âncora recomendado. Os 338 códigos do Código de Tributação Nacional da NFS-e desdobram a LC 116 de forma padronizada — é o primeiro código de serviço nacionalmente comparável, o que viabiliza benchmark entre tenants de municípios diferentes. |
| nbsCode | cadastrar | 9 dígitos, formato 1.XXXX.XX.XX. Opcional hoje, com tendência clara de obrigatoriedade conforme IBS/CBS amadurece. Campo preparado agora, exigido depois. |
| cTribMun | não carregar | Varia por município do prestador. Não é atributo do item. |
| Alíquotas e regras de retenção | não carregar | Retenção depende de quatro variáveis e o item carrega apenas uma. Ver o quadro abaixo. |
Por que não existe campo "retenções" no item. IRRF, INSS, CSRF e ISS retido dependem de: (a) tipo de serviço — atributo do item; (b) natureza jurídica e regime do prestador — atributo do fornecedor; (c) forma de execução — atributo do contrato ou do pedido: a mesma "manutenção predial" dispara INSS de 11% com equipe alocada e pode não disparar por empreitada de resultado; (d) município e regra de local da prestação — atributo da operação.
O item guarda o código de classificação. A matriz de retenção vive no motor fiscal. O que o Nexio deve oferecer é a flag cessaoDeMaoDeObra na linha do pedido ou no contrato — porque isso é decisão de compra, negociada, e ninguém a jusante sabe disso melhor que o comprador.
Reforma tributária — o único campo que nasce no comprador
| Campo | Balde | Regra no Nexio |
|---|---|---|
| predominantUse | cadastrar | ProductionInput | Resale | ConsumableUse | FixedAsset | PersonalBenefit. Default do ItemType, sobrescrevível na linha do pedido. O art. 57 da LC 214/2025 pergunta se o bem se destina à atividade econômica ou ao consumo pessoal — o fornecedor não sabe, o ERP fiscal não sabe, o sistema de compras sabe: está no requisitante, no centro de custo e na justificativa. |
| cClassTrib | não carregar | A armadilha de 2026. O mesmo NCM tem cClassTrib diferentes conforme a descrição precisa e o tipo de operação — absorvente e fralda descartável compartilham NCM e têm códigos distintos. Não é derivável do cadastro de item. Armazenar apenas o observado na nota. |
| CST IBS/CBS, alíquotas, reduções | esperar | Insumo de custo real, vindo da nota. |
| CST/CSOSN, CFOP, alíquotas ICMS/IPI/PIS/COFINS, cBenef, FCI | não carregar | CFOP é 100% da operação. Alíquota é função de origem, destino, regime, produto, benefício e data — guardar no item é garantir número errado. Custo real vem da nota recebida. |
Durante 2026 as notas chegam com dados de IBS/CBS incompletos ou ausentes e ainda assim autorizadas — o Ato Técnico Conjunto RFB/CGIBS nº 1/2026 adiou as validações, não a obrigação. O parser de nota não pode tratar ausência como erro.
Regulatório — ItemCompliance e ItemDocument
Quase nada disso é "um campo no item". A regra de decisão: flag quando a política é universal e binária; atributo de categoria quando é específica de um tipo de produto; objeto com validade quando tem número, órgão emissor e vencimento; documento anexo quando o que importa é o arquivo.
| Caso | Mecanismo | Onde mora |
|---|---|---|
| EPI — Certificado de Aprovação | ItemCompliance | Nº do CA, órgão MTE, validade. Bloqueia a compra quando vencido — é vedado ao empregador fornecer EPI sem CA. O caso mais forte de campo estruturado. |
| Registro ANVISA (medicamento, saneante, correlato) | ItemCompliance | Nº de registro do produto no item. A AFE é do fornecedor, não do item. |
| Registro MAPA (fertilizante, defensivo, veterinário) | ItemCompliance | Nº de registro no item. Receituário agronômico é documento da requisição — tem validade curta e é por aplicação. |
| Produto controlado — Exército / PF / Polícia Civil | flag + habilitação | controlKind no item (None | FederalPolice | Army | StatePolice). CR, licença e mapas mensais são habilitação do tenant e do fornecedor, com validade. Nunca no item. |
| Produto perigoso — FISPQ | ItemDocument + campos | FISPQ anexada com versão e data. Extraídos como campos: número ONU e classe de risco — porque disparam regra de transporte e armazenagem. |
| Certificação INMETRO / homologação ANATEL | ItemCompliance | Nº do certificado, órgão, validade, arquivo anexo. |
| Exigência de laudo ou certificado na entrega | requiredDocumentKinds | Lista herdada da categoria. O recebimento cobra os documentos declarados. |
ItemCompliance: kind, agency, registrationNumber, issuedAt, expiresAt, documentId?, blocksPurchaseWhenExpired. Um job diário varre vencimentos e notifica os responsáveis pelo catálogo via GrydNotifications.
Governança
Ciclo de vida
Três eixos ortogonais, seguindo a Oracle, que separa Status, Lifecycle Phase e Approval Status como atributos distintos. Colapsar os três num único enum é o erro que obriga a inventar estados como "ativo mas em revisão e bloqueado para pedido".
Eixo 1 — aprovação do cadastro
| De | Para | Comando | Regra |
|---|---|---|---|
| — | Draft | POST /items | Quem tem catalog:create. Validação mínima: nome básico, tipo, categoria, unidade base. |
| Draft | PendingApproval | POST /items/{id}/submit | Exige atributos obrigatórios da categoria preenchidos e re-checagem de duplicidade. |
| PendingApproval | Approved | POST /items/{id}/approve | Exige catalog.item:approve. O aprovador vê os candidatos a duplicata antes de decidir. |
| PendingApproval | Rejected | POST /items/{id}/reject | Motivo obrigatório, mínimo 20 caracteres. Notifica o criador. |
| Rejected | Draft | PUT /items/{id} | Editar um item rejeitado o devolve a rascunho. |
| Approved | PendingApproval | PUT /items/{id} | Somente quando a alteração toca campos materiais (categoria, tipo, unidade base, atributos obrigatórios, identificadores). Alterações menores não reabrem aprovação. |
Eixo 2 — fase do ciclo de vida
Configurável por tenant (ItemLifecyclePhase). Fases-semente: New, Active, PhasingOut, Obsolete, Superseded. Fase não é status de aprovação nem bloqueio — é onde o item está na vida dele.
Eixo 3 — matriz processo × política
Cada fase declara, por processo, uma das três políticas do modelo do D365: permitido, aviso ou bloqueado.
| Fase | Requisição | Cotação | Pedido | Recebimento |
|---|---|---|---|---|
| New | bloqueado | permitido | bloqueado | bloqueado |
| Active | permitido | permitido | permitido | permitido |
| PhasingOut | aviso | aviso | permitido | permitido |
| Obsolete | bloqueado | bloqueado | bloqueado | permitido |
| Superseded | bloqueado | bloqueado | bloqueado | permitido |
Recebimento permanece permitido em todas as fases finais: bloquear o recebimento de um pedido legítimo já colocado transforma uma decisão de catálogo num problema operacional. E Superseded sempre exibe o successorItemId ao requisitante — bloquear sem oferecer alternativa gera compra fora do processo.
Qualidade de dados
Antiduplicidade
Seis camadas, três pontos de disparo. O modelo de dois limiares vem do SAP MDG: abaixo do limiar baixo nada é sinalizado; entre os dois, apresenta-se como candidato; no ou acima do alto, considera-se idêntico — e ainda assim se apresenta ao usuário, nunca se decide sozinho.
| # | Regra de match | Ação |
|---|---|---|
| 1 | Exato em (manufacturerName, mpn) normalizado — maiúsculas, sem hífen, sem espaço | bloqueia |
| 2 | Exato em gtin | bloqueia |
| 3 | Exato em (supplierId, supplierPartNumber) | bloqueia no ItemSupplier |
| 4 | attributeSignature — hash dos atributos obrigatórios da categoria | bloqueia |
| 5 | Similaridade trigram (pg_trgm) sobre standardDescription, dentro da mesma categoria, com dicionário de abreviações pt-BR (PARAF→PARAFUSO, GALV→GALVANIZADO, INOX→AÇO INOXIDÁVEL) | alerta com score e candidatos |
| 6 | Re-execução das camadas 4 e 5 na submissão e na aprovação | alerta ao aprovador |
A tela de novo item começa como busca
POST /catalog/items/duplicate-check recebe nome básico, categoria e atributos parciais e devolve candidatos antes de qualquer gravação. É o passo mais barato do fluxo e o que mais reduz duplicata — bloquear na submissão já é tarde, o usuário já investiu no cadastro.
Bloqueio tem escape auditado
Quem tem catalog.item:approve pode forçar a criação de um item que casou em camada bloqueante, com justificativa obrigatória registrada em GrydAudit. Existem casos legítimos — mesmo MPN com especificações divergentes entre fabricantes homônimos. Bloqueio sem escape gera cadastro criativo: "PARAFUSO M8 (2)".
Rastreabilidade
Regras de negócio
Código interno gerado e imutável
Formato CAT-{YYYY}-{NNNNN}, único por tenant, sem edição manual. Reservado no momento da criação, mesmo em rascunho, para não haver furo de sequência.
Todo item tem tipo, categoria folha e unidade base
Os três são obrigatórios já no rascunho. É o mínimo que torna o item comparável, classificável e cotável.
A classe da unidade base deve ser permitida pelo tipo
ItemType.allowedUomClasses é validado no domínio. Impede matéria-prima medida em horas e consultoria medida em quilos.
Campos condicionais são recusados fora do seu tipo
NCM, CEST, GTIN, peso e dimensões em item de serviço, ou código LC 116 em matéria-prima, respondem 400 ITEM_FIELD_NOT_APPLICABLE com o nome do campo e do tipo. Ignorar silenciosamente é pior — o usuário acha que gravou.
Nenhum campo fiscal é obrigatório para criar item
Nem para aprovar. O tenant pode elevar campos a obrigatórios via ItemType.requiredFields, mas o default do produto é opcional.
Troca de tipo revalida os campos condicionais
Ao mudar itemTypeId, o domínio verifica os campos preenchidos contra o novo tipo. Campos que deixam de se aplicar são listados na resposta e a operação só prossegue com discardIncompatibleFields: true explícito. Os valores descartados ficam no log de auditoria.
A descrição padronizada é derivada
Recalculada a cada alteração de basicName ou de atributo marcado como usedInDescription. Só é editável quando a categoria não tem esquema de atributos. Truncada em 200 caracteres com aviso quando os atributos geram texto maior.
Atributos obrigatórios da categoria bloqueiam a submissão
Não bloqueiam a criação do rascunho — bloqueiam submit. Cadastrar em duas sessões é um caso real; aprovar item incompleto não é.
Conversão de unidade é por item, com par inteiro
numerator e denominator inteiros positivos. A unidade base não pode figurar como destino. Conversão entre classes distintas só existe por item e nunca é herdada da conversão padrão.
A unidade base é imutável após o primeiro documento
Requisição, cotação ou pedido referenciando o item congelam a base. Alteração responde 409 ITEM_BASE_UOM_LOCKED_CONFLICT e sugere criar item sucessor.
Item só classifica em nó folha ativo
Criar filho sob um nó que tem itens exige reclassificar esses itens na mesma transação. A API devolve 409 CATEGORY_NODE_HAS_ITEMS_CONFLICT com a contagem, e o comando aceita um mapa de reclassificação.
Profundidade da árvore respeita o teto do tenant
Criar nó além de catalogMaxCategoryDepth responde 400 CATEGORY_MAX_DEPTH_EXCEEDED. Aumentar o teto é operação de admin e não afeta nada existente; diminuir é recusado enquanto houver nós mais profundos.
Categoria e tipo inativos não recebem novos itens
Itens existentes não são afetados nem reclassificados. Ao desativar, a resposta informa a contagem de itens ativos atingidos.
Item não é excluído fisicamente
Havendo requisição, cotação, pedido ou nota vinculada, só há mudança de fase. O DELETE existe apenas para itens em Draft nunca submetidos.
Fornecedor bloqueado não apaga o vínculo
SupplierStatusChangedEvent muda o ItemSupplier para Blocked, preservando preço, histórico e prioridade. Reaprovação do fornecedor restaura o vínculo no status anterior.
Compliance vencido bloqueia a compra quando declarado
blocksPurchaseWhenExpired impede requisição e pedido a partir do vencimento. Job diário notifica 30, 15 e 5 dias antes. É o comportamento exigido para EPI sem CA válido.
Destinação predominante é obrigatória e sobrescrevível
Default vem do ItemType. A linha do pedido pode sobrescrever, e a sobrescrita é auditada — é evidência da intenção no momento da compra, defensável em fiscalização.
Preço de referência exige moeda e unidade
Os três campos gravam juntos ou nenhum grava. Preço sem unidade não é comparável e vira ruído no comparativo de cotações.
Concorrência otimista em todo update
expectedVersion obrigatório no corpo do PUT, checado antes de tocar o agregado. Versão defasada responde 409 ITEM_VERSION_CONFLICT com a versão atual na mensagem.
Auditoria completa com diff de campos
Item, ItemSupplier, CategoryNode e AttributeDefinition são IAuditableEntity, categoria Catalog. Registram campo, valor anterior, valor novo, usuário, IP e timestamp.
Camada API
Endpoints
Prefixo api/v{version}/nexio. Todos exigem [Authorize] e uma permissão explícita via [RequirePermission]. Sucesso é 200, inclusive nos POST, seguindo o padrão já estabelecido no módulo Admin.
Item
| Rota | Permissão | Retorno | Erros |
|---|---|---|---|
| GET /catalog/items | catalog:read | Paged de ItemSummaryDto | 400 |
| GET /catalog/items/{id} | catalog:read | ItemDto completo, com atributos, conversões, documentos e compliance | 404 |
| POST /catalog/items | catalog:create | ItemDto em Draft | 400 · 409 · 422 |
| PUT /catalog/items/{id} | catalog:update | ItemDto | 400 · 404 · 409 |
| POST /catalog/items/duplicate-check | catalog:read | Lista de candidatos com score e camada de match. Sem efeito colateral. | 400 |
| POST /catalog/items/{id}/submit | catalog:create | ItemDto em PendingApproval | 409 · 422 |
| POST /catalog/items/{id}/approve | catalog.item:approve | ItemDto em Approved | 404 · 409 |
| POST /catalog/items/{id}/reject | catalog.item:approve | ItemDto em Rejected | 400 · 404 · 409 |
| PUT /catalog/items/{id}/lifecycle-phase | catalog:update | ItemDto | 400 · 404 · 409 |
| POST /catalog/items/{id}/clone | catalog:clone | Novo item em Draft, sem código interno do original | 404 |
| DELETE /catalog/items/{id} | catalog:delete | Sem conteúdo. Só rascunho nunca submetido | 404 · 409 |
| GET /catalog/items/{id}/audit | catalog.audit:read | Paged de eventos com diff de campos | 404 |
Coleções do item
| Rota | Permissão | Observação |
|---|---|---|
| PUT /catalog/items/{id}/attributes | catalog:update | Reescreve o conjunto inteiro. Recalcula descrição e assinatura. |
| PUT /catalog/items/{id}/uom-conversions | catalog:update | Reescreve o conjunto inteiro. |
| POST /catalog/items/{id}/photos | catalog:update | Multipart. Máx. 5 no total. |
| DELETE /catalog/items/{id}/photos/{photoId} | catalog:update | Chama DeleteAsync do storage. |
| POST /catalog/items/{id}/documents | catalog:update | Tipo, versão, emissão e validade obrigatórios. |
| PUT /catalog/items/{id}/compliances | catalog:update | Reescreve o conjunto. |
| GET /catalog/items/{id}/suppliers | catalog:read | Paged, filtrável por status. |
| POST /catalog/items/{id}/suppliers | catalog.supplier:manage | Preço exige catalog.price:manage adicionalmente. |
| PUT /catalog/items/{id}/suppliers/{supplierItemId} | catalog.supplier:manage | Concorrência por expectedVersion. |
| DELETE /catalog/items/{id}/suppliers/{supplierItemId} | catalog.supplier:manage | Só se nunca usado em cotação ou pedido; caso contrário, bloquear. |
Configuração do catálogo
| Rota | Permissão | Observação |
|---|---|---|
| GET /catalog/categories | catalog:read | Árvore completa ou subárvore via rootId + depth. Não paginada — é árvore, não lista. |
| POST /catalog/categories | catalog.category:manage | Valida ciclo, profundidade e nome único entre irmãos. |
| PUT /catalog/categories/{id} | catalog.category:manage | Mover nó recalcula path e level em cascata, em transação. |
| DELETE /catalog/categories/{id} | catalog.category:manage | Desativa em cascata. Recusa se houver item ativo no nó. |
| PUT /catalog/categories/{id}/attributes | catalog.category:manage | Vincula atributos e define obrigatoriedade e ordem na descrição. |
| GET /catalog/attribute-definitions | catalog:read | Paged, filtrável por tipo de dado. |
| POST /catalog/attribute-definitions | catalog.attribute:manage | dataType imutável após o primeiro valor gravado. |
| GET /catalog/item-types | catalog:read | Consumido pelo formulário para decidir campos visíveis. |
| POST /catalog/item-types | catalog.itemtype:manage | requiresGoodsTaxCode e requiresServiceTaxCode são mutuamente exclusivos. |
| GET /catalog/uoms | uom:read | Módulo UoM. Filtro por classe, consumido pelo autocomplete. |
| GET /catalog/ncm | catalog:read | Tabela NCM espelhada, busca full-text sobre a descrição concatenada. Global, não por tenant. |
Listagem de itens — filtros e ordenação
| Filtros | Ordenável por | Padrão |
|---|---|---|
| search, categoryNodeId (+includeDescendants), itemTypeId, approvalStatus, lifecyclePhaseId, isPurchasable, supplierId, hasExpiredCompliance, completenessBelow | internalCode, standardDescription, updatedAt, completenessScore | updatedAt desc |
Página padrão 20, teto 100, valores fora da faixa ajustados. sortBy inválido responde 400 SORT_FIELD_INVALID com a lista permitida na mensagem. includeDeleted=true é recusado. A busca cobre standardDescription, basicName, internalCode, externalCode, mpn, gtin e keywords. As DTOs resolvem nomes de fornecedor e de categoria pelo diretório — nunca devolvem só Guids.
Contrato
Códigos de erro
Payload de erro: code, traceId e type, com mensagem resolvida em pt-BR, en e es-ES. Onde a tela precisa de dados para agir — lista de duplicatas, campos incompatíveis — ela consulta antes; o erro fica como guarda de corrida.
| Código | HTTP | Quando |
|---|---|---|
| ITEM_NOT_FOUND | 404 | Item inexistente no tenant |
| ITEM_BASIC_NAME_REQUIRED / _TOO_LONG | 400 | Nome básico vazio ou > 60 |
| ITEM_TYPE_REQUIRED / _INACTIVE | 400 | Tipo ausente ou desativado |
| ITEM_CATEGORY_NOT_LEAF | 400 | Categoria informada tem filhos ativos |
| ITEM_BASE_UOM_REQUIRED | 400 | Unidade base ausente |
| ITEM_UOM_CLASS_NOT_ALLOWED | 400 | Classe da unidade não permitida pelo tipo |
| ITEM_FIELD_NOT_APPLICABLE | 400 | Campo preenchido que o tipo não usa — mensagem nomeia campo e tipo |
| ITEM_GTIN_INVALID | 400 | Comprimento ou dígito verificador inválido |
| ITEM_NCM_INVALID | 400 | NCM não existe na tabela vigente |
| ITEM_CEST_REQUIRES_NCM | 400 | CEST informado sem NCM |
| ITEM_PRICE_INCOMPLETE | 400 | Preço sem moeda ou sem unidade |
| ITEM_CONVERSION_INVALID | 400 | Numerador ou denominador ≤ 0, ou destino igual à base |
| ITEM_ATTRIBUTE_NOT_IN_SCHEMA | 400 | Atributo não pertence ao esquema da categoria |
| ITEM_ATTRIBUTE_VALUE_INVALID | 400 | Fora da faixa, fora da lista restritiva ou tipo incompatível |
| ITEM_DUPLICATE_CONFLICT | 409 | Match em camada bloqueante — mensagem traz o código interno do item existente |
| ITEM_VERSION_CONFLICT | 409 | Versão defasada no PUT |
| ITEM_BASE_UOM_LOCKED_CONFLICT | 409 | Unidade base já usada em documento |
| ITEM_INVALID_TRANSITION_CONFLICT | 409 | Transição de aprovação inválida a partir do status atual |
| ITEM_HAS_DOCUMENTS_CONFLICT | 409 | Exclusão de item com requisição, cotação, pedido ou nota |
| ITEM_REQUIRED_ATTRIBUTES_MISSING | 422 | Submissão sem atributos obrigatórios — lista os códigos faltantes |
| ITEM_REJECTION_REASON_REQUIRED | 422 | Rejeição sem motivo ou com menos de 20 caracteres |
| CATEGORY_NODE_NOT_FOUND | 404 | Nó inexistente |
| CATEGORY_MAX_DEPTH_EXCEEDED | 400 | Profundidade acima do teto do tenant |
| CATEGORY_CYCLE_DETECTED | 400 | Mover nó para dentro da própria subárvore |
| CATEGORY_NAME_DUPLICATED_AMONG_SIBLINGS | 409 | Nome repetido entre irmãos |
| CATEGORY_NODE_HAS_ITEMS_CONFLICT | 409 | Criar filho sob nó com itens, ou desativar nó com item ativo |
| ATTRIBUTE_DATA_TYPE_LOCKED_CONFLICT | 409 | Alterar tipo de dado com valores já gravados |
| ITEM_SUPPLIER_DUPLICATED_CONFLICT | 409 | Par item × fornecedor já existe |
| ITEM_SUPPLIER_BLOCKED_CONFLICT | 409 | Fornecedor bloqueado no módulo Fornecedores |
| ITEM_COMPLIANCE_EXPIRED_CONFLICT | 409 | Compra de item com compliance vencido e bloqueante |
Autorização
Permissões
Padrão {verbo}:{recurso}, verbo derivado do método HTTP por [AutoPermission]. Um endpoint declara exatamente uma permissão; nada é implicado por outra.
| Permissão | Situação | Cobre |
|---|---|---|
| catalog:read / create / update / delete | existe | CRUD do item |
| catalog:import / export / clone | existe | Importação, exportação e clonagem |
| catalog.category:manage | existe | Árvore de categorias e vínculo de atributos |
| catalog.supplier:manage | existe | ItemSupplier |
| catalog.price:manage | existe | Preço de referência e preço do fornecedor |
| catalog.audit:read | existe | Histórico por registro |
| catalog.item:approve | novo | Aprovar, rejeitar e forçar criação sobre duplicata bloqueante |
| catalog.attribute:manage | novo | Dicionário de atributos |
| catalog.itemtype:manage | novo | Tipos de item e fases do ciclo de vida |
Grupos sugeridos: Nexio - Catálogo (read/create/update/clone/import/export), Nexio - Curadoria de Catálogo (+ approve, category, attribute, itemtype, price) e Nexio - Consulta de Catálogo (read). Um teste compara o conjunto seedado pelo atributo com o exigido pelo [RequirePermission], como já é feito no módulo Admin.
Onboarding
Importação em massa
É o maior gargalo de adoção: o cliente chega com uma planilha de dezenas de milhares de linhas de texto livre. O desenho abaixo assume isso.
| Coluna | Regra | |
|---|---|---|
| external_code | opc | Chave de upsert. Presente e existente = atualiza; ausente = cria. |
| item_type | obrig | Código do tipo. |
| category_path | obrig | Caminho completo separado por >: FIXADORES > PARAFUSOS > SEXTAVADOS. Nós ausentes são criados quando createMissingCategories: true, respeitando maxDepth. |
| basic_name | obrig | Nome básico. |
| description | cond | Usado apenas quando a categoria não tem esquema de atributos. |
| base_uom | obrig | Símbolo da unidade, validado contra as classes permitidas pelo tipo. |
| attr:{codigo} | cond | Colunas dinâmicas por atributo do esquema. O template baixado já vem com as colunas da categoria escolhida. |
| gtin, mpn, manufacturer | opc | Participam da antiduplicidade. |
| ncm, cest, service_code, ctribnac, nbs | opc | Validados, nunca obrigatórios. |
| reference_price, currency, price_uom | opc | Os três juntos ou nenhum. |
| supplier_code, supplier_part_number, purchase_uom, purchase_factor, price, lead_time, moq | opc | Cria o ItemSupplier na mesma linha. Repetir a linha com fornecedor diferente adiciona vínculo. |
Duas passadas: simular, depois aplicar
POST /catalog/items/import?mode=dryRun devolve o relatório completo — linhas válidas, erros por linha e campo, categorias que seriam criadas, duplicatas detectadas — sem gravar nada. O usuário corrige a planilha e reenvia em mode=apply. Importar 5.000 linhas e descobrir os erros depois é o caminho mais rápido para um catálogo sujo.
Erro por linha não interrompe
Linhas válidas entram; inválidas vão para o relatório com linha, campo e motivo. Limite de 10.000 linhas e 20 MB por arquivo, processamento assíncrono via GrydJobs, notificação por e-mail ao concluir com link para o relatório.
Itens importados nascem em rascunho
Salvo quando o usuário tem catalog.item:approve e marca autoApprove: true. Importação que aprova automaticamente por default transforma a planilha do cliente em verdade sem revisão.
Encadeamento
O que o item entrega ao fluxo de aprovação
O fluxo de aprovação é impactado por este cadastro em cinco pontos. Vale fixá-los agora, porque cada um é uma decisão que o motor de aprovação vai herdar pronta ou vai ter que improvisar.
| O que o item fornece | Como a aprovação usa |
|---|---|
| categoryNodeId + path | A regra amarra em qualquer nível da árvore e vale para os descendentes por rollup. Guardar o nó, nunca a lista expandida de folhas — assim subcategoria criada depois já nasce coberta pela política. |
| itemTypeId | Dimensão de regra natural e barata: serviço e CAPEX quase sempre têm alçada diferente de material de consumo. |
| criticality e flags de política | requiresContract, criticality e controlKind são candidatos a disparar aprovador funcional obrigatório — Jurídico em contrato, SESMT em produto controlado. |
| referencePrice | Insumo de detecção de anomalia de preço: pedido muito acima da referência sinaliza ao aprovador, o que é a correção prescrita para rubber-stamping. |
| predominantUse | Separa despesa de investimento na origem. É o dado que distingue alçada de OPEX de alçada de CAPEX sem depender do centro de custo. |
A pergunta que fica aberta e precisa ser resolvida no motor, não aqui: um pedido tem N linhas, cada uma com sua categoria e seu tipo. O modelo atual do ApprovalFlow tem um CategoryId singular e o invariante de resolver para um único fluxo. A resposta do mercado é avaliar as regras contra as linhas e decidir sobre o pedido inteiro, com união deduplicada de aprovadores. Como não existe ainda entidade de requisição, essa decisão continua gratuita — e é o próximo assunto.
Limites
Fora de escopo e decisões pendentes
- Estoque — saldo, depósito, ponto de reposição, lote, número de série, validade.
ItemType.controlsStockexiste e é semprefalse. - Variantes de item — o par cor/tamanho de varejo. Hoje cada combinação é um item; o esquema de atributos já suporta a modelagem quando fizer sentido.
- Kit e lista técnica — item composto por outros. Exige decisão sobre explosão em requisição e cotação.
- Catálogo de fornecedor externo — punchout, CIF, cXML. O
ItemSupplierfoi desenhado para receber isso depois. - Tradução de itens — Ariba e Coupa modelam i18n do item como entidade filha. Fica previsto, não implementado.
- Compartilhamento de esquema entre tenants. Vale entregar um esquema-semente por setor, ou cada tenant desenha o seu do zero? O primeiro reduz o custo de onboarding; o segundo evita impor taxonomia errada.
- Extração assistida na importação. Mapear texto livre para atributos é tarefa em que LLM é excelente e verificável. Entra no MVP da importação ou depois?
- Mapeamento categoria → conta contábil. Todo produto pesquisado carrega o eixo contábil; clientes vindos do Protheus vão cobrar. Não está nesta spec e provavelmente deveria estar na próxima.
- Multi-empresa.
TenantSettingstem um único CNPJ. Grupo econômico com N CNPJs duplica catálogo, categorias e itens. Decisão de tenancy, não de catálogo — mas o catálogo é quem mais sofre.