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