feat(mvp-rep): formas de pagamento do ERP + suporte offline completo
Formas de pagamento: - Endpoint GET /catalog/payment-methods lendo vw_formas_pagamento filtrado por ativa=1 e integrar_sar=1 - FormaPagamento schema/type no shared api-interface - Hook useFormasPagamento (staleTime 1h) substituindo lista hardcoded Offline (FR-4.2 / NFR-2.1–2.4): - IndexedDB queue: lib/offline/idb.ts + order-queue.ts sem deps externos - NewOrderPage detecta !navigator.onLine → enqueueOrder() → toast + reset - useOfflineSync: auto-sync ao reconectar (POST orders + PATCH transmit) - usePendingOrders: fila reativa via CustomEvents - AppShell: banner offline + useOfflineSync() global - OrdersPage: seção de pedidos pendentes com retry/descartar - sw.js: network-first para API GETs cacheáveis + stale-while-revalidate para assets + app shell navigate fallback Docs: - architecture.md: documento de decisões de arquitetura do SAR MVP Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,11 @@ export class AuthController {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
// Representante é cadastro global (sem id_empresa).
|
||||
const rows = await prisma.$queryRaw<{ codigo: number; nome: string }[]>`
|
||||
SELECT codigo, nome
|
||||
FROM sar.vw_representantes
|
||||
WHERE codigo = ${parseInt(userId, 10)}
|
||||
AND id_empresa = ${idEmpresa}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
ProdutoListQuerySchema,
|
||||
type EmpresaInfo,
|
||||
type FormaPagamento,
|
||||
type Pauta,
|
||||
type ProdutoDetail,
|
||||
type ProdutoListQuery,
|
||||
@@ -22,6 +24,16 @@ export class CatalogController {
|
||||
return this.catalog.pautas();
|
||||
}
|
||||
|
||||
@Get('payment-methods')
|
||||
formasPagamento(): Promise<FormaPagamento[]> {
|
||||
return this.catalog.formasPagamento();
|
||||
}
|
||||
|
||||
@Get('company')
|
||||
company(): Promise<EmpresaInfo> {
|
||||
return this.catalog.company();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
||||
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
EmpresaInfo,
|
||||
FormaPagamento,
|
||||
Pauta,
|
||||
ProdutoDetail,
|
||||
ProdutoListQuery,
|
||||
@@ -16,6 +18,14 @@ 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;
|
||||
@@ -45,10 +55,77 @@ interface ProdutoRow {
|
||||
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,
|
||||
NULLIF(e.numero, 0)::text AS numero,
|
||||
NULLIF(TRIM(e.complemento), '.') AS complemento,
|
||||
TRIM(e.bairro) AS bairro,
|
||||
TRIM(m.nome) AS cidade,
|
||||
TRIM(e.estado::text) AS uf,
|
||||
TRIM(e.cep::text) AS cep,
|
||||
TRIM(e.telefone::text) AS telefone,
|
||||
TRIM(e.email) AS email
|
||||
FROM gestao.empresa 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 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),
|
||||
};
|
||||
}
|
||||
|
||||
async pautas(): Promise<Pauta[]> {
|
||||
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 idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
@@ -60,8 +137,7 @@ export class CatalogService {
|
||||
const rows = 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 r.id_empresa = pa.id_empresa
|
||||
AND pa.codigo IN (
|
||||
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
|
||||
)
|
||||
@@ -81,7 +157,7 @@ export class CatalogService {
|
||||
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 = this.cls.get('idEmpresa');
|
||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
const { q, codGrupo, idPauta, page, limit } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -183,7 +259,7 @@ export class CatalogService {
|
||||
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 = this.cls.get('idEmpresa');
|
||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<ProdutoRow[]>(`
|
||||
SELECT
|
||||
@@ -232,4 +308,33 @@ export class CatalogService {
|
||||
precoPromocional: p.preco_promocional,
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface ClientRow {
|
||||
email: string | null;
|
||||
telefone: string | null;
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
limite_credito: string | null;
|
||||
dt_ultima_compra: Date | null;
|
||||
ativo: number;
|
||||
@@ -55,20 +56,33 @@ interface ClientRow {
|
||||
// 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).
|
||||
const PEDIDOS_JOINS = `
|
||||
// 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, id_empresa, MAX(dt_pedido) AS dt_max
|
||||
SELECT id_cliente, MAX(dt_pedido) AS dt_max
|
||||
FROM vw_pedidos_erp
|
||||
WHERE situa NOT IN (5)
|
||||
GROUP BY id_cliente, id_empresa
|
||||
) erp_ped ON erp_ped.id_cliente = c.id_cliente AND erp_ped.id_empresa = c.id_empresa
|
||||
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, id_empresa, MAX(dt_pedido) AS dt_max
|
||||
SELECT id_cliente, MAX(dt_pedido) AS dt_max
|
||||
FROM pedidos
|
||||
WHERE situa != 3
|
||||
GROUP BY id_cliente, id_empresa
|
||||
) sar_ped ON sar_ped.id_cliente = c.id_cliente AND sar_ped.id_empresa = c.id_empresa
|
||||
`;
|
||||
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') => `
|
||||
@@ -104,13 +118,14 @@ export class ClientsService {
|
||||
// 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.id_empresa = ${idEmpresa}
|
||||
AND c.ativo = 1
|
||||
WHERE c.ativo = 1
|
||||
${vendedorFilter}
|
||||
${searchFilter}
|
||||
${statusFilter}
|
||||
`;
|
||||
const joins = pedidosJoins(idEmpresa);
|
||||
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<ClientRow[]>(`
|
||||
@@ -123,6 +138,7 @@ export class ClientsService {
|
||||
c.email,
|
||||
c.telefone,
|
||||
c.cod_vendedor,
|
||||
${NOME_VENDEDOR_SUBQ},
|
||||
c.limite_credito::text,
|
||||
c.ativo,
|
||||
c.pessoa,
|
||||
@@ -138,7 +154,7 @@ export class ClientsService {
|
||||
c.dt_atual::text,
|
||||
GREATEST(erp_ped.dt_max, sar_ped.dt_max) AS dt_ultima_compra
|
||||
FROM vw_clientes c
|
||||
${PEDIDOS_JOINS}
|
||||
${joins}
|
||||
${baseWhere}
|
||||
ORDER BY c.nome
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
@@ -146,7 +162,7 @@ export class ClientsService {
|
||||
prisma.$queryRawUnsafe<[{ count: string }]>(`
|
||||
SELECT COUNT(*)::text AS count
|
||||
FROM vw_clientes c
|
||||
${PEDIDOS_JOINS}
|
||||
${joins}
|
||||
${baseWhere}
|
||||
`),
|
||||
]);
|
||||
@@ -162,6 +178,7 @@ export class ClientsService {
|
||||
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,
|
||||
@@ -178,14 +195,14 @@ export class ClientsService {
|
||||
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, c.limite_credito::text,
|
||||
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
|
||||
${PEDIDOS_JOINS}
|
||||
WHERE c.id_empresa = ${idEmpresa} AND c.id_cliente = ${idCliente}
|
||||
${pedidosJoins(idEmpresa)}
|
||||
WHERE c.id_cliente = ${idCliente}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
@@ -201,6 +218,7 @@ export class ClientsService {
|
||||
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,
|
||||
|
||||
@@ -7,11 +7,32 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||
const SITUA_PENDENTE = 1;
|
||||
|
||||
// tipo='G' em gestao.metavenda = meta geral de valor do mês
|
||||
const TIPO_META_GERAL = 'G';
|
||||
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
|
||||
const TIPO_META_GLOBAL = 'GL';
|
||||
const TIPO_META_GRUPO = 'GR';
|
||||
|
||||
interface MetaRow {
|
||||
// Metas/produtos vivem na MATRIZ; pedidos usam idEmpresa cru (ex.: 9001 → matriz 1).
|
||||
function matrizEmpresa(idEmpresa: number): number {
|
||||
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
}
|
||||
|
||||
interface MetaErpRow {
|
||||
tipo: string;
|
||||
cod_grupo: number | null;
|
||||
desc_grupo: string | null;
|
||||
valor: string;
|
||||
qtdade: string;
|
||||
peso: string;
|
||||
vl_fator: string;
|
||||
}
|
||||
|
||||
interface RealizadoGrupoRow {
|
||||
cod_grupo: number | null;
|
||||
grupo: string | null;
|
||||
pedidos: string;
|
||||
valor: string;
|
||||
qtd: string;
|
||||
peso: string;
|
||||
}
|
||||
|
||||
interface RepRow {
|
||||
@@ -28,6 +49,7 @@ interface InativoRow {
|
||||
|
||||
interface InativosPorRepRow {
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
inativos_count: string;
|
||||
}
|
||||
|
||||
@@ -48,25 +70,33 @@ export class DashboardService {
|
||||
const monthStart = new Date(year, month - 1, 1);
|
||||
const monthEnd = new Date(year, month, 0, 23, 59, 59, 999);
|
||||
|
||||
// 1. Meta geral do mês — fonte: gestao.metavenda (via vw_metas), tipo='G'
|
||||
const metaRows = await prisma.$queryRawUnsafe<MetaRow[]>(`
|
||||
SELECT valor::text
|
||||
// 1. Metas do mês — vw_metas vive na matriz (normaliza 9001→1).
|
||||
// GL = meta global; GR = meta por grupo. A dimensão segue o que o ERP tiver.
|
||||
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||
const metaErpRows = await prisma.$queryRawUnsafe<MetaErpRow[]>(`
|
||||
SELECT TRIM(tipo) AS tipo, cod_grupo, TRIM(desc_grupo) AS desc_grupo,
|
||||
valor::text AS valor, qtdade::text AS qtdade,
|
||||
peso::text AS peso, vl_fator::text AS vl_fator
|
||||
FROM vw_metas
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
WHERE id_empresa = ${idEmpresaMatriz}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND TRIM(tipo) = '${TIPO_META_GERAL}'
|
||||
AND ano = ${year}
|
||||
AND mes = ${month}
|
||||
LIMIT 1
|
||||
AND ano = ${year}
|
||||
AND mes = ${month}
|
||||
AND TRIM(tipo) IN ('${TIPO_META_GLOBAL}', '${TIPO_META_GRUPO}')
|
||||
`);
|
||||
const targetAmount = metaRows[0] ? Number(metaRows[0].valor) : 0;
|
||||
const glRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GLOBAL);
|
||||
const grRows = metaErpRows.filter((m) => m.tipo === TIPO_META_GRUPO);
|
||||
// Total global: usa GL se houver; senão soma as metas por grupo (GR).
|
||||
const targetAmount = glRows.length
|
||||
? glRows.reduce((a, m) => a + Number(m.valor), 0)
|
||||
: grRows.reduce((a, m) => a + Number(m.valor), 0);
|
||||
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
|
||||
|
||||
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
|
||||
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
|
||||
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
|
||||
FROM vw_representantes
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND codigo = ${codVendedor}
|
||||
WHERE codigo = ${codVendedor}
|
||||
LIMIT 1
|
||||
`);
|
||||
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
|
||||
@@ -79,7 +109,8 @@ export class DashboardService {
|
||||
});
|
||||
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
|
||||
|
||||
// 4. Atingido do mês — pedidos liberados/faturados no ERP (situa 2=Liberado, 4=Faturado)
|
||||
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
|
||||
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
|
||||
const monthStartStr = monthStart.toISOString().slice(0, 10);
|
||||
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
||||
|
||||
@@ -94,7 +125,10 @@ export class DashboardService {
|
||||
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;
|
||||
@@ -103,13 +137,13 @@ export class DashboardService {
|
||||
obs: string | null;
|
||||
}
|
||||
|
||||
const [atingidoRows, pedidosMesRows, recentRows] = await Promise.all([
|
||||
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||
SELECT COALESCE(SUM(total), 0)::text AS total
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND situa IN (2, 4)
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
`),
|
||||
@@ -123,16 +157,40 @@ export class DashboardService {
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
`),
|
||||
prisma.$queryRawUnsafe<RecentRow[]>(`
|
||||
SELECT id_pedido, num_ped_sar, numero, id_cliente, cod_vendedor,
|
||||
situa, status_descr, dt_pedido, total::text, desconto_perc::text, obs
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND situa != 5
|
||||
AND dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}'
|
||||
ORDER BY dt_pedido DESC
|
||||
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
|
||||
WHERE e.id_empresa = ${idEmpresa}
|
||||
AND e.cod_vendedor = ${codVendedor}
|
||||
AND e.situa != 5
|
||||
AND e.dt_pedido >= '${new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)}'
|
||||
ORDER BY e.dt_pedido DESC
|
||||
LIMIT 10
|
||||
`),
|
||||
// Realizado por grupo — itens faturados (situa 2/4) → produto (matriz) → cod_grupo.
|
||||
// peso = qtd × peso_líquido do produto; pedidos = nº de pedidos distintos.
|
||||
prisma.$queryRawUnsafe<RealizadoGrupoRow[]>(`
|
||||
SELECT p.cod_grupo,
|
||||
COALESCE(NULLIF(TRIM(p.grupo), ''), p.cod_grupo::text) AS grupo,
|
||||
COUNT(DISTINCT pi.id_pedido)::text AS pedidos,
|
||||
COALESCE(SUM(pi.total), 0)::text AS valor,
|
||||
COALESCE(SUM(pi.qtd), 0)::text AS qtd,
|
||||
COALESCE(SUM(pi.qtd * COALESCE(p.peso_liquido, 0)), 0)::text AS peso
|
||||
FROM vw_peditens_erp pi
|
||||
JOIN vw_pedidos_erp e ON e.id_pedido = pi.id_pedido
|
||||
JOIN vw_produtos p ON p.id_erp = pi.id_produto AND p.id_empresa = ${idEmpresaMatriz}
|
||||
WHERE e.id_empresa = ${idEmpresa}
|
||||
AND e.cod_vendedor = ${codVendedor}
|
||||
AND e.situa NOT IN (1, 5)
|
||||
AND e.dt_pedido >= '${monthStartStr}'
|
||||
AND e.dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY p.cod_grupo, grupo
|
||||
`),
|
||||
]);
|
||||
|
||||
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
||||
@@ -157,10 +215,9 @@ export class DashboardService {
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN vw_pedidos_erp p
|
||||
ON p.id_cliente = c.id_cliente
|
||||
AND p.id_empresa = c.id_empresa
|
||||
AND p.id_empresa = ${idEmpresa}
|
||||
AND p.situa != 5
|
||||
WHERE c.id_empresa = ${idEmpresa}
|
||||
AND c.cod_vendedor = ${codVendedor}
|
||||
WHERE c.cod_vendedor = ${codVendedor}
|
||||
AND c.ativo = 1
|
||||
GROUP BY c.id_cliente, c.nome
|
||||
HAVING MAX(p.dt_pedido) IS NULL
|
||||
@@ -169,8 +226,41 @@ export class DashboardService {
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
|
||||
const realPorGrupo = new Map<number, RealizadoGrupoRow>();
|
||||
for (const r of realizadoGrupoRows) {
|
||||
if (r.cod_grupo != null) realPorGrupo.set(Number(r.cod_grupo), r);
|
||||
}
|
||||
const metasPorGrupo = grRows
|
||||
.map((m) => {
|
||||
const cod = m.cod_grupo != null ? Number(m.cod_grupo) : null;
|
||||
const real = cod != null ? realPorGrupo.get(cod) : undefined;
|
||||
const valorMeta = Number(m.valor);
|
||||
const valorReal = real ? Number(real.valor) : 0;
|
||||
const pesoMeta = Number(m.peso);
|
||||
const pesoReal = real ? Number(real.peso) : 0;
|
||||
return {
|
||||
codigo: cod,
|
||||
rotulo: m.desc_grupo || real?.grupo || `Grupo ${cod ?? '?'}`,
|
||||
pedidos: real ? Number(real.pedidos) : 0,
|
||||
valorMeta,
|
||||
valorReal,
|
||||
qtdMeta: Number(m.qtdade),
|
||||
qtdReal: real ? Number(real.qtd) : 0,
|
||||
pesoMeta,
|
||||
pesoReal,
|
||||
fatorMeta: Number(m.vl_fator),
|
||||
fatorReal: pesoReal > 0 ? valorReal / pesoReal : 0,
|
||||
pct: valorMeta > 0 ? Math.round((valorReal / valorMeta) * 100) : 0,
|
||||
falta: Math.max(0, valorMeta - valorReal),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||
|
||||
return {
|
||||
meta: { atingido, total: targetAmount, pct, falta },
|
||||
metaDimensao,
|
||||
metasPorGrupo,
|
||||
comissao: { fixa, flex, total: fixa + flex },
|
||||
pedidosMes,
|
||||
pedidosRecentes: recentRows.map((o) => ({
|
||||
@@ -178,7 +268,10 @@ export class DashboardService {
|
||||
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,
|
||||
situa: Number(o.situa),
|
||||
statusDescr: o.status_descr,
|
||||
dtPedido: new Date(o.dt_pedido).toISOString(),
|
||||
@@ -244,30 +337,58 @@ export class DashboardService {
|
||||
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP)
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||
SELECT cod_vendedor, COUNT(*)::text AS inativos_count
|
||||
SELECT inativos.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r
|
||||
WHERE r.codigo = inativos.cod_vendedor
|
||||
LIMIT 1) AS nome_vendedor,
|
||||
COUNT(*)::text AS inativos_count
|
||||
FROM (
|
||||
SELECT c.id_cliente, c.cod_vendedor
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN vw_pedidos_erp p
|
||||
ON p.id_cliente = c.id_cliente
|
||||
AND p.id_empresa = c.id_empresa
|
||||
AND p.id_empresa = ${idEmpresa}
|
||||
AND p.situa != 5
|
||||
WHERE c.id_empresa = ${idEmpresa}
|
||||
AND c.ativo = 1
|
||||
WHERE c.ativo = 1
|
||||
GROUP BY c.id_cliente, c.cod_vendedor
|
||||
HAVING MAX(p.dt_pedido) IS NULL
|
||||
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
||||
) inativos
|
||||
GROUP BY cod_vendedor
|
||||
GROUP BY inativos.cod_vendedor
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 3
|
||||
`);
|
||||
|
||||
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
|
||||
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
|
||||
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
|
||||
const [repNameRows, cliNameRows] = await Promise.all([
|
||||
repCods.length
|
||||
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
cliIds.length
|
||||
? prisma.$queryRawUnsafe<
|
||||
{ id_cliente: number; nome: string | null; razao: string | null }[]
|
||||
>(
|
||||
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
||||
const cliNameMap = new Map(
|
||||
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
|
||||
);
|
||||
|
||||
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
|
||||
id: o.id,
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
|
||||
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
|
||||
codVendedor: o.codVendedor,
|
||||
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
total: String(o.total),
|
||||
@@ -287,6 +408,7 @@ export class DashboardService {
|
||||
},
|
||||
inativosPorRep: inativosPorRep.map((r) => ({
|
||||
codVendedor: Number(r.cod_vendedor),
|
||||
nomeVendedor: r.nome_vendedor ?? null,
|
||||
inativosCount: parseInt(r.inativos_count, 10),
|
||||
})),
|
||||
syncedAt: now.toISOString(),
|
||||
|
||||
@@ -52,6 +52,11 @@ export class OrdersController {
|
||||
return this.orders.create(parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/transmit')
|
||||
transmit(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
||||
return this.orders.transmit(id);
|
||||
}
|
||||
|
||||
@Patch(':id/approve')
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -13,8 +13,9 @@ import type {
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
// Situa SAR: 1=Ag.Aprovação, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
|
||||
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
|
||||
const SITUA_ORCAMENTO = 0;
|
||||
const SITUA_PENDENTE = 1;
|
||||
const SITUA_APROVADO = 2;
|
||||
const SITUA_CANCELADO = 3;
|
||||
@@ -78,6 +79,7 @@ export class OrdersService {
|
||||
nome_cliente: string | null;
|
||||
razao_cliente: string | null;
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
situa: number;
|
||||
status_descr: string;
|
||||
dt_pedido: Date;
|
||||
@@ -86,25 +88,94 @@ export class OrdersService {
|
||||
obs: string | null;
|
||||
}
|
||||
|
||||
const [rows, countRows] = await Promise.all([
|
||||
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
|
||||
FROM vw_pedidos_erp e
|
||||
LEFT JOIN vw_clientes c ON c.id_cliente = e.id_cliente AND c.id_empresa = e.id_empresa
|
||||
${filters}
|
||||
ORDER BY e.dt_pedido DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`),
|
||||
// Pedidos SAR-nativos (Orçamento/Transmitido) — ainda não estão no ERP.
|
||||
const sarWhere: Prisma.PedidoWhereInput = {
|
||||
idEmpresa,
|
||||
...(role === 'rep' ? { codVendedor } : {}),
|
||||
...(idCliente != null ? { idCliente } : {}),
|
||||
...(situa != null ? { situa } : {}),
|
||||
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
||||
...(from || to
|
||||
? {
|
||||
dtPedido: {
|
||||
...(from ? { gte: new Date(from) } : {}),
|
||||
...(to ? { lte: new Date(to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
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 total = Number(countRows[0]?.count ?? 0);
|
||||
const sarCount = sarPedidos.length;
|
||||
const erpTotal = Number(countRows[0]?.count ?? 0);
|
||||
const total = sarCount + erpTotal;
|
||||
|
||||
const data: PedidoSummary[] = rows.map((o) => ({
|
||||
// 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 [cliNameRows, repNameRows] = await Promise.all([
|
||||
cliIds.length
|
||||
? prisma.$queryRawUnsafe<
|
||||
{ id_cliente: number; nome: string | null; razao: string | null }[]
|
||||
>(
|
||||
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
repCods.length
|
||||
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
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) => ({
|
||||
id: p.id,
|
||||
numPedSar: p.numPedSar,
|
||||
idCliente: p.idCliente,
|
||||
nomeCliente: cliMap.get(p.idCliente)?.nome ?? null,
|
||||
razaoCliente: cliMap.get(p.idCliente)?.razao ?? null,
|
||||
codVendedor: p.codVendedor,
|
||||
nomeVendedor: repMap.get(p.codVendedor) ?? null,
|
||||
situa: p.situa,
|
||||
dtPedido: p.dtPedido.toISOString(),
|
||||
total: decimalToString(p.total),
|
||||
descontoPerc: decimalToString(p.descontoPerc),
|
||||
obs: p.obs,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
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),
|
||||
@@ -112,6 +183,7 @@ export class OrdersService {
|
||||
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,
|
||||
@@ -120,10 +192,10 @@ export class OrdersService {
|
||||
descontoPerc: o.desconto_perc ?? '0',
|
||||
obs: o.obs ?? null,
|
||||
createdAt: new Date(o.dt_pedido).toISOString(),
|
||||
fonte: 'erp',
|
||||
fonte: 'erp' as const,
|
||||
}));
|
||||
|
||||
return { data, total, page, limit };
|
||||
return { data: [...sarData, ...erpData], total, page, limit };
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<PedidoDetail> {
|
||||
@@ -149,7 +221,8 @@ export class OrdersService {
|
||||
return this.mapDetail(o);
|
||||
}
|
||||
|
||||
// Cria novo pedido. Valida alçada por codGrupo (codGrupo=0 = default).
|
||||
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
|
||||
// notificação ao supervisor acontecem no transmit(), não aqui.
|
||||
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
||||
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
@@ -170,15 +243,6 @@ export class OrdersService {
|
||||
if (existing) return this.mapDetail(existing);
|
||||
}
|
||||
|
||||
// Resolve alçadas: (codVendedor, idEmpresa, codGrupo=0) = default
|
||||
const limitRows = await prisma.alcadaDesconto.findMany({
|
||||
where: { codVendedor, idEmpresa },
|
||||
});
|
||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||
const getLimit = (codGrupo: number) => limitMap.get(codGrupo) ?? limitMap.get(0) ?? 5;
|
||||
|
||||
const needsApproval = dto.descontoPerc > getLimit(0);
|
||||
|
||||
const itemsData = dto.itens.map((it) => {
|
||||
const descontoValor =
|
||||
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||
@@ -200,12 +264,11 @@ export class OrdersService {
|
||||
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
|
||||
|
||||
const situa = needsApproval ? SITUA_PENDENTE : SITUA_APROVADO;
|
||||
const situa = SITUA_ORCAMENTO;
|
||||
|
||||
// Gera número sequencial: SAR-NNNNN
|
||||
// Gera número sequencial GLOBAL: SAR-NNNNN (numPedSar é unique entre empresas).
|
||||
const lastOrder = await prisma.pedido.findFirst({
|
||||
where: { idEmpresa },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { numPedSar: 'desc' },
|
||||
select: { numPedSar: true },
|
||||
});
|
||||
const seq = lastOrder ? parseInt(lastOrder.numPedSar.replace('SAR-', ''), 10) + 1 : 1;
|
||||
@@ -246,15 +309,61 @@ export class OrdersService {
|
||||
},
|
||||
});
|
||||
|
||||
if (situa === SITUA_PENDENTE) {
|
||||
void this.notifications.notifySupervisors({
|
||||
title: 'Pedido aguardando aprovação',
|
||||
body: `Pedido ${pedido.numPedSar} — R$ ${pedido.total.toFixed(2).replace('.', ',')}`,
|
||||
url: `/pedidos/${pedido.id}`,
|
||||
});
|
||||
return this.mapDetail(pedido);
|
||||
}
|
||||
|
||||
// Transmite um Orçamento (situa 0) → Transmitido (situa 2).
|
||||
// Alçada de desconto (codGrupo=0 = default) é BLOQUEIO DURO: desconto acima do
|
||||
// máximo do rep barra a transmissão com mensagem — não há fila de aprovação.
|
||||
async transmit(id: string): Promise<PedidoDetail> {
|
||||
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') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
// Rep só transmite o próprio orçamento
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||
throw new BadRequestException(
|
||||
`Pedido não é um orçamento (situa: ${pedido.situa}) — só orçamentos podem ser transmitidos`,
|
||||
);
|
||||
|
||||
// Alçada do rep (codGrupo=0 = default; fallback 5%) — bloqueia se desconto acima.
|
||||
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||
const limiteMax = limitMap.get(0) ?? 5;
|
||||
const desconto = Number(pedido.descontoPerc);
|
||||
if (desconto > limiteMax) {
|
||||
throw new BadRequestException(
|
||||
`Desconto de ${desconto}% acima do máximo permitido para você (${limiteMax}%). Reduza o desconto para transmitir o pedido.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.mapDetail(pedido);
|
||||
const now = new Date();
|
||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_ORCAMENTO,
|
||||
situaNova: SITUA_APROVADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
nota: 'Transmitido',
|
||||
},
|
||||
});
|
||||
|
||||
const final = await prisma.pedido.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||
@@ -364,7 +473,37 @@ export class OrdersService {
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
private mapDetail(o: {
|
||||
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos
|
||||
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
|
||||
private async lookupNames(
|
||||
idCliente: number,
|
||||
codVendedor: number,
|
||||
): Promise<{
|
||||
nomeCliente: string | null;
|
||||
razaoCliente: string | null;
|
||||
nomeVendedor: string | null;
|
||||
}> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
// Cliente e representante são cadastros globais (sem id_empresa).
|
||||
const [cliRows, repRows] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<{ nome: string | null; razao: string | null }[]>(
|
||||
`SELECT nome, razao FROM vw_clientes WHERE id_cliente = ${idCliente} LIMIT 1`,
|
||||
),
|
||||
prisma.$queryRawUnsafe<{ nome: string | null }[]>(
|
||||
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
nomeCliente: cliRows[0]?.nome ?? null,
|
||||
razaoCliente: cliRows[0]?.razao ?? null,
|
||||
nomeVendedor: repRows[0]?.nome ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapDetail(o: {
|
||||
id: string;
|
||||
numPedSar: string;
|
||||
idCliente: number;
|
||||
@@ -406,12 +545,16 @@ export class OrdersService {
|
||||
nota: string | null;
|
||||
changedAt: Date;
|
||||
}[];
|
||||
}): PedidoDetail {
|
||||
}): Promise<PedidoDetail> {
|
||||
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
||||
return {
|
||||
id: o.id,
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
nomeCliente: names.nomeCliente,
|
||||
razaoCliente: names.razaoCliente,
|
||||
codVendedor: o.codVendedor,
|
||||
nomeVendedor: names.nomeVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
total: decimalToString(o.total),
|
||||
|
||||
Reference in New Issue
Block a user