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

@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
FormaPagamento,
Pauta,
ProdutoDetail,
ProdutoListQuery,
@@ -16,6 +18,14 @@ function escSql(s: string): string {
return s.replace(/'/g, "''");
}
// Produtos, pautas e estoque são por-empresa e vivem na MATRIZ. O ERP usa códigos
// de empresa > 9000 para origem de pedido (ex.: 9001), mas o cadastro fica na
// matriz correspondente (9001 → 1), espelhando o CASE de vw_pedidos_erp.
// Pedidos continuam usando idEmpresa cru; só o catálogo normaliza.
function matrizEmpresa(idEmpresa: number): number {
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
}
interface ProdutoRow {
id_erp: number;
codigo: string;
@@ -45,10 +55,77 @@ interface ProdutoRow {
export class CatalogService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
// Dados legais da empresa matriz que fatura o pedido (cabeçalho do PDF).
async company(): Promise<EmpresaInfo> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
interface Row {
id_empresa: number;
razao_social: string | null;
nome_fantasia: string | null;
cnpj: string | null;
inscr_estadual: string | null;
endereco: string | null;
numero: string | null;
complemento: string | null;
bairro: string | null;
cidade: string | null;
uf: string | null;
cep: string | null;
telefone: string | null;
email: string | null;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT e.id_empresa,
TRIM(e.razao_social) AS razao_social,
TRIM(e.nome) AS nome_fantasia,
TRIM(e.cnpj) AS cnpj,
TRIM(e.inscr_estadual) AS inscr_estadual,
TRIM(e.endereco) AS endereco,
NULLIF(e.numero, 0)::text AS numero,
NULLIF(TRIM(e.complemento), '.') AS complemento,
TRIM(e.bairro) AS bairro,
TRIM(m.nome) AS cidade,
TRIM(e.estado::text) AS uf,
TRIM(e.cep::text) AS cep,
TRIM(e.telefone::text) AS telefone,
TRIM(e.email) AS email
FROM gestao.empresa e
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
WHERE e.id_empresa = ${idEmpresa}
LIMIT 1
`);
const r = rows[0];
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
const clean = (v: string | null) => {
const t = (v ?? '').trim();
return t === '' ? null : t;
};
return {
idEmpresa: Number(r.id_empresa),
razaoSocial: clean(r.razao_social) ?? clean(r.nome_fantasia) ?? `Empresa ${r.id_empresa}`,
nomeFantasia: clean(r.nome_fantasia),
cnpj: clean(r.cnpj),
inscricaoEstadual: clean(r.inscr_estadual),
endereco: clean(r.endereco),
numero: clean(r.numero),
complemento: clean(r.complemento),
bairro: clean(r.bairro),
cidade: clean(r.cidade),
uf: clean(r.uf),
cep: clean(r.cep),
telefone: clean(r.telefone),
email: clean(r.email),
};
}
async pautas(): Promise<Pauta[]> {
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 idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
@@ -60,8 +137,7 @@ export class CatalogService {
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
FROM vw_pautas pa
JOIN vw_representantes r ON r.id_empresa = pa.id_empresa
AND pa.codigo IN (
JOIN vw_representantes r ON pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
@@ -81,7 +157,7 @@ export class CatalogService {
async list(query: ProdutoListQuery): Promise<ProdutoListResponse> {
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 idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit;
@@ -183,7 +259,7 @@ export class CatalogService {
async findOne(idErp: number): Promise<ProdutoDetail | null> {
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 idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
@@ -232,4 +308,33 @@ export class CatalogService {
precoPromocional: p.preco_promocional,
};
}
async formasPagamento(): Promise<FormaPagamento[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
interface Row {
codigo: number;
descricao: string;
num_parcelas: number | null;
tx_acrescimo: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT codigo, TRIM(descricao) AS descricao, num_parcelas, tx_acrescimo::text
FROM sar.vw_formas_pagamento
WHERE id_empresa = ${idEmpresa}
AND ativa = 1
AND integrar_sar = 1
ORDER BY codigo
`);
return rows.map((r) => ({
codigo: Number(r.codigo),
descricao: r.descricao,
numParcelas: r.num_parcelas !== null ? Number(r.num_parcelas) : null,
txAcrescimo: r.tx_acrescimo ?? '0',
}));
}
}