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:
@@ -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() };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user