# 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