Testado de verdade. Todo o SQL e todo arquivo src/lib desta página rodaram contra o SuperDB em produção em 21/10/2026, num projeto de teste que foi apagado depois: 24 verificações, 24 passaram. A nota fiscal foi até a prévia em homologação: sem certificado A1 real, nenhuma nota foi emitida. As saídas estão no repositório do SuperDB, em docs/marketing/aprenda/provas/erp.txt.
O problema do negócio
A assistência técnica controla peça em planilha e ordem de serviço em papel. A planilha diz que tem tela, o técnico abre a gaveta e não tem; o atendente dá desconto mudando o preço; e a nota de serviço é feita à mão no fim do mês. Um mini ERP resolve isso se três coisas forem verdade sempre, não só quando o app se comporta:
- o preço do item vem da tabela, não do que o app mandou;
- a entrega baixa as peças e fecha a ordem numa transação só, e estoque nunca fica negativo;
- só o dono muda preço e lança compra, e quem não é da equipe não vê nada.
O modelo de dados
| Tabela | O que guarda | Quem lê | Quem altera |
|---|---|---|---|
equipe | logins e papel (dono, atendente, tecnico) | cada um a própria linha; o dono todas | você, pelo SQL Editor |
clientes | nome, CPF ou CNPJ, telefone, e-mail e endereço (a nota fiscal exige) | a equipe | a equipe |
produtos | peças (com estoque) e serviços (sem), com preço de tabela | a equipe | só o dono; o estoque, só pelas funções |
ordens e itens_ordem | o equipamento, o defeito, os itens e os totais | a equipe | a equipe (os totais, só a entrega) |
movimentos_estoque | cada entrada e saída, com quem e quando | a equipe | ninguém direto: só as funções |
Passo 1: crie a conta e o projeto
Crie a conta e um projeto para o ERP. Um projeto por cliente da agência deixa os dados de cada empresa num banco próprio.
Passo 2: rode o SQL
No SQL Editor do projeto, cole e rode. Pode rodar de novo sem medo.
-- Mini ERP de assistência técnica: clientes, peças e serviços, estoque e
-- ordens de serviço. Só a equipe entra; o cliente da loja não tem login.
-- Pode rodar de novo sem medo.
create table if not exists equipe (
user_id uuid primary key,
nome text not null,
papel text not null default 'atendente'
check (papel in ('dono', 'atendente', 'tecnico'))
);
create table if not exists clientes (
id bigint generated always as identity primary key,
nome text not null check (length(nome) between 1 and 200),
documento text check (documento ~ '^([0-9]{11}|[0-9]{14})$'), -- CPF ou CNPJ, só dígitos
telefone text,
email text,
-- Endereço para a NFS-e: { codigo_municipio (IBGE, 7 dígitos), cep, logradouro, numero, bairro }
endereco jsonb,
criado_em timestamptz not null default now()
);
-- Peça tem estoque; serviço não. O preço é o de tabela.
create table if not exists produtos (
id bigint generated always as identity primary key,
sku text not null unique,
nome text not null,
tipo text not null check (tipo in ('peca', 'servico')),
preco numeric(10,2) not null check (preco >= 0),
estoque int not null default 0 check (estoque >= 0),
estoque_minimo int not null default 0 check (estoque_minimo >= 0)
);
create table if not exists ordens (
id bigint generated always as identity primary key,
cliente_id bigint not null references clientes(id),
equipamento text not null,
defeito text not null,
status text not null default 'aberta'
check (status in ('aberta', 'em_andamento', 'pronta', 'entregue', 'cancelada')),
total_pecas numeric(10,2) not null default 0,
total_servicos numeric(10,2) not null default 0,
nfse_id uuid,
aberta_por uuid not null default auth.uid(),
aberta_em timestamptz not null default now(),
entregue_em timestamptz
);
create table if not exists itens_ordem (
id bigint generated always as identity primary key,
ordem_id bigint not null references ordens(id) on delete cascade,
produto_id bigint not null references produtos(id),
quantidade int not null check (quantidade between 1 and 999),
preco_unitario numeric(10,2) not null check (preco_unitario >= 0)
);
-- Todo movimento de estoque fica registrado. Ninguém escreve direto aqui:
-- só as funções abaixo.
create table if not exists movimentos_estoque (
id bigint generated always as identity primary key,
produto_id bigint not null references produtos(id),
quantidade int not null, -- positivo entra, negativo sai
motivo text not null check (motivo in ('compra', 'ordem', 'ajuste')),
ordem_id bigint references ordens(id),
feito_por uuid not null default auth.uid(),
feito_em timestamptz not null default now()
);
alter table equipe enable row level security;
alter table clientes enable row level security;
alter table produtos enable row level security;
alter table ordens enable row level security;
alter table itens_ordem enable row level security;
alter table movimentos_estoque enable row level security;
create or replace function e_da_equipe(p_papel text default null) returns boolean
language sql stable security definer set search_path from current as $$
select exists (
select 1 from equipe
where user_id = auth.uid() and (p_papel is null or papel = p_papel)
)
$$;
-- O preço do item vem da tabela de produtos, não do app.
create or replace function item_preco_de_tabela() returns trigger
language plpgsql security definer set search_path from current as $$
begin
select p.preco into new.preco_unitario from produtos p where p.id = new.produto_id;
if exists (select 1 from ordens o where o.id = new.ordem_id and o.status in ('entregue', 'cancelada')) then
raise exception 'ordem_fechada';
end if;
return new;
end $$;
drop trigger if exists item_preco_de_tabela on itens_ordem;
create trigger item_preco_de_tabela before insert or update on itens_ordem
for each row execute function item_preco_de_tabela();
-- Equipe: cada um vê a própria linha; o dono vê todas.
drop policy if exists equipe_ver on equipe;
create policy equipe_ver on equipe for select to authenticated
using (user_id = auth.uid() or e_da_equipe('dono'));
-- Clientes, ordens e itens: toda a equipe lê e escreve.
drop policy if exists clientes_equipe on clientes;
create policy clientes_equipe on clientes for all to authenticated
using (e_da_equipe()) with check (e_da_equipe());
drop policy if exists ordens_equipe on ordens;
create policy ordens_equipe on ordens for all to authenticated
using (e_da_equipe()) with check (e_da_equipe());
drop policy if exists itens_equipe on itens_ordem;
create policy itens_equipe on itens_ordem for all to authenticated
using (e_da_equipe()) with check (e_da_equipe());
-- Produtos: a equipe lê; só o dono cadastra e muda preço.
drop policy if exists produtos_ler on produtos;
create policy produtos_ler on produtos for select to authenticated
using (e_da_equipe());
drop policy if exists produtos_dono on produtos;
create policy produtos_dono on produtos for all to authenticated
using (e_da_equipe('dono')) with check (e_da_equipe('dono'));
-- Movimentos: a equipe lê; ninguém escreve direto.
drop policy if exists movimentos_ler on movimentos_estoque;
create policy movimentos_ler on movimentos_estoque for select to authenticated
using (e_da_equipe());
revoke all on equipe, clientes, produtos, ordens, itens_ordem, movimentos_estoque from anon, authenticated;
grant select on equipe to authenticated;
grant select, insert, update on clientes to authenticated;
grant select, insert on ordens to authenticated;
grant update (status, equipamento, defeito) on ordens to authenticated;
grant select, insert, delete on itens_ordem to authenticated;
grant update (quantidade) on itens_ordem to authenticated;
grant select on produtos to authenticated;
grant insert, update (sku, nome, tipo, preco, estoque_minimo) on produtos to authenticated;
grant select on movimentos_estoque to authenticated;
-- Entrada de mercadoria (compra) ou ajuste de inventário: só o dono.
create or replace function movimentar_estoque(p_produto bigint, p_quantidade int, p_motivo text)
returns int
language plpgsql security definer set search_path from current as $$
declare
novo int;
begin
if not e_da_equipe('dono') then
raise exception 'so_o_dono_movimenta_estoque';
end if;
if p_motivo not in ('compra', 'ajuste') then
raise exception 'motivo_invalido';
end if;
update produtos set estoque = estoque + p_quantidade
where id = p_produto and tipo = 'peca'
returning estoque into novo;
if novo is null then
raise exception 'peca_nao_encontrada';
end if;
insert into movimentos_estoque (produto_id, quantidade, motivo)
values (p_produto, p_quantidade, p_motivo);
return novo;
end $$;
-- Entregar a ordem: baixa as peças do estoque, soma os totais e fecha, tudo
-- numa transação. Falta de peça desfaz tudo.
create or replace function entregar_ordem(p_ordem bigint)
returns ordens
language plpgsql security definer set search_path from current as $$
declare
item record;
resultado ordens;
begin
if not e_da_equipe() then
raise exception 'so_a_equipe_entrega';
end if;
perform 1 from ordens where id = p_ordem and status not in ('entregue', 'cancelada') for update;
if not found then
raise exception 'ordem_nao_encontrada_ou_fechada';
end if;
for item in
select i.produto_id, i.quantidade, p.sku
from itens_ordem i join produtos p on p.id = i.produto_id
where i.ordem_id = p_ordem and p.tipo = 'peca'
loop
update produtos set estoque = estoque - item.quantidade
where id = item.produto_id and estoque >= item.quantidade;
if not found then
raise exception 'estoque_insuficiente: %', item.sku;
end if;
insert into movimentos_estoque (produto_id, quantidade, motivo, ordem_id)
values (item.produto_id, -item.quantidade, 'ordem', p_ordem);
end loop;
update ordens o set
status = 'entregue',
entregue_em = now(),
total_pecas = coalesce((select sum(i.quantidade * i.preco_unitario) from itens_ordem i
join produtos p on p.id = i.produto_id
where i.ordem_id = p_ordem and p.tipo = 'peca'), 0),
total_servicos = coalesce((select sum(i.quantidade * i.preco_unitario) from itens_ordem i
join produtos p on p.id = i.produto_id
where i.ordem_id = p_ordem and p.tipo = 'servico'), 0)
where o.id = p_ordem
returning o.* into resultado;
return resultado;
end $$;
revoke all on function movimentar_estoque(bigint, int, text) from public, anon;
revoke all on function entregar_ordem(bigint) from public, anon;
grant execute on function movimentar_estoque(bigint, int, text) to authenticated;
grant execute on function entregar_ordem(bigint) to authenticated;
-- O que precisa comprar. security_invoker: a view respeita a RLS de quem lê.
create or replace view estoque_baixo with (security_invoker = true) as
select id, sku, nome, estoque, estoque_minimo
from produtos
where tipo = 'peca' and estoque <= estoque_minimo;
revoke all on estoque_baixo from anon;
grant select on estoque_baixo to authenticated;
Cadastre o estoque por compra. Crie a peça com estoque zero e lance a entrada com movimentar_estoque: assim cada unidade tem um movimento registrado. Num insert de várias linhas, mande todas as colunas em todas as linhas (o teste mostrou que uma coluna ausente numa linha vira nulo, não o padrão).
Passo 3: ponha a equipe
-- troque pelos ids dos logins (painel: Autenticação → Usuários)
insert into equipe (user_id, nome, papel) values
('00000000-0000-0000-0000-000000000001', 'Dono', 'dono'),
('00000000-0000-0000-0000-000000000002', 'Atendente', 'atendente')
on conflict (user_id) do nothing;
Passo 4: conecte o app
Instale com npm install @superdb/client e crie o cliente:
// src/lib/superdb.ts
import { createClient } from '@superdb/client'
// A anon key é pública e pode ficar no front. A service_role, nunca.
export const db = createClient('https://auth.superdb.com.br', 'SUA_ANON_KEY', {
project: 'SEU_SLUG',
})
// src/lib/erp.ts: as chamadas das telas do balcão (equipe logada).
// db = createClient('https://auth.superdb.com.br', ANON_KEY, { project: 'SEU_SLUG' })
import type { SupabaseClient } from '@superdb/client'
export async function novoCliente(db: SupabaseClient, c: {
nome: string
documento?: string
telefone?: string
// a NFS-e exige: { codigo_municipio, cep, logradouro, numero, bairro }
endereco?: { codigo_municipio: string; cep: string; logradouro: string; numero: string; bairro: string }
}) {
const { data, error } = await db.from('clientes').insert(c).select('id,nome').single()
if (error) throw error
return data as { id: number; nome: string }
}
// Abre a ordem e lança os itens. O preço vem da tabela de produtos.
export async function abrirOrdem(db: SupabaseClient, o: {
cliente_id: number; equipamento: string; defeito: string
itens: { produto_id: number; quantidade: number }[]
}) {
const { itens, ...cabecalho } = o
const { data: ordem, error } = await db.from('ordens').insert(cabecalho).select('id,status').single()
if (error) throw error
const linhas = itens.map((i) => ({ ...i, ordem_id: (ordem as { id: number }).id, preco_unitario: 0 }))
const { error: e2 } = await db.from('itens_ordem').insert(linhas)
if (e2) throw e2
return ordem as { id: number; status: string }
}
// Entregar: baixa o estoque e fecha a ordem numa transação só.
export async function entregar(db: SupabaseClient, ordemId: number) {
const { data, error } = await db.rpc('entregar_ordem', { p_ordem: ordemId })
if (error) {
const peca = /estoque_insuficiente: (\S+)/.exec(error.message)?.[1]
if (peca) throw new Error(`Falta a peça ${peca} no estoque. A ordem não foi fechada.`)
throw error
}
return data as { id: number; status: string; total_pecas: number; total_servicos: number }
}
// Só o dono: entrada de compra ou ajuste de inventário.
export async function entradaDeEstoque(db: SupabaseClient, produtoId: number, quantidade: number) {
const { data, error } = await db.rpc('movimentar_estoque', { p_produto: produtoId, p_quantidade: quantidade, p_motivo: 'compra' })
if (error) throw error
return data as number // o estoque novo
}
export async function estoqueBaixo(db: SupabaseClient) {
const { data, error } = await db.from('estoque_baixo').select('sku,nome,estoque,estoque_minimo')
if (error) throw error
return data
}
As telas mínimas: Clientes, Nova ordem (cliente, equipamento, defeito e itens do catálogo), Ordens abertas (com entregar), Estoque (com a lista de estoqueBaixo e, para o dono, a entrada de compra). O login é por e-mail e senha ou código: só a equipe usa o app.
Passo 5: ative a nota fiscal em homologação
O SuperDB Fiscal emite a NFS-e do padrão nacional no CNPJ do seu cliente, e a nota vira uma linha na tabela sdb_notas_fiscais do projeto. Ative no painel, em Notas fiscais do projeto, que tem um guia de instalação, ou pela API de gestão com a chave sdb_pmk_:
# 1. ativar (cria o bucket privado e a tabela sdb_notas_fiscais)
curl -X POST https://auth.superdb.com.br/platform/v1/projects/$PROJECT_ID/fiscal/ativacao \
-H "Authorization: Bearer $SUPERDB_MANAGEMENT_KEY"
# 2. o emitente (o CNPJ do seu cliente). somente_homologacao: true para só testar
curl -X POST https://auth.superdb.com.br/platform/v1/projects/$PROJECT_ID/fiscal/emitentes \
-H "Authorization: Bearer $SUPERDB_MANAGEMENT_KEY" -H "Content-Type: application/json" \
-d '{"cnpj":"11222333000181","razao_social":"Assistência Exemplo LTDA","crt":1,"uf":"MG",
"logradouro":"Rua das Flores","numero":"100","bairro":"Centro","municipio":"Belo Horizonte",
"codigo_municipio":"3106200","cep":"30110000","somente_homologacao":true}'
# 3. o serviço padrão (códigos de exemplo: confirme com o contador do município)
curl -X PUT https://auth.superdb.com.br/platform/v1/projects/$PROJECT_ID/fiscal/emitentes/$EMITENTE_ID/servico-padrao \
-H "Authorization: Bearer $SUPERDB_MANAGEMENT_KEY" -H "Content-Type: application/json" \
-d '{"codigo_tributacao_nacional":"140101","codigo_tributacao_municipal":"001","nbs":"120018900",
"descricao":"Conserto e manutenção de aparelhos eletrônicos",
"iss":{"tributacao":"1","retencao":"1"},"tributos_aproximados":{"simples_nacional_percentual":6}}'
# 4. o que falta para emitir
curl https://auth.superdb.com.br/platform/v1/projects/$PROJECT_ID/fiscal/emitentes/$EMITENTE_ID/prontidao \
-H "Authorization: Bearer $SUPERDB_MANAGEMENT_KEY"
No teste, num projeto do plano Grátis: a ativação respondeu 201, o emitente nasceu em homologacao, o serviço padrão foi salvo com o aviso de que o município seria conferido depois do certificado, e a prontidão listou as duas faltas, certificado e producao. O próximo passo é enviar o certificado A1 do CNPJ, que o SuperDB repassa ao motor fiscal e não guarda. Todos os campos e recusas estão em Notas fiscais.
Os códigos de serviço do exemplo são exemplo. O código de tributação (aqui 14.01.01, conserto e manutenção) e o NBS dependem do serviço e do município. Confirme com o contador antes de pedir a produção.
Passo 6: emita a NFS-e pelo servidor
Depois da entrega, o seu servidor emite a nota do valor de mão de obra. A ref é o número da ordem: repetir o pedido devolve a mesma nota, então é seguro chamar de novo depois de um erro de rede. A emissão só aceita a chave de servidor; com a anon, o SDK recusa antes de pedir.
// nota.ts: no seu servidor, depois que a ordem foi entregue.
// A NFS-e é do SERVIÇO. Peça vendida é mercadoria (NF-e, ICMS): o SuperDB não emite.
import { createClient } from '@superdb/client'
const admin = createClient('https://auth.superdb.com.br', process.env.SUPERDB_SERVICE_ROLE_KEY!, {
project: process.env.SUPERDB_PROJECT!,
})
export async function notaDaOrdem(ordemId: number, apenasPrevia = false) {
const { data: ordem, error } = await admin
.from('ordens')
.select('id,status,total_servicos,nfse_id,clientes(nome,documento,endereco)')
.eq('id', ordemId)
.single()
if (error) throw error
const o = ordem as any
if (o.status !== 'entregue') throw new Error('A nota sai depois da entrega.')
if (o.nfse_id) return { id: o.nfse_id, repetida: true }
if (Number(o.total_servicos) <= 0) return null // só peça: não há serviço para a NFS-e
const doc: string | null = o.clientes?.documento ?? null
const pedido = {
ref: `os-${o.id}`, // a mesma ref devolve a mesma nota: repetir é seguro
tomador: {
...(doc?.length === 14 ? { cnpj: doc } : doc ? { cpf: doc } : {}),
nome: o.clientes?.nome,
// Obrigatório em homologação e, a partir de 01/01/2027, em produção (IBS/CBS)
endereco: o.clientes?.endereco,
},
servico: {
valor: Number(o.total_servicos),
informacoes_complementares: `Ordem de serviço ${o.id}`,
},
}
if (apenasPrevia) return admin.fiscal.nfse.previa(pedido)
const { data: nota, error: e2 } = await admin.fiscal.nfse.emitir(pedido)
if (e2) throw new Error(e2.message)
await admin.from('ordens').update({ nfse_id: nota!.id }).eq('id', o.id)
return nota
}
A nota exige o CPF ou CNPJ e o endereço completo do cliente: sem eles, a prévia e a emissão voltam 422 tomador_sem_endereco (em homologação desde já e em produção a partir de 01/01/2027, por causa do IBS/CBS). Veja a documentação. No teste, notaDaOrdem(1, true) gerou a prévia da ordem entregue (R$ 90 de serviço): o XML da DPS e um PDF de prévia, sem emitir e sem gravar. A emissão de verdade (apenasPrevia = false) precisa do certificado e não foi executada.
Com IA: o prompt pronto
Faça os passos 1 a 3 no painel e cole este prompt com o arquivo src/lib/erp.ts. No Lovable, siga antes o guia SuperDB no Lovable; no Cursor ou no Claude, conecte o MCP do SuperDB.
Crie o sistema de balcão de uma assistência técnica de celular, em português.
IMPORTANTE: NÃO use o Lovable Cloud nem o Supabase. O backend é o SuperDB,
pelo pacote npm @superdb/client. Não use @supabase/supabase-js.
As tabelas já existem no projeto SEU_SLUG (equipe, clientes, produtos,
ordens, itens_ordem, movimentos_estoque) com RLS. NÃO crie nem altere
tabelas e NÃO desligue a RLS. Use SOMENTE as funções de src/lib/erp.ts
(colado abaixo) e o cliente de src/lib/superdb.ts.
Telas, só para usuários logados por e-mail e senha:
1. Clientes: lista e cadastro (nome, CPF ou CNPJ, telefone).
2. Nova ordem: cliente, equipamento, defeito e itens do catálogo, com
abrirOrdem. Mostre o preço de tabela, mas não deixe editar.
3. Ordens abertas: com o botão Entregar (entregar). Se faltar peça,
mostre a mensagem do erro e mantenha a ordem aberta.
4. Estoque: estoqueBaixo e, para o dono, entrada de compra.
Nunca coloque a service_role no front. A nota fiscal sai pelo servidor.
[cole aqui o conteúdo de src/lib/erp.ts]
O que testar antes de entregar
O teste deste roteiro usou o dono, um atendente e um usuário de fora da equipe:
- Quem não é da equipe não vê produtos nem cria cliente; visitante sem login não lê clientes.
- O atendente não muda preço e não lança estoque; ninguém altera o estoque direto na tabela.
- O item da ordem leva o preço de tabela mesmo quando o app manda zero.
- Entregar fecha a ordem com os totais (peças R$ 180, serviço R$ 90), baixa a tela de 3 para 2 e registra o movimento.
- A mesma ordem não é entregue duas vezes e não aceita item depois de entregue.
- Faltou bateria: nenhuma peça baixa e a ordem continua aberta.
- A lista do que comprar respeita a RLS (quem é de fora vê lista vazia).
- Nota: ativar no Grátis, emitente em homologação, prontidão e prévia; a anon não pede nota e a sessão do app não lê a tabela de notas.
Quanto custa
| Plano | Preço | O que este roteiro usa do plano |
|---|---|---|
| Grátis | R$ 0 | O ERP inteiro e a nota fiscal em homologação: 1 CNPJ e 50 notas de teste por mês, sem valor fiscal. Projeto sem uso por 30 dias é pausado e volta em 1 clique. |
| Site | R$ 39/mês | O ERP inteiro e a mesma homologação (1 CNPJ, 50 notas de teste por mês). Sem produção de notas. Não pausa por inatividade. |
| Pro | R$ 119/mês | Nota fiscal de verdade: 100 NFS-e por mês em 1 CNPJ, depois de a equipe do SuperDB conferir o pedido de produção. |
| Escala | R$ 349/mês | Para agência ou contador com vários clientes: 1.000 NFS-e por mês em 5 CNPJs. |
Não há cobrança de excedente de notas: passou da cota, a emissão espera o dia 1º. Veja Preços.
Perguntas
O SuperDB emite nota de produto (NF-e ou NFC-e)?
Não. O SuperDB Fiscal emite só a NFS-e do padrão nacional, a nota de serviço. Neste roteiro a nota sai pelo valor da mão de obra; a peça vendida é mercadoria, com ICMS, e precisa de um emissor de NF-e à parte.
Posso testar a nota fiscal no plano Grátis?
Pode, em homologação: ativar, cadastrar 1 CNPJ e emitir até 50 notas de teste por mês, sem valor fiscal, em qualquer plano. A emissão de verdade é do Pro (100 NFS-e por mês em 1 CNPJ) e do Escala (1.000 por mês em 5 CNPJs), depois de a equipe do SuperDB conferir o pedido de produção.
Precisa de certificado digital?
Para emitir, sim: o certificado A1 do CNPJ que emite. Sem ele dá para ativar, cadastrar o emitente, salvar o serviço padrão e gerar a prévia da nota (o XML e um PDF de prévia), que foi o que este roteiro testou.
O estoque pode ficar negativo?
Não. A coluna tem uma regra no banco, e a entrega da ordem baixa as peças numa transação só: faltou uma, nada é baixado e a ordem continua aberta.
Serve para outro tipo de negócio?
Serve para quem presta serviço e usa peça ou material: oficina, assistência técnica, manutenção, gráfica rápida. Troque os produtos e o código de serviço da nota com o contador do seu cliente.