feat(mvp-rep): formas de pagamento do ERP + suporte offline completo

Formas de pagamento:
- Endpoint GET /catalog/payment-methods lendo vw_formas_pagamento
  filtrado por ativa=1 e integrar_sar=1
- FormaPagamento schema/type no shared api-interface
- Hook useFormasPagamento (staleTime 1h) substituindo lista hardcoded

Offline (FR-4.2 / NFR-2.1–2.4):
- IndexedDB queue: lib/offline/idb.ts + order-queue.ts sem deps externos
- NewOrderPage detecta !navigator.onLine → enqueueOrder() → toast + reset
- useOfflineSync: auto-sync ao reconectar (POST orders + PATCH transmit)
- usePendingOrders: fila reativa via CustomEvents
- AppShell: banner offline + useOfflineSync() global
- OrdersPage: seção de pedidos pendentes com retry/descartar
- sw.js: network-first para API GETs cacheáveis + stale-while-revalidate
  para assets + app shell navigate fallback

Docs:
- architecture.md: documento de decisões de arquitetura do SAR MVP

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 21:30:23 +00:00
parent 1647871a39
commit a3c68f9f05
33 changed files with 2175 additions and 173 deletions

View File

@@ -52,6 +52,11 @@ export class OrdersController {
return this.orders.create(parsed);
}
@Patch(':id/transmit')
transmit(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.transmit(id);
}
@Patch(':id/approve')
approve(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -13,8 +13,9 @@ import type {
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 1=Ag.Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
const SITUA_ORCAMENTO = 0;
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3;
@@ -78,6 +79,7 @@ export class OrdersService {
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
nome_vendedor: string | null;
situa: number;
status_descr: string;
dt_pedido: Date;
@@ -86,25 +88,94 @@ export class OrdersService {
obs: string | null;
}
const [rows, countRows] = await Promise.all([
prisma.$queryRawUnsafe<ErpRow[]>(`
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
c.nome AS nome_cliente, c.razao AS razao_cliente
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente AND c.id_empresa = e.id_empresa
${filters}
ORDER BY e.dt_pedido DESC
LIMIT ${limit} OFFSET ${offset}
`),
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
const sarWhere: Prisma.PedidoWhereInput = {
idEmpresa,
...(role === 'rep' ? { codVendedor } : {}),
...(idCliente != null ? { idCliente } : {}),
...(situa != null ? { situa } : {}),
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
...(from || to
? {
dtPedido: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
};
const [sarPedidos, countRows] = await Promise.all([
prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
`),
]);
const total = Number(countRows[0]?.count ?? 0);
const sarCount = sarPedidos.length;
const erpTotal = Number(countRows[0]?.count ?? 0);
const total = sarCount + erpTotal;
const data: PedidoSummary[] = rows.map((o) => ({
// Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP.
const sarSlice = sarPedidos.slice(offset, offset + limit);
const erpNeeded = limit - sarSlice.length;
const erpOffset = Math.max(0, offset - sarCount);
const erpRows =
erpNeeded > 0
? await prisma.$queryRawUnsafe<ErpRow[]>(`
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
c.nome AS nome_cliente, c.razao AS razao_cliente,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = e.cod_vendedor
LIMIT 1) AS nome_vendedor
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
${filters}
ORDER BY e.dt_pedido DESC
LIMIT ${erpNeeded} OFFSET ${erpOffset}
`)
: [];
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
const [cliNameRows, repNameRows] = await Promise.all([
cliIds.length
? prisma.$queryRawUnsafe<
{ id_cliente: number; nome: string | null; razao: string | null }[]
>(
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
)
: Promise.resolve([]),
repCods.length
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
)
: Promise.resolve([]),
]);
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const sarData: PedidoSummary[] = sarSlice.map((p) => ({
id: p.id,
numPedSar: p.numPedSar,
idCliente: p.idCliente,
nomeCliente: cliMap.get(p.idCliente)?.nome ?? null,
razaoCliente: cliMap.get(p.idCliente)?.razao ?? null,
codVendedor: p.codVendedor,
nomeVendedor: repMap.get(p.codVendedor) ?? null,
situa: p.situa,
dtPedido: p.dtPedido.toISOString(),
total: decimalToString(p.total),
descontoPerc: decimalToString(p.descontoPerc),
obs: p.obs,
createdAt: p.createdAt.toISOString(),
fonte: 'sar' as const,
}));
const erpData: PedidoSummary[] = erpRows.map((o) => ({
id: `erp-${o.id_pedido}`,
numPedSar: (o.num_ped_sar ?? '').trim(),
numero: Number(o.numero),
@@ -112,6 +183,7 @@ export class OrdersService {
nomeCliente: o.nome_cliente ?? null,
razaoCliente: o.razao_cliente ?? null,
codVendedor: Number(o.cod_vendedor),
nomeVendedor: o.nome_vendedor ?? null,
// Normaliza situa SIG → SAR para consistência com pedidos SAR
situa: sigToSar(Number(o.situa)),
statusDescr: o.status_descr,
@@ -120,10 +192,10 @@ export class OrdersService {
descontoPerc: o.desconto_perc ?? '0',
obs: o.obs ?? null,
createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp',
fonte: 'erp' as const,
}));
return { data, total, page, limit };
return { data: [...sarData, ...erpData], total, page, limit };
}
async findOne(id: string): Promise<PedidoDetail> {
@@ -149,7 +221,8 @@ export class OrdersService {
return this.mapDetail(o);
}
// Cria novo pedido. Valida alçada por codGrupo (codGrupo=0 = default).
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
// notificação ao supervisor acontecem no transmit(), não aqui.
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
async create(dto: CreatePedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
@@ -170,15 +243,6 @@ export class OrdersService {
if (existing) return this.mapDetail(existing);
}
// Resolve alçadas: (codVendedor, idEmpresa, codGrupo=0) = default
const limitRows = await prisma.alcadaDesconto.findMany({
where: { codVendedor, idEmpresa },
});
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5;
const needsApproval = dto.descontoPerc > getLimit(0);
const itemsData = dto.itens.map((it) => {
const descontoValor =
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
@@ -200,12 +264,11 @@ export class OrdersService {
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
const situa = needsApproval ? SITUA_PENDENTE : SITUA_APROVADO;
const situa = SITUA_ORCAMENTO;
// Gera número sequencial: SAR-NNNNN
// Gera número sequencial GLOBAL: SAR-NNNNN (numPedSar é unique entre empresas).
const lastOrder = await prisma.pedido.findFirst({
where: { idEmpresa },
orderBy: { createdAt: 'desc' },
orderBy: { numPedSar: 'desc' },
select: { numPedSar: true },
});
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
@@ -246,15 +309,61 @@ export class OrdersService {
},
});
if (situa === SITUA_PENDENTE) {
void this.notifications.notifySupervisors({
title: 'Pedido aguardando aprovação',
body: `Pedido ${pedido.numPedSar} — R$ ${pedido.total.toFixed(2).replace('.', ',')}`,
url: `/pedidos/${pedido.id}`,
});
return this.mapDetail(pedido);
}
// Transmite um Orçamento (situa 0) → Transmitido (situa 2).
// Alçada de desconto (codGrupo=0 = default) é BLOQUEIO DURO: desconto acima do
// máximo do rep barra a transmissão com mensagem — não há fila de aprovação.
async transmit(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10);
// Rep só transmite o próprio orçamento
const repFilter = role === 'rep' ? { codVendedor } : {};
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException(
`Pedido não é um orçamento (situa: ${pedido.situa}) — só orçamentos podem ser transmitidos`,
);
// Alçada do rep (codGrupo=0 = default; fallback 5%) — bloqueia se desconto acima.
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
const limiteMax = limitMap.get(0) ?? 5;
const desconto = Number(pedido.descontoPerc);
if (desconto > limiteMax) {
throw new BadRequestException(
`Desconto de ${desconto}% acima do máximo permitido para você (${limiteMax}%). Reduza o desconto para transmitir o pedido.`,
);
}
return this.mapDetail(pedido);
const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_ORCAMENTO,
situaNova: SITUA_APROVADO,
changedBy: codVendedor,
changedAt: now,
nota: 'Transmitido',
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(final);
}
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
@@ -364,7 +473,37 @@ export class OrdersService {
return this.mapDetail(final);
}
private mapDetail(o: {
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
private async lookupNames(
idCliente: number,
codVendedor: number,
): Promise<{
nomeCliente: string | null;
razaoCliente: string | null;
nomeVendedor: string | null;
}> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
// Cliente e representante são cadastros globais (sem id_empresa).
const [cliRows, repRows] = await Promise.all([
prisma.$queryRawUnsafe<{ nome: string | null; razao: string | null }[]>(
`SELECT nome, razao FROM vw_clientes WHERE id_cliente = ${idCliente} LIMIT 1`,
),
prisma.$queryRawUnsafe<{ nome: string | null }[]>(
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
),
]);
return {
nomeCliente: cliRows[0]?.nome ?? null,
razaoCliente: cliRows[0]?.razao ?? null,
nomeVendedor: repRows[0]?.nome ?? null,
};
}
private async mapDetail(o: {
id: string;
numPedSar: string;
idCliente: number;
@@ -406,12 +545,16 @@ export class OrdersService {
nota: string | null;
changedAt: Date;
}[];
}): PedidoDetail {
}): Promise<PedidoDetail> {
const names = await this.lookupNames(o.idCliente, o.codVendedor);
return {
id: o.id,
numPedSar: o.numPedSar,
idCliente: o.idCliente,
nomeCliente: names.nomeCliente,
razaoCliente: names.razaoCliente,
codVendedor: o.codVendedor,
nomeVendedor: names.nomeVendedor,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: decimalToString(o.total),