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