- DevLogin com 3 usuarios reais: Pavei (rep 29), Sidnei (supervisor 191) e Lucas (gerente 156); guard deixa de forcar DEV_REP_CODE em dev e usa sub/id_empresa do JWT (fallback para os valores do .env); idEmpresa do token dev opcional, default DEV_EMPRESA_ID - getTeamCodes (vw_representantes.cod_supervisor): supervisor ve so a equipe em pedidos SAR/ERP, detalhe, aprovar/recusar, clientes (lista e carteira), dashboard supervisor, chamados SAC e relatorios carteira/curva ABC; gerente/admin seguem vendo a empresa toda - Painel gerencial: card "Positivacao Diaria de Clientes" (clientes distintos por dia via vw_pedidos_erp) com campo de meta/dia persistido em localStorage, linha de meta tracejada e contador de dias na meta Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
735 lines
25 KiB
TypeScript
735 lines
25 KiB
TypeScript
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<WorkspaceClsStore>) {}
|
|
|
|
// 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<void> {
|
|
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<ClientListResponse> {
|
|
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<ClientRow[]>(`
|
|
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<ClientNovoResult> {
|
|
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<Row[]>(`
|
|
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<Contato[]> {
|
|
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<Row[]>(`
|
|
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<ContatoResult> {
|
|
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<Row[]>(`
|
|
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<ResultRow[]>(`
|
|
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<PedidoSummary[]> {
|
|
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<Row[]>(`
|
|
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<TopProduto[]> {
|
|
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<Row[]>(`
|
|
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<CtrSummary> {
|
|
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<Row[]>(`
|
|
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<ClientDetail> {
|
|
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<ClientRow[]>(`
|
|
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<CtrTitulo[]> {
|
|
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<CtrRow[]>(
|
|
`
|
|
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<NotaFiscal[]> {
|
|
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<NfRow[]>(
|
|
`
|
|
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,
|
|
}));
|
|
}
|
|
}
|