Tutorial: central de compras com aprovação

Do zero a uma central de compras com fornecedores, pedidos com total calculado, esteira de aprovação com campos bloqueados e permissões por perfil — só com manifestos e duas páginas.

Neste tutorial você constrói uma central de compras: cadastro de fornecedores, pedidos de compra com total calculado no servidor, uma esteira Rascunho → Em aprovação → Aprovado / Recusado e perfis de acesso. Quase tudo é declarativo: o backend recebe dois manifestos JSON e o frontend, duas páginas com componentes do Core.

Ao final, você terá:

  • CRUD completo de fornecedores e pedidos, com busca, paginação, importação e auditoria;
  • total = quantidade × valorUnitario calculado e validado pelo backend, com prévia no formulário;
  • transições de etapa validadas no servidor, inclusive contra PATCH direto;
  • campos de negócio bloqueados depois que o pedido vai para aprovação;
  • exclusão de fornecedor recusada quando existem pedidos;
  • perfis Comprador e Consulta aplicados no backend.

Pré-requisitos

Uma Central gerada e rodando localmente, como no Quickstart: npx @oondemand/create-central-oon minha-central, MONGO_URI configurado e npm run dev funcionando.

1. Declare as permissões e os perfis

Em central.app.json, acrescente as permissões e dois perfis ao bloco rbac (mantenha o perfil admin que o scaffold já traz):

{
  "rbac": {
    "permissions": ["fornecedores.read", "fornecedores.write", "compras.read", "compras.write"],
    "roles": [
      { "code": "admin", "name": "Administrador", "admin": true, "permissions": ["*"] },
      {
        "code": "comprador",
        "name": "Comprador",
        "description": "Cadastra fornecedores e conduz pedidos de compra.",
        "permissions": ["fornecedores.read", "fornecedores.write", "compras.read", "compras.write"]
      },
      { "code": "consulta", "name": "Consulta", "permissions": ["fornecedores.read", "compras.read"] }
    ]
  }
}

Permissões seguem o formato recurso.acao; "*" só é aceito em perfis admin: true. Detalhes em Autenticação e RBAC.

2. Declare o domínio

Crie backend/central.domain.json com as duas models:

{
  "schemaVersion": 1,
  "name": "Central de compras",
  "models": [
    {
      "name": "Fornecedor",
      "singular": "fornecedor",
      "basePath": "/fornecedores",
      "crud": { "enabled": true, "permissions": { "read": "fornecedores.read", "write": "fornecedores.write" } },
      "fields": {
        "nome": { "kind": "string", "required": true, "maxLength": 120 },
        "cnpj": { "kind": "string", "maxLength": 18 },
        "email": { "kind": "string", "maxLength": 120 }
      }
    },
    {
      "name": "PedidoCompra",
      "singular": "pedido de compra",
      "basePath": "/pedidos-compra",
      "crud": { "enabled": true, "permissions": { "read": "compras.read", "write": "compras.write" } },
      "fields": {
        "descricao": { "kind": "string", "required": true, "maxLength": 120 },
        "fornecedorId": { "kind": "ref", "ref": "Fornecedor", "required": true },
        "quantidade": { "kind": "number", "required": true, "min": 1 },
        "valorUnitario": { "kind": "currency", "required": true, "min": 0 },
        "total": {
          "kind": "currency",
          "computed": {
            "precision": 2,
            "expression": { "op": "multiply", "args": [{ "field": "quantidade" }, { "field": "valorUnitario" }] }
          }
        },
        "etapa": {
          "kind": "enum",
          "values": ["Rascunho", "Em aprovação", "Aprovado", "Recusado"],
          "default": "Rascunho"
        }
      },
      "validations": [
        {
          "name": "total-positivo",
          "code": "PEDIDO_TOTAL_ZERO",
          "message": "O total do pedido precisa ser maior que zero.",
          "field": "valorUnitario",
          "assert": { "op": "gt", "args": [{ "field": "total" }, { "value": 0 }] }
        }
      ]
    }
  ]
}

O que cada parte faz:

  • crud.permissions protege as rotas geradas (GET/POST/PUT/PATCH/DELETE /pedidos-compra e as demais de Models e CRUD).
  • total é computed: fica readonly, é recalculado pelo backend em toda criação, edição e importação, e aparece calculado no formulário enquanto o usuário digita.
  • A validação total-positivo roda depois do cálculo; um pedido com valor zero recebe 422 com o código PEDIDO_TOTAL_ZERO. Mais em Fórmulas e validações.

3. Declare a esteira de aprovação

Crie backend/central.process.json. Ele é carregado depois do domínio, então toda model e campo citados precisam existir no passo anterior:

{
  "schemaVersion": 1,
  "models": {
    "PedidoCompra": {
      "workflow": {
        "stageField": "etapa",
        "initialStages": ["Rascunho"],
        "defaultStage": "Rascunho",
        "transitions": [
          { "from": "Rascunho", "to": "Em aprovação" },
          { "from": "Em aprovação", "to": "Aprovado" },
          { "from": "Em aprovação", "to": "Recusado" },
          { "from": "Recusado", "to": "Rascunho" }
        ],
        "lockedFieldsByStage": {
          "Em aprovação": ["descricao", "fornecedorId", "quantidade", "valorUnitario"],
          "Aprovado": ["descricao", "fornecedorId", "quantidade", "valorUnitario"]
        },
        "lockedMessage": "Pedido enviado para aprovação não pode ser alterado."
      }
    },
    "Fornecedor": {
      "deleteProtection": [
        { "sourceModel": "PedidoCompra", "foreignField": "fornecedorId", "message": "Existem pedidos de compra deste fornecedor." }
      ]
    }
  }
}
  • Só as transições listadas são aceitas. Um pedido Aprovado não volta para Rascunho, e um Rascunho não pula direto para Aprovado — nem por PATCH direto na API.
  • lockedFieldsByStage vale para a etapa anterior da mutação: com o pedido em aprovação, descrição, fornecedor, quantidade e valor não podem mudar. Um pedido Recusado volta para Rascunho e pode ser corrigido.
  • deleteProtection impede excluir um fornecedor que tenha pedidos.

A referência completa está em Esteiras e processos.

4. Crie as páginas

O formulário, as colunas e os rótulos vêm da metadata do backend; as páginas só escolhem o componente.

import { CoreCollection } from "@oondemand/oon-core-front";

export default function FornecedoresPage() {
  return <CoreCollection model="Fornecedor" label="Fornecedores" endpoint="/fornecedores" />;
}
import { CorePipeline } from "@oondemand/oon-core-front";

export default function PedidosCompraPage() {
  return (
    <CorePipeline
      model="PedidoCompra"
      label="Pedidos de compra"
      endpoint="/pedidos-compra"
      titleField="descricao"
      stageField="etapa"
      create={{ enabled: true, label: "Novo pedido", defaultStage: "Rascunho" }}
      defaultActions={false}
      ticketActions={[
        { id: "enviar", label: "Enviar para aprovação", type: "transition", field: "etapa", value: "Em aprovação",
          hiddenWhen: { field: "etapa", notEquals: "Rascunho" }, permissions: ["compras.write"] },
        { id: "aprovar", label: "Aprovar", type: "transition", field: "etapa", value: "Aprovado",
          hiddenWhen: { field: "etapa", notEquals: "Em aprovação" }, permissions: ["compras.write"] },
        { id: "recusar", label: "Recusar", type: "transition", field: "etapa", value: "Recusado",
          hiddenWhen: { field: "etapa", notEquals: "Em aprovação" }, permissions: ["compras.write"],
          confirm: { title: "Recusar o pedido?", description: "O pedido volta a poder ser corrigido em Rascunho." } },
        { id: "reabrir", label: "Voltar para rascunho", type: "transition", field: "etapa", value: "Rascunho",
          hiddenWhen: { field: "etapa", notEquals: "Recusado" }, permissions: ["compras.write"] },
      ]}
      viewModes={["board", "list"]}
      defaultView="board"
      list={{ columns: ["descricao", "fornecedorId", "total", "etapa"] }}
      stages={[
        { id: "Rascunho", label: "Rascunho" },
        { id: "Em aprovação", label: "Em aprovação" },
        { id: "Aprovado", label: "Aprovado" },
        { id: "Recusado", label: "Recusado" },
      ]}
    />
  );
}

defaultActions={false} desliga as ações padrão de aprovação, que levam sempre para a próxima etapa ou de volta para a anterior e não combinam com uma esteira que se ramifica em Aprovado/Recusado. Cada botão de ticketActions corresponde a uma transição declarada no passo 3 e só aparece na etapa de origem (hiddenWhen). Esconder o botão é só experiência: se alguém tentar outra transição, o backend recusa.

Registre as rotas e o menu. As páginas usam export default porque são carregadas com lazy:

import { defineOonRoutes } from "@oondemand/oon-core-front/routing";
import { HomePage } from "../pages/HomePage";

export const routes = defineOonRoutes([
  { path: "/", component: HomePage },
  { path: "/fornecedores", lazy: () => import("../pages/FornecedoresPage"), permissions: ["fornecedores.read"] },
  { path: "/pedidos-compra", lazy: () => import("../pages/PedidosCompraPage"), permissions: ["compras.read"] },
]);
import { defineOonNavigation } from "@oondemand/oon-core-front/routing";

export const navigation = defineOonNavigation([
  { label: "Início", href: "/", order: 10 },
  { label: "Pedidos de compra", href: "/pedidos-compra", section: "Compras", order: 20, permissions: ["compras.read"] },
  { label: "Fornecedores", href: "/fornecedores", section: "Compras", order: 30, permissions: ["fornecedores.read"] },
]);

As permissões em rotas e menus são só experiência: quem autoriza é o backend. Veja Rotas e navegação e Esteiras (CorePipeline).

5. Prove a jornada

Reinicie npm run dev e use o banner do runtime local para trocar de perfil.

Cenário Resultado esperado
Comprador cadastra um fornecedor e um pedido de 10 × 25,00 Pedido criado em Rascunho com total 250,00
Pedido com valor unitário 0 422 com PEDIDO_TOTAL_ZERO
Enviar para aprovação em um Rascunho Aceito; o pedido vai para Em aprovação
Alterar a quantidade com o pedido em aprovação Recusado com a mensagem de campo bloqueado
Recusar um pedido em aprovação e depois Voltar para rascunho Aceito; os campos voltam a ser editáveis
PATCH /pedidos-compra/:id de Rascunho direto para Aprovado Recusado: a transição não existe
Excluir um fornecedor com pedidos Recusado com "Existem pedidos de compra deste fornecedor."
Perfil Consulta tenta criar ou mover um pedido 403: leitura permitida, escrita negada no backend

Depois rode npm run check e npm run build --prefix frontend antes de publicar.

E se só alguns perfis puderem aprovar?

Neste tutorial, qualquer perfil com compras.write move o pedido entre as etapas permitidas. Para separar quem aprova de quem compra, declare uma permissão própria (por exemplo, compras.approve) e exponha a aprovação como rota privada com { permission: "compras.approve" }, em vez de liberar a transição para todos que editam o pedido. A esteira continua garantindo que a transição é válida.

Próximos passos