feat(web+api): catálogo com detalhe do produto, preços por pauta e consulta de pedidos ERP

- Catálogo: modal de detalhe centralizado (1100px) com identificação, estoque,
  preços base e lista de preços por pauta do representante (DISTINCT para evitar duplicatas)
- Contrato: loteMulVenda promovido para ProdutoSummarySchema; PautaPrecoSchema adicionado
- Pedido novo: valida lote múltiplo de venda — qtd inicial = lote, step = lote,
  InputNumber em erro quando inválido, alerta e bloqueio do submit
- Consulta ERP: tela OrdersErpPage com listagem de pedidos do ERP (P/E), contrato
  PedidoErpConsulta, endpoint no orders.controller, rota e sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:27:19 +00:00
parent 0858e1e941
commit a2bab75bad
15 changed files with 1804 additions and 492 deletions

View File

@@ -283,6 +283,7 @@ export class CatalogService {
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,
};
}
@@ -290,36 +291,62 @@ export class CatalogService {
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 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.id_erp = ${idErp} AND p.ativo = 1
LIMIT 1
`);
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
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 pp.id_produto = ${idErp}
AND r.codigo = ${codVendedor}
AND pp.preco1 > 0
ORDER BY pa.codigo
`),
]);
const p = rows[0];
if (!p) return null;
@@ -336,6 +363,12 @@ export class CatalogService {
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,
})),
};
}

View File

@@ -16,11 +16,14 @@ import { createZodDto } from 'nestjs-zod';
import {
AprovarPedidoSchema,
CreatePedidoSchema,
PedidoErpConsultaQuerySchema,
PedidoListQuerySchema,
RecusarPedidoSchema,
type AprovarPedido,
type CreatePedido,
type PedidoDetail,
type PedidoErpConsultaQuery,
type PedidoErpConsultaResponse,
type PedidoListQuery,
type PedidoListResponse,
type RecusarPedido,
@@ -32,6 +35,7 @@ class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' })
export class OrdersController {
@@ -85,6 +89,12 @@ export class OrdersController {
return this.orders.findOneErp(idPedido);
}
@Get('erp-consulta')
listErpConsulta(@Query() query: PedidoErpConsultaQueryDto): Promise<PedidoErpConsultaResponse> {
const parsed = PedidoErpConsultaQuerySchema.parse(query) as PedidoErpConsultaQuery;
return this.orders.listErpConsulta(parsed);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.findOne(id);

View File

@@ -5,6 +5,9 @@ import type {
AprovarPedido,
CreatePedido,
PedidoDetail,
PedidoErpConsultaItem,
PedidoErpConsultaQuery,
PedidoErpConsultaResponse,
PedidoListQuery,
PedidoListResponse,
PedidoSummary,
@@ -26,11 +29,6 @@ function sigToSar(sigSitua: number): number {
return sigSitua === 5 ? 3 : sigSitua;
}
// Mapeia situa SAR → situa SIG para usar nos filtros SQL contra vw_pedidos_erp.
function sarToSig(sarSitua: number): number {
return sarSitua === 3 ? 5 : sarSitua;
}
function decimalToString(v: Prisma.Decimal | null | undefined): string {
return v ? v.toString() : '0';
}
@@ -53,43 +51,7 @@ export class OrdersService {
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
const offset = (page - 1) * limit;
// Filtro de vendedor: rep vê apenas seus pedidos
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
const clienteFilter = idCliente != null ? `AND e.id_cliente = ${idCliente}` : '';
// Converte situa SAR → SIG para filtrar corretamente contra vw_pedidos_erp
const sigSitua = situa != null ? sarToSig(situa) : null;
const situaFilter = sigSitua != null ? `AND e.situa = ${sigSitua}` : '';
const pedSarFilter = numPedSar ? `AND TRIM(e.num_ped_sar) ILIKE '%${numPedSar}%'` : '';
const fromFilter = from ? `AND e.dt_pedido >= '${from}'` : '';
const toFilter = to ? `AND e.dt_pedido <= '${to}'` : '';
const filters = `
WHERE e.id_empresa = ${idEmpresa}
${vendedorFilter} ${clienteFilter} ${situaFilter}
${pedSarFilter} ${fromFilter} ${toFilter}
`;
interface ErpRow {
id_pedido: number;
num_ped_sar: string;
numero: number;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
nome_vendedor: string | null;
situa: number;
status_descr: string;
dt_pedido: Date;
total: string;
desconto_perc: string;
obs: string | null;
}
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
const sarWhere: Prisma.PedidoWhereInput = {
const where: Prisma.PedidoWhereInput = {
idEmpresa,
...(role === 'rep' ? { codVendedor } : {}),
...(idCliente != null ? { idCliente } : {}),
@@ -105,42 +67,13 @@ export class OrdersService {
: {}),
};
const [sarPedidos, countRows] = await Promise.all([
prisma.pedido.findMany({ where: sarWhere, orderBy: { dtPedido: 'desc' } }),
prisma.$queryRawUnsafe<[{ count: string }]>(`
SELECT COUNT(*)::text AS count FROM vw_pedidos_erp e ${filters}
`),
const [pedidos, total] = await Promise.all([
prisma.pedido.findMany({ where, orderBy: { dtPedido: 'desc' }, skip: offset, take: limit }),
prisma.pedido.count({ where }),
]);
const sarCount = sarPedidos.length;
const erpTotal = Number(countRows[0]?.count ?? 0);
const total = sarCount + erpTotal;
// Paginação combinada: SAR-nativos primeiro (ativos), depois histórico ERP.
const sarSlice = sarPedidos.slice(offset, offset + limit);
const erpNeeded = limit - sarSlice.length;
const erpOffset = Math.max(0, offset - sarCount);
const erpRows =
erpNeeded > 0
? await prisma.$queryRawUnsafe<ErpRow[]>(`
SELECT e.id_pedido, e.num_ped_sar, e.numero, e.id_cliente, e.cod_vendedor,
e.situa, e.status_descr, e.dt_pedido, e.total::text, e.desconto_perc::text, e.obs,
c.nome AS nome_cliente, c.razao AS razao_cliente,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = e.cod_vendedor
LIMIT 1) AS nome_vendedor
FROM vw_pedidos_erp e
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente
${filters}
ORDER BY e.dt_pedido DESC
LIMIT ${erpNeeded} OFFSET ${erpOffset}
`)
: [];
// Resolve nomes (cliente/rep) dos pedidos SAR em lote — views globais.
const cliIds = [...new Set(sarSlice.map((p) => p.idCliente))];
const repCods = [...new Set(sarSlice.map((p) => p.codVendedor))];
const cliIds = [...new Set(pedidos.map((p) => p.idCliente))];
const repCods = [...new Set(pedidos.map((p) => p.codVendedor))];
const [cliNameRows, repNameRows] = await Promise.all([
cliIds.length
? prisma.$queryRawUnsafe<
@@ -158,7 +91,7 @@ export class OrdersService {
const cliMap = new Map(cliNameRows.map((c) => [Number(c.id_cliente), c]));
const repMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const sarData: PedidoSummary[] = sarSlice.map((p) => ({
const data: PedidoSummary[] = pedidos.map((p) => ({
id: p.id,
numPedSar: p.numPedSar,
idCliente: p.idCliente,
@@ -175,27 +108,173 @@ export class OrdersService {
fonte: 'sar' as const,
}));
const erpData: PedidoSummary[] = erpRows.map((o) => ({
id: `erp-${o.id_pedido}`,
numPedSar: (o.num_ped_sar ?? '').trim(),
numero: Number(o.numero),
idCliente: Number(o.id_cliente),
nomeCliente: o.nome_cliente ?? null,
razaoCliente: o.razao_cliente ?? null,
codVendedor: Number(o.cod_vendedor),
nomeVendedor: o.nome_vendedor ?? null,
// Normaliza situa SIG → SAR para consistência com pedidos SAR
situa: sigToSar(Number(o.situa)),
statusDescr: o.status_descr,
dtPedido: new Date(o.dt_pedido).toISOString(),
total: o.total ?? '0',
descontoPerc: o.desconto_perc ?? '0',
obs: o.obs ?? null,
createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp' as const,
return { data, total, page, limit };
}
async listErpConsulta(query: PedidoErpConsultaQuery): Promise<PedidoErpConsultaResponse> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const { search, from, to, page, limit } = query;
const offset = (page - 1) * limit;
const dataHistorico = new Date();
dataHistorico.setDate(dataHistorico.getDate() - 90);
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
const fromFilterE = from ? `AND COALESCE(a.data, b.data) >= '${from}'` : '';
const toFilterE = to ? `AND COALESCE(a.data, b.data) <= '${to}'` : '';
const fromFilterP = from ? `AND a.data >= '${from}'` : '';
const toFilterP = to ? `AND a.data <= '${to}'` : '';
const safe = (s: string) => s.replace(/'/g, "''");
const searchFilter = search
? `AND (r.numero_pedido::text ILIKE '%${safe(search)}%' OR LOWER(cl.nome) LIKE LOWER('%${safe(search)}%') OR LOWER(cl.razao) LIKE LOWER('%${safe(search)}%'))`
: '';
interface ErpConsultaRow {
numero_pedido: number;
data: Date;
dt_prevent: Date | null;
data_emissao: Date | null;
situa: number;
status_descr: string | null;
id_cliente: number;
nome_cliente: string | null;
razao_cliente: string | null;
cod_vendedor: number;
forma_pagamento: string | null;
total: string;
obs: string | null;
tipo: 'P' | 'E';
num_ped_sar: string | null;
numero_entrega: number | null;
numero_nf: number | null;
chave_acesso_nfe: string | null;
sit_ped: number | null;
id_pedido: number | null;
}
const cte = `
WITH all_rows AS (
SELECT
b.numero AS numero_pedido,
COALESCE(b.data, a.data) AS data,
a.dtprevent AS dt_prevent,
a.data_emissao,
a.situa,
NULL::text AS status_descr,
a.clien::integer AS id_cliente,
a.cod_vendedor,
TRIM(c.descr) AS forma_pagamento,
a.total::text,
a.obs,
'E'::varchar(1) AS tipo,
TRIM(b.num_ped_sar) AS num_ped_sar,
a.numero AS numero_entrega,
d.numero AS numero_nf,
d.chave_acesso_nfe,
b.situa AS sit_ped,
a.id_pedido
FROM sig.pedidos a
LEFT JOIN sig.pedidos b
ON a.numer_ped_vinc = b.numero
AND a.id_empresa = b.id_empresa
AND b.tipo = 'P'
LEFT JOIN gestao.formapag c
ON c.id_empresa = ${idMatriz}
AND a.cod_formapag = c.codigo
LEFT JOIN gestao.nf d
ON a.numero = d.num_entrega
AND d.id_empresa = ${idMatriz}
WHERE a.id_empresa = ${idEmpresa}
AND a.tipo = 'E'
${vendedorFilter}
AND b.id_pedido IS NOT NULL
AND COALESCE(a.data, b.data) >= '${dataHistoricoStr}'
${fromFilterE} ${toFilterE}
UNION ALL
SELECT
a.numero AS numero_pedido,
a.data,
a.dtprevent AS dt_prevent,
a.data_emissao,
a.situa,
c.descricao AS status_descr,
a.clien::integer AS id_cliente,
a.cod_vendedor,
TRIM(b.descr) AS forma_pagamento,
a.total::text,
a.obs,
'P'::varchar(1) AS tipo,
TRIM(a.num_ped_sar) AS num_ped_sar,
NULL::integer AS numero_entrega,
NULL::integer AS numero_nf,
NULL::varchar AS chave_acesso_nfe,
NULL::integer AS sit_ped,
a.id_pedido
FROM sig.pedidos a
LEFT JOIN gestao.formapag b
ON b.id_empresa = 1
AND a.cod_formapag = b.codigo
LEFT JOIN vw_sitpedido c
ON c.id_sitpedido = a.situa
WHERE a.id_empresa = ${idEmpresa}
AND a.tipo = 'P'
${vendedorFilter}
AND a.situa NOT IN (3, 4, 5, 6)
${fromFilterP} ${toFilterP}
),
named AS (
SELECT r.*, cl.nome AS nome_cliente, cl.razao AS razao_cliente
FROM all_rows r
LEFT JOIN vw_clientes cl ON cl.id_cliente = r.id_cliente
WHERE 1=1 ${searchFilter}
)
`;
const [countRows, dataRows] = await Promise.all([
prisma.$queryRawUnsafe<[{ count: string }]>(
`${cte} SELECT COUNT(*)::text AS count FROM named`,
),
prisma.$queryRawUnsafe<ErpConsultaRow[]>(
`${cte} SELECT * FROM named ORDER BY data DESC LIMIT ${limit} OFFSET ${offset}`,
),
]);
const total = Number(countRows[0]?.count ?? 0);
const data: PedidoErpConsultaItem[] = dataRows.map((r) => ({
numeroPedido: Number(r.numero_pedido),
data: r.data ? new Date(r.data).toISOString() : new Date().toISOString(),
dtPrevent: r.dt_prevent ? new Date(r.dt_prevent).toISOString() : null,
dataEmissao: r.data_emissao ? new Date(r.data_emissao).toISOString() : null,
situa: Number(r.situa),
statusDescr: r.status_descr ?? null,
idCliente: Number(r.id_cliente),
nomeCliente: r.nome_cliente ?? null,
razaoCliente: r.razao_cliente ?? null,
codVendedor: Number(r.cod_vendedor),
formaPagamento: r.forma_pagamento ?? null,
total: r.total ?? '0',
obs: r.obs ?? null,
tipo: r.tipo as 'P' | 'E',
numPedSar: r.num_ped_sar ?? null,
numeroEntrega: r.numero_entrega != null ? Number(r.numero_entrega) : null,
numeroNf: r.numero_nf != null ? Number(r.numero_nf) : null,
chaveAcessoNfe: r.chave_acesso_nfe ?? null,
sitPed: r.sit_ped != null ? Number(r.sit_ped) : null,
idPedido: r.id_pedido != null ? Number(r.id_pedido) : null,
}));
return { data: [...sarData, ...erpData], total, page, limit };
return { data, total, page, limit };
}
async findOne(id: string): Promise<PedidoDetail> {