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 × valorUnitariocalculado e validado pelo backend, com prévia no formulário;- transições de etapa validadas no servidor, inclusive contra
PATCHdireto; - 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.permissionsprotege as rotas geradas (GET/POST/PUT/PATCH/DELETE /pedidos-comprae as demais de Models e CRUD).totalécomputed: ficareadonly, é 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-positivoroda depois do cálculo; um pedido com valor zero recebe422com o códigoPEDIDO_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
PATCHdireto na API. lockedFieldsByStagevale 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.deleteProtectionimpede 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
- Publicação e ambientes — Do commit ao ambiente de Dev, HML e Produção.
- Esteiras e processos — Bindings, agregações, recálculo assíncrono e invariantes.
- Modal de detalhe e grids — Abas, resumo e grids relacionados no card do pedido.
- Rotas privadas — Ações próprias com permissão dedicada.