# 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. > [!NOTE] > Models declaradas em `central.domain.json` são adequadas a Apps **single_tenant dedicados** (o padrão do scaffold para uso próprio). Para isolar linhas por tenant em um App multi-tenant, use `defineModel({ scope: "tenant" })`. Veja [Tipos de App e tenancy](/docs/fundamentos/tipos-de-app-e-tenancy). ## Pré-requisitos Uma Central gerada e rodando localmente, como no [Quickstart](/docs/introducao/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): ```json title="central.app.json" { "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](/docs/seguranca/autenticacao-e-rbac). ## 2. Declare o domínio Crie `backend/central.domain.json` com as duas models: ```json title="backend/central.domain.json" { "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](/docs/backend/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](/docs/backend/formulas-e-validacoes). ## 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: ```json title="backend/central.process.json" { "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](/docs/backend/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. ```tsx title="frontend/src/pages/FornecedoresPage.tsx" import { CoreCollection } from "@oondemand/oon-core-front"; export default function FornecedoresPage() { return ; } ``` ```tsx title="frontend/src/pages/PedidosCompraPage.tsx" import { CorePipeline } from "@oondemand/oon-core-front"; export default function PedidosCompraPage() { return ( ); } ``` `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`: ```tsx title="frontend/src/app/routes.tsx" 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"] }, ]); ``` ```ts title="frontend/src/app/navigation.ts" 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](/docs/frontend/rotas-e-navegacao) e [Esteiras (CorePipeline)](/docs/frontend/esteiras). ## 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](/docs/backend/rotas-privadas) 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 ```cards [ { "title": "Publicação e ambientes", "href": "/docs/operacao/publicacao", "description": "Do commit ao ambiente de Dev, HML e Produção." }, { "title": "Esteiras e processos", "href": "/docs/backend/esteiras-e-processos", "description": "Bindings, agregações, recálculo assíncrono e invariantes." }, { "title": "Modal de detalhe e grids", "href": "/docs/frontend/modal-de-detalhe", "description": "Abas, resumo e grids relacionados no card do pedido." }, { "title": "Rotas privadas", "href": "/docs/backend/rotas-privadas", "description": "Ações próprias com permissão dedicada." } ] ``` Fonte: https://sdk.oonapps.com/docs/tutoriais/central-de-compras