Files
sar/apps/api/src/app/catalog/catalog.service.ts
julian 9107b0a825 fix(api): restringe produtos e precos a pauta vinculada ao representante
Rep so enxerga e vende itens das pautas do cadastro dele (cod_pauta1..6):

- catalog: lista de produtos exige pauta e recusa pauta de terceiro
- orders: create/update validam pauta do pedido e todo item na pauta
  com preco > 0, fechando preco forjado e produto fora da pauta

Gestor/admin seguem navegando livremente. O teto de desconto continua
em assertAlcadaItens; aqui so ancora o preco de tabela.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:29:07 +00:00

489 lines
17 KiB
TypeScript

import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
UpdateLogoBody,
FormaPagamento,
Municipio,
Pauta,
ProdutoDetail,
ProdutoListQuery,
ProdutoListResponse,
ProdutoSummary,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
// 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, "''");
}
// 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;
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_pauta: number | null;
referencia: string | null;
descr_det: 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_promocional: string | null;
}
@Injectable()
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,
e.num_endereco::text AS numero,
e.complemento,
TRIM(e.bairro) AS bairro,
TRIM(m.nome) AS cidade,
TRIM(e.uf) AS uf,
e.cep,
e.telefone,
TRIM(e.email) AS email
FROM sar.vw_empresas 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 config = await prisma.configEmpresa.findUnique({ where: { idEmpresa } });
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),
logoBase64: config?.logoBase64 ?? null,
};
}
// Logo do cabeçalho da impressão — só gerente/admin altera; vale para todos
// os representantes da empresa (gravado na matriz, mesma fonte do company()).
async updateLogo(body: UpdateLogoBody): Promise<void> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem alterar o logo da empresa');
}
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'));
await prisma.configEmpresa.upsert({
where: { idEmpresa },
create: { idEmpresa, logoBase64: body.logoBase64 },
update: { logoBase64: body.logoBase64 },
});
}
async municipios(q?: string): Promise<Municipio[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
interface Row {
id_municipio: number;
nome: string;
uf: string;
codigo_ibge: number | null;
}
const searchFilter = q ? `WHERE UPPER(nome) LIKE UPPER('%${escSql(q)}%')` : '';
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id_municipio, TRIM(nome) AS nome, uf::text AS uf, codigo_ibge
FROM sar.vw_municipios
${searchFilter}
ORDER BY nome
LIMIT 100
`);
return rows.map((r) => ({
idMunicipio: Number(r.id_municipio),
nome: r.nome,
uf: r.uf,
codigoIbge: r.codigo_ibge !== null ? Number(r.codigo_ibge) : null,
}));
}
async pautas(): Promise<Pauta[]> {
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'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const role = this.cls.get('role') ?? 'rep';
interface PautaRow {
id_pauta: number;
codigo: number;
descricao: string;
qtd_reps?: string;
}
// Rep vê só as pautas atribuídas a ele (cod_pauta1..6); gestor vê TODAS as
// pautas ativas, com a contagem de reps que usam cada uma.
const rows =
role === 'rep'
? 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 pa.codigo IN (
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
)
WHERE pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
ORDER BY pa.codigo
`)
: await prisma.$queryRawUnsafe<PautaRow[]>(`
SELECT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao,
COUNT(DISTINCT r.codigo)::text AS qtd_reps
FROM vw_pautas pa
LEFT 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
)
WHERE pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
GROUP BY pa.id_pauta, pa.codigo, pa.descricao
ORDER BY pa.codigo
`);
return rows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
...(r.qtd_reps != null && { qtdReps: Number(r.qtd_reps) }),
}));
}
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 = matrizEmpresa(this.cls.get('idEmpresa'));
const role = this.cls.get('role') ?? 'rep';
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit;
// PGD-AUTHZ: o rep só enxerga produtos/preços das pautas vinculadas ao
// cadastro dele (cod_pauta1..6). Sem pauta selecionada → lista vazia (precisa
// escolher a pauta primeiro); pauta de terceiro → recusa. Gestor/admin
// navegam livremente (preço base ou pauta que escolherem).
if (role === 'rep') {
if (idPauta == null) return { data: [], total: 0, page, limit };
await this.assertPautaDoRep(idPauta, codVendedor, idEmpresa);
}
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
: '';
// Com pauta: usa preço específico da pauta. Sem pauta: filtra vl_preco1 > 0.
if (idPauta != null) {
interface PautaItemRow extends ProdutoRow {
preco_pauta: string;
}
const [rows, countRows] = await Promise.all([
prisma.$queryRawUnsafe<PautaItemRow[]>(`
SELECT
p.id_erp, p.codigo, p.descricao, p.unidade,
pp.preco1::text AS preco_pauta,
p.cod_grupo, p.grupo, p.cod_subgrupo, p.subgrupo, p.marca,
p.ativo, e.qtd_estoque::text, p.lista_pauta,
p.referencia, p.descr_det, 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_promocional::text
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
LEFT JOIN vw_estoque e ON e.id_erp = p.id_erp AND e.id_empresa = ${idEmpresa}
WHERE pp.id_pauta = ${idPauta}
AND p.ativo = 1
AND pp.preco1 > 0
${grupoFilter}
${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto AND p.id_empresa = ${idEmpresa}
WHERE pp.id_pauta = ${idPauta} AND p.ativo = 1 AND pp.preco1 > 0
${grupoFilter} ${searchFilter}
`),
]);
const total = parseInt(countRows[0]?.count ?? '0', 10);
return {
data: rows.map((p) => this.mapRow(p, p.preco_pauta)),
total,
page,
limit,
};
}
// Sem pauta: produtos com preço base preenchido
const [rows, countRows] = await Promise.all([
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_pauta,
p.referencia, p.descr_det, 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_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_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
${grupoFilter} ${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count FROM vw_produtos p
WHERE p.id_empresa = ${idEmpresa} AND p.ativo = 1 AND p.vl_preco1 > 0
${grupoFilter} ${searchFilter}
`),
]);
const total = parseInt(countRows[0]?.count ?? '0', 10);
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit };
}
// Confere que a pauta pertence às pautas do rep (cod_pauta1..6 do cadastro).
// codVendedor/idPauta são Int (JWT/query coercida) — interpolação segura.
private async assertPautaDoRep(
idPauta: number,
codVendedor: number,
idMatriz: number,
): Promise<void> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number }[]>(`
SELECT DISTINCT pa.id_pauta
FROM vw_pautas pa
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
)
WHERE pa.id_empresa = ${idMatriz}
AND pa.ativo = 1
AND r.codigo = ${codVendedor}
AND pa.id_pauta = ${Number(idPauta)}
LIMIT 1
`);
if (!rows[0]) {
throw new ForbiddenException('Pauta de preços não vinculada ao seu cadastro');
}
}
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return {
idErp: Number(p.id_erp),
codigo: (p.codigo ?? '').trim(),
descricao: (p.descricao ?? '').trim(),
unidade: p.unidade,
vlPreco1: preco ?? '0',
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo ? p.grupo.trim() : null,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo ? p.subgrupo.trim() : null,
marca: p.marca ? p.marca.trim() : null,
ativo: Number(p.ativo),
qtdEstoque: p.qtd_estoque,
listaParauta: p.lista_pauta !== null ? Number(p.lista_pauta) : null,
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : 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 = matrizEmpresa(this.cls.get('idEmpresa'));
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface PautaPrecoRow {
id_pauta: number;
codigo: number;
descricao: string;
preco: string;
}
const [rows, pautaRows] = await Promise.all([
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_pauta,
p.referencia,
p.descr_det,
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_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
`),
prisma.$queryRawUnsafe<PautaPrecoRow[]>(`
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao, pp.preco1::text AS preco
FROM vw_pauta_produtos pp
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
AND pa.id_empresa = ${idEmpresa}
AND pa.ativo = 1
${
// Rep vê só as pautas dele; gestor vê os preços de TODAS as pautas
(this.cls.get('role') ?? 'rep') === 'rep'
? `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
) AND r.codigo = ${codVendedor}`
: ''
}
WHERE pp.id_produto = ${idErp}
AND pp.preco1 > 0
ORDER BY pa.codigo
`),
]);
const p = rows[0];
if (!p) return null;
return {
...this.mapRow(p, p.vl_preco1),
referencia: p.referencia,
descricaoDetalhada: p.descr_det,
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: null,
precoPromocional: p.preco_promocional,
pautaPrecos: pautaRows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
preco: r.preco,
})),
};
}
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',
}));
}
}