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>
This commit is contained in:
2026-07-25 22:29:07 +00:00
parent 9b855f63b2
commit 9107b0a825
2 changed files with 112 additions and 0 deletions

View File

@@ -230,10 +230,22 @@ export class CatalogService {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa')); 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 { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit; 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 grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
const searchFilter = q const searchFilter = q
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')` ? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
@@ -310,6 +322,33 @@ export class CatalogService {
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit }; 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 { private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return { return {
idErp: Number(p.id_erp), idErp: Number(p.id_erp),

View File

@@ -315,6 +315,69 @@ export class OrdersService {
return this.mapDetail(o); return this.mapDetail(o);
} }
// PGD-AUTHZ: o rep só vende produtos/preços das pautas vinculadas ao cadastro
// dele (cod_pauta1..6). Valida que (a) a pauta do pedido é do rep e (b) todo
// item existe naquela pauta com preço > 0 — fecha preço forjado (ex.: 0,01) e
// produto fantasma/fora da pauta. O TETO de desconto (alçada + promoção/regra
// ativa da política comercial) continua em assertAlcadaItens; aqui só ancora o
// preço de tabela na pauta do rep para que aquele cálculo nunca caia no
// fallback de "sem preço de referência".
private async assertPautaDoRep(
codVendedor: number,
idPauta: number | null | undefined,
itens: { ordem: number; idProduto: number; codProduto?: string | null }[],
): Promise<void> {
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
if (idPauta == null) {
throw new BadRequestException('Selecione a pauta de preços para lançar o pedido.');
}
// codVendedor (JWT) e idPauta/idProduto (payload validado como Int) — seguros.
const pautaRows = 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 (!pautaRows[0]) {
throw new BadRequestException('Pauta de preços não vinculada ao seu cadastro.');
}
const prodIds = [...new Set(itens.map((i) => i.idProduto))];
if (prodIds.length === 0) return;
const okRows = await prisma.$queryRawUnsafe<{ id_produto: number }[]>(`
SELECT pp.id_produto
FROM vw_pauta_produtos pp
JOIN vw_produtos p ON p.id_erp = pp.id_produto
AND p.id_empresa = ${idMatriz}
AND p.ativo = 1
WHERE pp.id_pauta = ${Number(idPauta)}
AND pp.preco1 > 0
AND pp.id_produto IN (${prodIds.join(',')})
`);
const validos = new Set(okRows.map((r) => Number(r.id_produto)));
const foraDaPauta = itens.filter((it) => !validos.has(it.idProduto));
if (foraDaPauta.length > 0) {
const lista = foraDaPauta
.map((it) => `item ${it.ordem} (${it.codProduto ?? it.idProduto})`)
.join(', ');
throw new BadRequestException(
`Produto fora da pauta selecionada: ${lista}. Só é possível vender itens da sua pauta de preços.`,
);
}
}
// Valida a alçada de um payload de criação/edição (mesma regra do transmit): // Valida a alçada de um payload de criação/edição (mesma regra do transmit):
// desconto global e por item contra alcada + promoção/regra do dono do pedido. // desconto global e por item contra alcada + promoção/regra do dono do pedido.
private async assertAlcadaDto( private async assertAlcadaDto(
@@ -398,6 +461,11 @@ export class OrdersService {
if (existing) return this.mapDetail(existing); if (existing) return this.mapDetail(existing);
} }
// Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
if (role === 'rep') {
await this.assertPautaDoRep(codVendedor, dto.idPauta, dto.itens);
}
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto // Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
await this.assertAlcadaDto(codVendedor, dto); await this.assertAlcadaDto(codVendedor, dto);
@@ -709,6 +777,11 @@ export class OrdersService {
} }
} }
// Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
if (role === 'rep') {
await this.assertPautaDoRep(pedido.codVendedor, dto.idPauta, dto.itens);
}
// Alçada do DONO do pedido (supervisor editando usa o limite do rep) // Alçada do DONO do pedido (supervisor editando usa o limite do rep)
await this.assertAlcadaDto(pedido.codVendedor, dto); await this.assertAlcadaDto(pedido.codVendedor, dto);