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>
489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
import { ForbiddenException, Injectable } from '@nestjs/common';
|
|
import { ClsService } from 'nestjs-cls';
|
|
import type {
|
|
EmpresaInfo,
|
|
UpdateLogoBody,
|
|
FormaPagamento,
|
|
Municipio,
|
|
Pauta,
|
|
ProdutoDetail,
|
|
ProdutoListQuery,
|
|
ProdutoListResponse,
|
|
ProdutoSummary,
|
|
} from '@sar/api-interface';
|
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|
|
|
// ADR 0006 revogado: produtos lidos diretamente de vw_produtos (ERP) + vw_estoque.
|
|
// Sem sync — dados sempre frescos da view do ERP.
|
|
|
|
function escSql(s: string): string {
|
|
return s.replace(/'/g, "''");
|
|
}
|
|
|
|
// Produtos, pautas e estoque são por-empresa e vivem na MATRIZ. O ERP usa códigos
|
|
// de empresa > 9000 para origem de pedido (ex.: 9001), mas o cadastro fica na
|
|
// matriz correspondente (9001 → 1), espelhando o CASE de vw_pedidos_erp.
|
|
// Pedidos continuam usando idEmpresa cru; só o catálogo normaliza.
|
|
function matrizEmpresa(idEmpresa: number): number {
|
|
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
|
}
|
|
|
|
interface ProdutoRow {
|
|
id_erp: number;
|
|
codigo: string;
|
|
descricao: string;
|
|
unidade: string | null;
|
|
vl_preco1: string;
|
|
cod_grupo: number | null;
|
|
grupo: string | null;
|
|
cod_subgrupo: number | null;
|
|
subgrupo: string | null;
|
|
marca: string | null;
|
|
ativo: number;
|
|
qtd_estoque: string | null;
|
|
lista_pauta: number | null;
|
|
referencia: string | null;
|
|
descr_det: string | null;
|
|
vl_preco2: string | null;
|
|
vl_preco3: string | null;
|
|
aliq_ipi: string | null;
|
|
peso_liquido: string | null;
|
|
qtd_volume: string | null;
|
|
lote_mul_venda: number | null;
|
|
preco_promocional: string | null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CatalogService {
|
|
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
|
|
|
// Dados legais da empresa matriz que fatura o pedido (cabeçalho do PDF).
|
|
async company(): Promise<EmpresaInfo> {
|
|
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'));
|
|
|
|
interface Row {
|
|
id_empresa: number;
|
|
razao_social: string | null;
|
|
nome_fantasia: string | null;
|
|
cnpj: string | null;
|
|
inscr_estadual: string | null;
|
|
endereco: string | null;
|
|
numero: string | null;
|
|
complemento: string | null;
|
|
bairro: string | null;
|
|
cidade: string | null;
|
|
uf: string | null;
|
|
cep: string | null;
|
|
telefone: string | null;
|
|
email: string | null;
|
|
}
|
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
|
SELECT e.id_empresa,
|
|
TRIM(e.razao_social) AS razao_social,
|
|
TRIM(e.nome) AS nome_fantasia,
|
|
TRIM(e.cnpj) AS cnpj,
|
|
TRIM(e.inscr_estadual) AS inscr_estadual,
|
|
TRIM(e.endereco) AS endereco,
|
|
e.num_endereco::text AS numero,
|
|
e.complemento,
|
|
TRIM(e.bairro) AS bairro,
|
|
TRIM(m.nome) AS cidade,
|
|
TRIM(e.uf) AS uf,
|
|
e.cep,
|
|
e.telefone,
|
|
TRIM(e.email) AS email
|
|
FROM sar.vw_empresas e
|
|
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
|
|
WHERE e.id_empresa = ${idEmpresa}
|
|
LIMIT 1
|
|
`);
|
|
const r = rows[0];
|
|
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
|
|
|
|
const config = await prisma.configEmpresa.findUnique({ where: { idEmpresa } });
|
|
|
|
const clean = (v: string | null) => {
|
|
const t = (v ?? '').trim();
|
|
return t === '' ? null : t;
|
|
};
|
|
return {
|
|
idEmpresa: Number(r.id_empresa),
|
|
razaoSocial: clean(r.razao_social) ?? clean(r.nome_fantasia) ?? `Empresa ${r.id_empresa}`,
|
|
nomeFantasia: clean(r.nome_fantasia),
|
|
cnpj: clean(r.cnpj),
|
|
inscricaoEstadual: clean(r.inscr_estadual),
|
|
endereco: clean(r.endereco),
|
|
numero: clean(r.numero),
|
|
complemento: clean(r.complemento),
|
|
bairro: clean(r.bairro),
|
|
cidade: clean(r.cidade),
|
|
uf: clean(r.uf),
|
|
cep: clean(r.cep),
|
|
telefone: clean(r.telefone),
|
|
email: clean(r.email),
|
|
logoBase64: config?.logoBase64 ?? null,
|
|
};
|
|
}
|
|
|
|
// Logo do cabeçalho da impressão — só gerente/admin altera; vale para todos
|
|
// os representantes da empresa (gravado na matriz, mesma fonte do company()).
|
|
async updateLogo(body: UpdateLogoBody): Promise<void> {
|
|
const role = this.cls.get('role') ?? 'rep';
|
|
if (role !== 'manager' && role !== 'admin') {
|
|
throw new ForbiddenException('Apenas gerentes podem alterar o logo da empresa');
|
|
}
|
|
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'));
|
|
|
|
await prisma.configEmpresa.upsert({
|
|
where: { idEmpresa },
|
|
create: { idEmpresa, logoBase64: body.logoBase64 },
|
|
update: { logoBase64: body.logoBase64 },
|
|
});
|
|
}
|
|
|
|
async municipios(q?: string): Promise<Municipio[]> {
|
|
const prisma = this.cls.get('prisma');
|
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
|
|
interface Row {
|
|
id_municipio: number;
|
|
nome: string;
|
|
uf: string;
|
|
codigo_ibge: number | null;
|
|
}
|
|
|
|
const searchFilter = q ? `WHERE UPPER(nome) LIKE UPPER('%${escSql(q)}%')` : '';
|
|
|
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
|
SELECT id_municipio, TRIM(nome) AS nome, uf::text AS uf, codigo_ibge
|
|
FROM sar.vw_municipios
|
|
${searchFilter}
|
|
ORDER BY nome
|
|
LIMIT 100
|
|
`);
|
|
|
|
return rows.map((r) => ({
|
|
idMunicipio: Number(r.id_municipio),
|
|
nome: r.nome,
|
|
uf: r.uf,
|
|
codigoIbge: r.codigo_ibge !== null ? Number(r.codigo_ibge) : null,
|
|
}));
|
|
}
|
|
|
|
async pautas(): Promise<Pauta[]> {
|
|
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 role = this.cls.get('role') ?? 'rep';
|
|
|
|
interface PautaRow {
|
|
id_pauta: number;
|
|
codigo: number;
|
|
descricao: string;
|
|
qtd_reps?: string;
|
|
}
|
|
// Rep vê só as pautas atribuídas a ele (cod_pauta1..6); gestor vê TODAS as
|
|
// pautas ativas, com a contagem de reps que usam cada uma.
|
|
const rows =
|
|
role === 'rep'
|
|
? 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 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
|
|
`)
|
|
: await prisma.$queryRawUnsafe<PautaRow[]>(`
|
|
SELECT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao,
|
|
COUNT(DISTINCT r.codigo)::text AS qtd_reps
|
|
FROM vw_pautas pa
|
|
LEFT 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 = ${idEmpresa}
|
|
AND pa.ativo = 1
|
|
GROUP BY pa.id_pauta, pa.codigo, pa.descricao
|
|
ORDER BY pa.codigo
|
|
`);
|
|
|
|
return rows.map((r) => ({
|
|
idPauta: Number(r.id_pauta),
|
|
codigo: Number(r.codigo),
|
|
descricao: r.descricao,
|
|
...(r.qtd_reps != null && { qtdReps: Number(r.qtd_reps) }),
|
|
}));
|
|
}
|
|
|
|
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 = 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 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 searchFilter = q
|
|
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
|
|
: '';
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
// 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 };
|
|
}
|
|
|
|
// 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 {
|
|
return {
|
|
idErp: Number(p.id_erp),
|
|
codigo: (p.codigo ?? '').trim(),
|
|
descricao: (p.descricao ?? '').trim(),
|
|
unidade: p.unidade,
|
|
vlPreco1: preco ?? '0',
|
|
codGrupo: p.cod_grupo !== null ? Number(p.cod_grupo) : null,
|
|
grupo: p.grupo ? p.grupo.trim() : null,
|
|
codSubgrupo: p.cod_subgrupo !== null ? Number(p.cod_subgrupo) : null,
|
|
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,
|
|
loteMulVenda: p.lote_mul_venda !== null ? Number(p.lote_mul_venda) : null,
|
|
};
|
|
}
|
|
|
|
async findOne(idErp: number): Promise<ProdutoDetail | null> {
|
|
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;
|
|
|
|
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
|
|
${
|
|
// Rep vê só as pautas dele; gestor vê os preços de TODAS as pautas
|
|
(this.cls.get('role') ?? 'rep') === 'rep'
|
|
? `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
|
|
) AND r.codigo = ${codVendedor}`
|
|
: ''
|
|
}
|
|
WHERE pp.id_produto = ${idErp}
|
|
AND pp.preco1 > 0
|
|
ORDER BY pa.codigo
|
|
`),
|
|
]);
|
|
|
|
const p = rows[0];
|
|
if (!p) return null;
|
|
|
|
return {
|
|
...this.mapRow(p, p.vl_preco1),
|
|
referencia: p.referencia,
|
|
descricaoDetalhada: p.descr_det,
|
|
vlPreco2: p.vl_preco2,
|
|
vlPreco3: p.vl_preco3,
|
|
aliqIpi: p.aliq_ipi,
|
|
pesoLiquido: p.peso_liquido,
|
|
qtdVolume: p.qtd_volume,
|
|
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,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async formasPagamento(): Promise<FormaPagamento[]> {
|
|
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'));
|
|
|
|
interface Row {
|
|
codigo: number;
|
|
descricao: string;
|
|
num_parcelas: number | null;
|
|
tx_acrescimo: string;
|
|
}
|
|
|
|
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
|
SELECT codigo, TRIM(descricao) AS descricao, num_parcelas, tx_acrescimo::text
|
|
FROM sar.vw_formas_pagamento
|
|
WHERE id_empresa = ${idEmpresa}
|
|
AND ativa = 1
|
|
AND integrar_sar = 1
|
|
ORDER BY codigo
|
|
`);
|
|
|
|
return rows.map((r) => ({
|
|
codigo: Number(r.codigo),
|
|
descricao: r.descricao,
|
|
numParcelas: r.num_parcelas !== null ? Number(r.num_parcelas) : null,
|
|
txAcrescimo: r.tx_acrescimo ?? '0',
|
|
}));
|
|
}
|
|
}
|