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