import { Injectable, NotFoundException } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import type { ActivityStatus, ClientDetail, ClientListQuery, ClientListResponse, ClientNovoResult, ClientSummary, Contato, ContatoResult, CtrSummary, CtrTitulo, CreateClientNovo, CreateContato, NotaFiscal, PedidoSummary, TopProduto, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { getTeamCodes } from '../workspace/team-scope.util'; // Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente. const ALERT_DAYS = 30; const INACTIVE_DAYS = 60; // Usado apenas por findOne (já tem dt_ultima_compra calculado pelo SQL) function activityStatus(dtUltimaCompra: Date | null): ActivityStatus { if (!dtUltimaCompra) return 'inactive'; const days = Math.floor((Date.now() - dtUltimaCompra.getTime()) / 86_400_000); if (days >= INACTIVE_DAYS) return 'inactive'; if (days >= ALERT_DAYS) return 'alert'; return 'active'; } function escSql(s: string): string { return s.replace(/'/g, "''"); } // Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para // origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe. function matrizEmpresa(idEmpresa: number): number { return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; } // Row bruta do $queryRawUnsafe interface ClientRow { id_cliente: number; id_empresa: number; nome: string; razao: string | null; cgcpf: string | null; email: string | null; telefone: string | null; cod_vendedor: number; nome_vendedor: string | null; limite_credito: string | null; dt_ultima_compra: Date | null; ativo: number; pessoa: number | null; inscricao_estadual: string | null; endereco: string | null; num_endereco: string | null; bairro: string | null; cep: string | null; ddd: string | null; obs: string | null; cod_pauta: number | null; dt_cadastro: string | null; dt_atual: string | null; } // SQL compartilhado: dois subqueries que calculam a data do último pedido // considerando TANTO pedidos ERP (vw_pedidos_erp) QUANTO pedidos SAR (tabela pedidos). // vw_pedidos_erp: situa SIG 5=Cancelado (excluir); pedidos SAR: situa 3=Cancelado (excluir). // Clientes são cadastro GLOBAL (sem vínculo de id_empresa). A "última compra", // porém, é escopada à empresa atual: filtramos os pedidos por idEmpresa e juntamos // apenas por id_cliente. function pedidosJoins(idEmpresa: number): string { return ` LEFT JOIN ( SELECT id_cliente, MAX(dt_pedido) AS dt_max FROM vw_pedidos_erp WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa} GROUP BY id_cliente ) erp_ped ON erp_ped.id_cliente = c.id_cliente LEFT JOIN ( SELECT id_cliente, MAX(dt_pedido) AS dt_max FROM pedidos WHERE situa != 3 AND id_empresa = ${idEmpresa} GROUP BY id_cliente ) sar_ped ON sar_ped.id_cliente = c.id_cliente `; } // Subquery escalar para o nome do representante (cadastro global, sem id_empresa). // NÃO usar JOIN: vw_representantes tem códigos duplicados, o que multiplicaria as // linhas de cliente e quebraria contagem/paginação. LIMIT 1 garante 1 nome. const NOME_VENDEDOR_SUBQ = ` (SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor`; // Expressão SQL que calcula o activity_status a partir das datas dos dois joins. const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => ` CASE WHEN GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max) IS NULL THEN 'inactive' WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${INACTIVE_DAYS} THEN 'inactive' WHEN (CURRENT_DATE - GREATEST(${alias_erp}.dt_max, ${alias_sar}.dt_max)::date) >= ${ALERT_DAYS} THEN 'alert' ELSE 'active' END `; @Injectable() export class ClientsService { constructor(private readonly cls: ClsService) {} // PGD-AUTHZ: rep só acessa clientes da própria carteira; supervisor acessa as // carteiras da sua equipe (cod_supervisor); gerente/admin passam direto. // NotFound (não Forbidden) para não revelar a existência de clientes de terceiros. private async assertClienteDaCarteira(idCliente: number): Promise { const role = this.cls.get('role') ?? 'rep'; if (role !== 'rep' && role !== 'supervisor') return; const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); const userId = this.cls.get('userId'); const codVendedor = userId ? parseInt(userId, 10) : 0; const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>( `SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`, idCliente, ); const dono = rows[0] ? Number(rows[0].cod_vendedor) : null; const permitidos = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [codVendedor]; if (dono == null || !permitidos.includes(dono)) { throw new NotFoundException(`Cliente ${idCliente} não encontrado`); } } async list(query: ClientListQuery): Promise { 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 { q, status, page, limit } = query; const offset = (page - 1) * limit; // Rep vê apenas sua carteira; supervisor vê as carteiras da equipe const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : role === 'supervisor' ? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})` : ''; const searchFilter = q ? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')` : ''; // Filtro de status calculado em SQL — evita paginação quebrada do filtro pós-SQL const statusFilter = status ? `AND ${ACTIVITY_CASE()} = '${status}'` : ''; // Clientes globais: sem filtro de id_empresa. Rep continua escopado por cod_vendedor. const baseWhere = ` WHERE c.ativo = 1 ${vendedorFilter} ${searchFilter} ${statusFilter} `; const joins = pedidosJoins(idEmpresa); const [rows, totalRows] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email, c.telefone, c.cod_vendedor, ${NOME_VENDEDOR_SUBQ}, c.limite_credito::text, c.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta, c.dt_cadastro::text, c.dt_atual::text, GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra FROM vw_clientes c ${joins} ${baseWhere} ORDER BY c.nome LIMIT ${limit} OFFSET ${offset} `), prisma.$queryRawUnsafe<[{ count: string }]>(` SELECT COUNT(*)::text AS count FROM vw_clientes c ${joins} ${baseWhere} `), ]); const total = parseInt(totalRows[0]?.count ?? '0', 10); const mapped: ClientSummary[] = rows.map((r) => ({ idCliente: Number(r.id_cliente), idEmpresa: Number(r.id_empresa), nome: r.nome, razao: r.razao, cgcpf: r.cgcpf, email: r.email, telefone: r.telefone, codVendedor: Number(r.cod_vendedor), nomeVendedor: r.nome_vendedor ?? null, limiteCreditoStr: r.limite_credito, activityStatus: activityStatus(r.dt_ultima_compra), dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null, })); return { data: mapped, total, page, limit }; } async createNovo(dto: CreateClientNovo): Promise { 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 Row { id: string; id_corrent_erp: number | null; sincronizado: boolean; erro_sync: string | null; } const esc = (s: string) => s.replace(/'/g, "''"); const opt = (v: string | undefined) => (v != null ? `'${esc(v)}'` : 'NULL'); const optN = (v: number | undefined) => (v != null ? String(v) : 'NULL'); // INSERT retorna id — RETURNING lê valores pré-trigger, então // fazemos SELECT separado para capturar o que o trigger gravou const insertRows = await prisma.$queryRawUnsafe<[{ id: string }]>(` INSERT INTO sar.clientes_novos ( id_empresa, cod_vendedor, pesso, consfinal, nome, razao, cgcpf, suf_cgcpf, inscr, indicador_ie, endereco, num_endereco, bairro, id_municipio, cep, ddd, telefone, email, limite_credito, cod_formapag, cod_pauta ) VALUES ( ${idEmpresa}, ${codVendedor}, ${dto.pesso}, ${dto.consfinal}, '${esc(dto.nome)}', '${esc(dto.razao)}', ${opt(dto.cgcpf)}, '${esc(dto.sufCgcpf ?? '')}', '${esc(dto.inscr ?? '')}', ${optN(dto.indicadorIe)}, '${esc(dto.endereco ?? '')}', '${esc(dto.numEndereco ?? '')}', '${esc(dto.bairro ?? '')}', ${dto.idMunicipio}, '${esc(dto.cep ?? '')}', '${esc(dto.ddd ?? '')}', '${esc(dto.telefone ?? '')}', '${esc(dto.email ?? '')}', ${dto.limiteCredito ?? 0}, ${optN(dto.codFormapag)}, ${optN(dto.codPauta)} ) RETURNING id::text `); const id = insertRows[0]?.id; if (!id) throw new Error('Falha ao inserir cliente'); const rows = await prisma.$queryRawUnsafe(` SELECT id::text, id_corrent_erp, sincronizado, erro_sync FROM sar.clientes_novos WHERE id = '${id}' `); const r = rows[0]; if (!r) throw new Error('Falha ao recuperar cliente inserido'); return { id: r.id, idCorrentErp: r.id_corrent_erp !== null ? Number(r.id_corrent_erp) : null, sincronizado: r.sincronizado, erroSync: r.erro_sync, }; } async listContacts(idCorrent: number): Promise { await this.assertClienteDaCarteira(idCorrent); const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); interface Row { id_contato: number; id_empresa: number; id_corrent: number; id_chatwoot: number | null; nome: string; empresa: string | null; cargo: string | null; departamento: string | null; dt_aniversario: string | null; telefone: string | null; ramal: string | null; celular: string | null; whatsapp: string | null; email: string | null; ativo: number; anotacoes: string | null; nome_cliente: string | null; razao_cliente: string | null; cod_vendedor: number | null; } const rows = await prisma.$queryRawUnsafe(` SELECT id_contato, id_empresa, id_corrent, id_chatwoot, nome, empresa, cargo, departamento, dt_aniversario::text, telefone, ramal, celular, whatsapp, email, ativo, anotacoes, nome_cliente, razao_cliente, cod_vendedor FROM sar.vw_contatos WHERE id_corrent = ${idCorrent} AND ativo = 1 ORDER BY nome `); return rows.map((r) => ({ idContato: Number(r.id_contato), idEmpresa: Number(r.id_empresa), idCorrent: Number(r.id_corrent), idChatwoot: r.id_chatwoot !== null ? Number(r.id_chatwoot) : null, nome: r.nome, empresa: r.empresa, cargo: r.cargo, departamento: r.departamento, dtAniversario: r.dt_aniversario, telefone: r.telefone, ramal: r.ramal, celular: r.celular, whatsapp: r.whatsapp, email: r.email, ativo: Number(r.ativo), anotacoes: r.anotacoes, nomeCliente: r.nome_cliente, razaoCliente: r.razao_cliente, codVendedor: r.cod_vendedor !== null ? Number(r.cod_vendedor) : null, })); } async createContato(idCorrent: number, dto: CreateContato): Promise { await this.assertClienteDaCarteira(idCorrent); 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 Row { id: string; } const esc = (s: string) => s.replace(/'/g, "''"); const opt = (v: string | undefined) => (v ? `'${esc(v)}'` : 'NULL'); const insertRows = await prisma.$queryRawUnsafe(` INSERT INTO sar.contatos_novos ( id_empresa, cod_vendedor, id_corrent, nome, empresa, cargo, departamento, dt_aniversario, telefone, ramal, celular, whatsapp, email, anotacoes ) VALUES ( ${idEmpresa}, ${codVendedor}, ${idCorrent}, '${esc(dto.nome)}', ${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)}, ${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'}, ${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)}, ${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)} ) RETURNING id::text `); const id = insertRows[0]?.id; if (!id) throw new Error('Falha ao inserir contato'); interface ResultRow { id: string; id_contato_erp: number | null; sincronizado: boolean; erro_sync: string | null; } const rows = await prisma.$queryRawUnsafe(` SELECT id::text, id_contato_erp, sincronizado, erro_sync FROM sar.contatos_novos WHERE id = '${id}' `); const r = rows[0]; if (!r) throw new Error('Falha ao recuperar contato inserido'); return { id: r.id, idContatoErp: r.id_contato_erp !== null ? Number(r.id_contato_erp) : null, sincronizado: r.sincronizado, erroSync: r.erro_sync, }; } async listOrdersHistory(idCliente: number, limit: number): Promise { await this.assertClienteDaCarteira(idCliente); const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); const idEmpresa = this.cls.get('idEmpresa'); interface Row { id: string; num_ped: string; situa: number; dt_pedido: Date; total: string; fonte: string; } const rows = await prisma.$queryRawUnsafe(` SELECT id::text AS id, num_ped_sar AS num_ped, situa, dt_pedido, total::text, 'sar' AS fonte FROM sar.pedidos WHERE id_cliente = ${idCliente} AND id_empresa = ${idEmpresa} UNION ALL SELECT ('erp-' || id_pedido::text) AS id, COALESCE(NULLIF(TRIM(num_ped_sar::text),''), numero::text) AS num_ped, CASE WHEN situa = 5 THEN 3 ELSE situa END AS situa, dt_pedido, total::text, 'erp' AS fonte FROM sar.vw_pedidos_erp WHERE id_cliente = ${idCliente} AND id_empresa = ${idEmpresa} AND situa != 5 ORDER BY dt_pedido DESC LIMIT ${limit} `); return rows.map((r) => ({ id: r.id, numPedSar: r.num_ped ?? r.id, idCliente, nomeCliente: null, razaoCliente: null, codVendedor: 0, nomeVendedor: null, situa: Number(r.situa), dtPedido: r.dt_pedido.toISOString(), total: r.total ?? '0', descontoPerc: '0', obs: null, createdAt: r.dt_pedido.toISOString(), fonte: r.fonte as 'sar' | 'erp', })); } async listTopProdutos(idCliente: number, limit: number): Promise { await this.assertClienteDaCarteira(idCliente); const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); const idEmpresa = this.cls.get('idEmpresa'); // Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos const idEmpresaMatriz = matrizEmpresa(idEmpresa); interface Row { id_produto: number; codigo: string; descricao: string; unidade: string | null; qtd_total: string; num_pedidos: string; ultima_compra: Date; ultimo_preco: string; } const rows = await prisma.$queryRawUnsafe(` SELECT i.id_produto, TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao, TRIM(p.unidade) AS unidade, SUM(i.qtd)::numeric(15,3)::text AS qtd_total, COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos, MAX(ped.dt_pedido) AS ultima_compra, ( SELECT i2.preco_unitario::numeric(15,2)::text FROM sar.vw_peditens_erp i2 JOIN sar.vw_pedidos_erp p2 ON p2.id_pedido = i2.id_pedido WHERE i2.id_produto = i.id_produto AND p2.id_cliente = ped.id_cliente AND p2.id_empresa = ${idEmpresa} AND p2.situa NOT IN (5) ORDER BY p2.dt_pedido DESC, p2.id_pedido DESC LIMIT 1 ) AS ultimo_preco FROM sar.vw_pedidos_erp ped JOIN sar.vw_peditens_erp i ON i.id_pedido = ped.id_pedido JOIN sar.vw_produtos p ON p.id_erp = i.id_produto AND p.id_empresa = ${idEmpresaMatriz} WHERE ped.id_cliente = ${idCliente} AND ped.id_empresa = ${idEmpresa} AND ped.situa NOT IN (5) GROUP BY i.id_produto, p.codigo, p.descricao, p.unidade, ped.id_cliente ORDER BY SUM(i.qtd) DESC LIMIT ${limit} `); return rows.map((r) => ({ idProduto: Number(r.id_produto), codProduto: r.codigo ?? '', descricao: r.descricao, unidade: r.unidade, qtdTotal: parseFloat(r.qtd_total ?? '0'), numPedidos: parseInt(r.num_pedidos ?? '0', 10), ultimaCompra: (r.ultima_compra as Date).toISOString().slice(0, 10), ultimoPreco: parseFloat(r.ultimo_preco ?? '0'), })); } async getCtrSummary(idCliente: number): Promise { await this.assertClienteDaCarteira(idCliente); const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa')); interface Row { qtd_aberto: string; total_aberto: string; qtd_vencido: string; total_vencido: string; maior_atraso_dias: string; } const rows = await prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS qtd_aberto, COALESCE(SUM(saldo), 0)::numeric(15,2)::text AS total_aberto, (COUNT(*) FILTER (WHERE dt_vencimento < CURRENT_DATE))::text AS qtd_vencido, COALESCE(SUM(saldo) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::numeric(15,2)::text AS total_vencido, COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias FROM sar.vw_ctr WHERE id_cliente = ${idCliente} AND id_empresa = ${idEmpresaMatriz} AND situacao = 'A' AND saldo > 0 `); const r = rows[0]; return { qtdAberto: parseInt(r?.qtd_aberto ?? '0', 10), totalAberto: parseFloat(r?.total_aberto ?? '0'), qtdVencido: parseInt(r?.qtd_vencido ?? '0', 10), totalVencido: parseFloat(r?.total_vencido ?? '0'), maiorAtrasoDias: parseInt(r?.maior_atraso_dias ?? '0', 10), }; } async findOne(idCliente: number): Promise { await this.assertClienteDaCarteira(idCliente); 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 rows = await prisma.$queryRawUnsafe(` SELECT c.id_cliente, c.id_empresa, c.nome, c.razao, c.cgcpf, c.email, c.telefone, c.cod_vendedor, ${NOME_VENDEDOR_SUBQ}, c.limite_credito::text, c.ativo, c.pessoa, c.inscricao_estadual, c.endereco, c.num_endereco, c.bairro, c.cep, c.ddd, c.obs, c.cod_pauta, c.dt_cadastro::text, c.dt_atual::text, GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra FROM vw_clientes c ${pedidosJoins(idEmpresa)} WHERE c.id_cliente = ${idCliente} LIMIT 1 `); const r = rows[0]; if (!r) throw new NotFoundException(`Cliente ${idCliente} não encontrado`); return { idCliente: Number(r.id_cliente), idEmpresa: Number(r.id_empresa), nome: r.nome, razao: r.razao, cgcpf: r.cgcpf, email: r.email, telefone: r.telefone, codVendedor: Number(r.cod_vendedor), nomeVendedor: r.nome_vendedor ?? null, limiteCreditoStr: r.limite_credito, activityStatus: activityStatus(r.dt_ultima_compra), dtUltimaCompra: r.dt_ultima_compra?.toISOString() ?? null, ativo: Number(r.ativo), pessoa: r.pessoa !== null ? Number(r.pessoa) : null, inscricaoEstadual: r.inscricao_estadual, endereco: r.endereco, numEndereco: r.num_endereco, bairro: r.bairro, cep: r.cep, ddd: r.ddd, obs: r.obs, codPauta: r.cod_pauta !== null ? Number(r.cod_pauta) : null, dtCadastro: r.dt_cadastro, dtAtual: r.dt_atual, }; } async listCtrTitulos(idCliente: number, limit: number): Promise { await this.assertClienteDaCarteira(idCliente); const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa')); type CtrRow = { id_ctr: number; numero: string; prefixo: string; dt_emissao: Date; dt_vencimento: Date; saldo: string; }; const rows = await prisma.$queryRawUnsafe( ` SELECT id_ctr, TRIM(numero) AS numero, TRIM(prefixo) AS prefixo, dt_emissao, dt_vencimento, saldo::text AS saldo FROM sar.vw_ctr WHERE id_cliente = $1 AND id_empresa = $3 AND situacao = 'A' AND saldo > 0 ORDER BY dt_vencimento ASC LIMIT $2 `, idCliente, limit, idEmpresaMatriz, ); const toDate = (d: Date | string): string => d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10); return rows.map((r: CtrRow) => ({ idCtr: Number(r.id_ctr), numero: r.numero, prefixo: r.prefixo, dtEmissao: toDate(r.dt_emissao), dtVencimento: toDate(r.dt_vencimento), saldo: Number(r.saldo), })); } async listNotasFiscais(idCliente: number, limit: number): Promise { await this.assertClienteDaCarteira(idCliente); const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); // NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000) const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa')); const idEmpresaFiscal = idEmpresaMatriz + 9000; type NfRow = { id_nf: number; numero: number; serie: string; dt_emissao: Date; vl_nf: string; chave_acesso_nfe: string | null; }; const rows = await prisma.$queryRawUnsafe( ` SELECT id_nf, numero, serie, dt_emissao, vl_nf, chave_acesso_nfe FROM ( SELECT DISTINCT ON (nf.id_nf) nf.id_nf, nf.numero, TRIM(nf.serie) AS serie, nf.dt_emissao, nf.vl_nf::text AS vl_nf, TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe FROM sar.vw_nf nf JOIN sar.vw_pedidos_erp p ON p.numero = nf.num_entrega AND p.tipo = 'E' AND p.id_empresa IN ($3, $4) WHERE p.id_cliente = $1 AND nf.id_empresa = $3 AND nf.status = 'E' AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != '' ORDER BY nf.id_nf ) sub ORDER BY dt_emissao DESC LIMIT $2 `, idCliente, limit, idEmpresaMatriz, idEmpresaFiscal, ); return rows.map((r: NfRow) => ({ idNf: Number(r.id_nf), numero: Number(r.numero), serie: r.serie?.trim() ?? '', dtEmissao: (r.dt_emissao instanceof Date ? r.dt_emissao.toISOString().split('T')[0] : String(r.dt_emissao)) ?? '', vlNf: Number(r.vl_nf), chaveAcessoNfe: r.chave_acesso_nfe?.trim() || null, })); } }