feat(catalog): filtro de preço e seletor de pauta

- Catálogo só mostra produtos com preço preenchido (vl_preco1 > 0) por default
- Novo endpoint GET /catalog/pautas — retorna as 6 pautas do representante logado
- GET /catalog?idPauta=N — usa preço da pauta selecionada (vw_pauta_produtos)
- CatalogPage: dropdown "Selecionar pauta de preços" com as pautas do rep
- product.contract: adiciona PautaSchema e idPauta no ProdutoListQuerySchema

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 14:55:18 +00:00
parent 1f8a9d872a
commit e7cbadcf7e
5 changed files with 185 additions and 84 deletions

View File

@@ -2,6 +2,7 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '
import { createZodDto } from 'nestjs-zod';
import {
ProdutoListQuerySchema,
type Pauta,
type ProdutoDetail,
type ProdutoListQuery,
type ProdutoListResponse,
@@ -16,6 +17,11 @@ class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@Get('pautas')
pautas(): Promise<Pauta[]> {
return this.catalog.pautas();
}
@Get()
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
Pauta,
ProdutoDetail,
ProdutoListQuery,
ProdutoListResponse,
@@ -44,12 +45,45 @@ interface ProdutoRow {
export class CatalogService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async pautas(): Promise<Pauta[]> {
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 userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface PautaRow {
id_pauta: number;
codigo: number;
descricao: string;
}
const rows = 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 r.id_empresa = pa.id_empresa
AND 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
`);
return rows.map((r) => ({
idPauta: Number(r.id_pauta),
codigo: Number(r.codigo),
descricao: r.descricao,
}));
}
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, codGrupo, page, limit } = query;
const { q, codGrupo, idPauta, page, limit } = query;
const offset = (page - 1) * limit;
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
@@ -57,65 +91,93 @@ export class CatalogService {
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
: '';
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_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.ativo = 1
${grupoFilter}
${searchFilter}
ORDER BY p.descricao
LIMIT ${limit} OFFSET ${offset}
`);
// 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,
};
}
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);
// 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 };
}
const data: ProdutoSummary[] = rows.map((p) => ({
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
return {
idErp: Number(p.id_erp),
codigo: p.codigo,
descricao: p.descricao,
codigo: (p.codigo ?? '').trim(),
descricao: (p.descricao ?? '').trim(),
unidade: p.unidade,
vlPreco1: p.vl_preco1,
vlPreco1: preco ?? '0',
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
grupo: p.grupo,
grupo: p.grupo ? p.grupo.trim() : null,
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
subgrupo: p.subgrupo,
marca: p.marca,
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,
}));
return { data, total, page, limit };
};
}
async findOne(idErp: number): Promise<ProdutoDetail | null> {
@@ -157,19 +219,7 @@ export class CatalogService {
if (!p) return null;
return {
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_pauta !== null ? Number(p.lista_pauta) : null,
...this.mapRow(p, p.vl_preco1),
referencia: p.referencia,
descricaoDetalhada: p.descr_det,
vlPreco2: p.vl_preco2,