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) {} // Dados legais da empresa matriz que fatura o pedido (cabeçalho do PDF). async company(): Promise { 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(` 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 { 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 { 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(` 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 { 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(` 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(` 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 { 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(` 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(` 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 { 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 { 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(` 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(` 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 { 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(` 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', })); } }