refactor(erp): integração direta com banco ERP — schema sar

Revoga ADR 0006 (BD-por-workspace separado). O SAR agora conecta ao
banco PostgreSQL do ERP (módulo SIG) e usa o schema `sar` para tudo.

PRISMA
- Remove: Client, Product, Order, OrderItem, OrderStatusHistory,
  RepTarget, RepDiscountLimit, PushSubscription (modelos isolados)
- Adiciona: Pedido, PedidoItem, HistoricoPedido, AlcadaDesconto,
  MetaRepresentante, PushSubscription (mapeados para sar.*)
- IDs: id_cliente/cod_vendedor/id_empresa são INTEGER (ERP)
- situa: Int (1=Pendente 2=Aprovado 3=Cancelado 4=Faturado)
- JWT: workspace_id:string → id_empresa:number
- URL: inclui ?schema=sar para Prisma rotear ao schema ERP

SERVICES
- ClientsService: $queryRawUnsafe contra sar.vw_clientes + sar.pedidos
- CatalogService: $queryRawUnsafe contra sar.vw_produtos + sar.vw_estoque
- OrdersService: Prisma models Pedido/PedidoItem/HistoricoPedido/AlcadaDesconto
- DashboardService: MetaRepresentante + queries raw para inativos
- NotificationsService: PushSubscription com codVendedor + idEmpresa

CONTRATOS (api-interface)
- client.contract: campos ERP (idCliente, nome, cgcpf, cod_vendedor…)
- order.contract: PedidoSummary/PedidoDetail/CreatePedido + SITUA_LABEL
- product.contract: ProdutoSummary/ProdutoDetail (vw_produtos)
- auth.contract: workspaceId:string → idEmpresa:number

WEB
- Todos os cockpits e queries atualizados para os novos tipos

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 21:51:16 +00:00
parent 246eb28bb1
commit b0b60d7a14
39 changed files with 1433 additions and 1544 deletions

View File

@@ -1,135 +1,188 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
ProductDetail,
ProductListQuery,
ProductListResponse,
ProductSummary,
ProductSyncRequest,
ProductSyncResponse,
ProdutoDetail,
ProdutoListQuery,
ProdutoListResponse,
ProdutoSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function decimalToString(v: Prisma.Decimal | null | undefined): string | null {
return v ? v.toString() : null;
// ADR 0006 revogado: produtos lidos diretamente de vw_produtos (ERP) + vw_estoque.
// Sem sync — dados sempre frescos da view do ERP.
function escSql(s: string): string {
return s.replace(/'/g, "''");
}
interface ProdutoRow {
id_erp: number;
codigo: string;
descricao: string;
unidade: string | null;
vl_preco1: string;
cod_grupo: number | null;
grupo: string | null;
cod_subgrupo: number | null;
subgrupo: string | null;
marca: string | null;
ativo: number;
qtd_estoque: string | null;
lista_parauta: number | null;
referencia: string | null;
descricao_detalhada: string | null;
vl_preco2: string | null;
vl_preco3: string | null;
aliq_ipi: string | null;
peso_liquido: string | null;
qtd_volume: string | null;
lote_mul_venda: number | null;
preco_com_ipi: string | null;
preco_promocional: string | null;
}
@Injectable()
export class CatalogService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async list(query: ProductListQuery): Promise<ProductListResponse> {
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 { q, category, page, limit } = query;
const skip = (page - 1) * limit;
const { q, codGrupo, page, limit } = query;
const offset = (page - 1) * limit;
const where: Prisma.ProductWhereInput = {
deletedAt: null,
active: true,
...(category ? { category } : {}),
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ code: { contains: q, mode: 'insensitive' } },
],
}
: {}),
};
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
: '';
const [rows, total] = await Promise.all([
prisma.product.findMany({
where,
select: {
id: true,
code: true,
name: true,
category: true,
unitPrice: true,
stock: true,
active: true,
},
skip,
take: limit,
orderBy: [{ category: 'asc' }, { name: 'asc' }],
}),
prisma.product.count({ where }),
]);
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp,
p.codigo,
p.descricao,
p.unidade,
p.vl_preco1::text,
p.cod_grupo,
p.grupo,
p.cod_subgrupo,
p.subgrupo,
p.marca,
p.ativo,
e.qtd_estoque::text,
p.lista_parauta,
p.referencia,
p.descricao_detalhada,
p.vl_preco2::text,
p.vl_preco3::text,
p.aliq_ipi::text,
p.peso_liquido::text,
p.qtd_volume::text,
p.lote_mul_venda,
p.preco_com_ipi::text,
p.preco_promocional::text
FROM vw_produtos p
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.ativo = 1
${grupoFilter}
${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`);
const data: ProductSummary[] = rows.map((p) => ({
id: p.id,
code: p.code,
name: p.name,
category: p.category,
unitPrice: decimalToString(p.unitPrice) ?? '0',
stock: decimalToString(p.stock),
active: p.active,
const totalRows = await prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_produtos p
WHERE p.ativo = 1
${grupoFilter}
${searchFilter}
`);
const total = parseInt(totalRows[0]?.count ?? '0', 10);
const data: ProdutoSummary[] = rows.map((p) => ({
idErp: Number(p.id_erp),
codigo: p.codigo,
descricao: p.descricao,
unidade: p.unidade,
vlPreco1: p.vl_preco1,
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo,
marca: p.marca,
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_parauta !== null ? Number(p.lista_parauta) : null,
}));
return { data, total, page, limit };
}
async findOne(id: string): Promise<ProductDetail | null> {
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 p = await prisma.product.findFirst({ where: { id, deletedAt: null, active: true } });
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
SELECT
p.id_erp,
p.codigo,
p.descricao,
p.unidade,
p.vl_preco1::text,
p.cod_grupo,
p.grupo,
p.cod_subgrupo,
p.subgrupo,
p.marca,
p.ativo,
e.qtd_estoque::text,
p.lista_parauta,
p.referencia,
p.descricao_detalhada,
p.vl_preco2::text,
p.vl_preco3::text,
p.aliq_ipi::text,
p.peso_liquido::text,
p.qtd_volume::text,
p.lote_mul_venda,
p.preco_com_ipi::text,
p.preco_promocional::text
FROM vw_produtos p
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE p.id_erp = ${idErp} AND p.ativo = 1
LIMIT 1
`);
const p = rows[0];
if (!p) return null;
return {
id: p.id,
code: p.code,
name: p.name,
description: p.description,
category: p.category,
unitPrice: decimalToString(p.unitPrice) ?? '0',
stock: decimalToString(p.stock),
active: p.active,
erpCode: p.erpCode,
syncedAt: p.syncedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
idErp: Number(p.id_erp),
codigo: p.codigo,
descricao: p.descricao,
unidade: p.unidade,
vlPreco1: p.vl_preco1,
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo,
marca: p.marca,
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_parauta !== null ? Number(p.lista_parauta) : null,
referencia: p.referencia,
descricaoDetalhada: p.descricao_detalhada,
vlPreco2: p.vl_preco2,
vlPreco3: p.vl_preco3,
aliqIpi: p.aliq_ipi,
pesoLiquido: p.peso_liquido,
qtdVolume: p.qtd_volume,
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
precoComIpi: p.preco_com_ipi,
precoPromocional: p.preco_promocional,
};
}
async sync(req: ProductSyncRequest): Promise<ProductSyncResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const syncedAt = new Date();
let upserted = 0;
for (const item of req.items) {
await prisma.product.upsert({
where: { code: item.code },
create: {
code: item.code,
name: item.name,
description: item.description ?? null,
category: item.category ?? 'geral',
unitPrice: item.unitPrice,
stock: item.stock ?? null,
active: item.active ?? true,
erpCode: item.erpCode ?? null,
syncedAt,
},
update: {
name: item.name,
description: item.description ?? null,
category: item.category ?? 'geral',
unitPrice: item.unitPrice,
stock: item.stock ?? null,
active: item.active ?? true,
erpCode: item.erpCode ?? null,
syncedAt,
},
});
upserted++;
}
return { upserted, syncedAt: syncedAt.toISOString() };
}
}