fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
Auditoria de segurança + robustez da área do representante antes de produção. API (autorização e injeção): - IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos, histórico, contatos) validam carteira do rep via assertClienteDaCarteira; cliente alheio retorna 404. orders.create idem antes de aceitar idCliente. - Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403). - Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação); dtAniversario de contato validado e escapado. - id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded. - Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela pauta>promo>base + promoção ativa), não só o desconto global. - Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono. Web (erros nunca silenciosos): - Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e politicas — corrige criar oportunidade, abrir chamado e editar políticas. - Handler global de erro no QueryClient (query e mutation viram toast). - Error boundary por rota (payload divergente não derruba o app em branco). - Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis. Qualidade: - Corrige ping.contract.spec (idEmpresa) e zera erros de lint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,12 @@ 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;
|
||||
@@ -108,6 +114,25 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
|
||||
export class ClientsService {
|
||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||
|
||||
// PGD-AUTHZ: rep só acessa clientes da própria carteira; gestores 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') 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,
|
||||
);
|
||||
if (!rows[0] || Number(rows[0].cod_vendedor) !== codVendedor) {
|
||||
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');
|
||||
@@ -257,6 +282,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -316,6 +342,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -337,7 +364,7 @@ export class ClientsService {
|
||||
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
||||
'${esc(dto.nome)}',
|
||||
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
||||
${dto.dtAniversario ? `'${dto.dtAniversario}'` : 'NULL'},
|
||||
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
|
||||
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
||||
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
||||
)
|
||||
@@ -370,6 +397,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -430,12 +458,13 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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 = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||
|
||||
interface Row {
|
||||
id_produto: number;
|
||||
@@ -493,8 +522,10 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -513,6 +544,7 @@ export class ClientsService {
|
||||
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
|
||||
`);
|
||||
@@ -528,6 +560,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -579,8 +612,10 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -602,7 +637,7 @@ export class ClientsService {
|
||||
saldo::text AS saldo
|
||||
FROM sar.vw_ctr
|
||||
WHERE id_cliente = $1
|
||||
AND id_empresa = 1
|
||||
AND id_empresa = $3
|
||||
AND situacao = 'A'
|
||||
AND saldo > 0
|
||||
ORDER BY dt_vencimento ASC
|
||||
@@ -610,6 +645,7 @@ export class ClientsService {
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
idEmpresaMatriz,
|
||||
);
|
||||
|
||||
const toDate = (d: Date | string): string =>
|
||||
@@ -626,8 +662,12 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -653,9 +693,9 @@ export class ClientsService {
|
||||
JOIN sar.vw_pedidos_erp p
|
||||
ON p.numero = nf.num_entrega
|
||||
AND p.tipo = 'E'
|
||||
AND p.id_empresa IN (1, 9001)
|
||||
AND p.id_empresa IN ($3, $4)
|
||||
WHERE p.id_cliente = $1
|
||||
AND nf.id_empresa = 1
|
||||
AND nf.id_empresa = $3
|
||||
AND nf.status = 'E'
|
||||
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||
ORDER BY nf.id_nf
|
||||
@@ -665,6 +705,8 @@ export class ClientsService {
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
idEmpresaMatriz,
|
||||
idEmpresaFiscal,
|
||||
);
|
||||
|
||||
return rows.map((r: NfRow) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
@@ -11,6 +11,15 @@ export class DashboardController {
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
) {}
|
||||
|
||||
// PGD-AUTHZ: dashboards gerenciais agregam dados da empresa toda (faturamento,
|
||||
// ranking de todos os reps) — rep não pode acessá-los.
|
||||
private assertGestor(): void {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') {
|
||||
throw new ForbiddenException('Apenas supervisores e gerentes podem acessar este painel');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('rep')
|
||||
repDashboard(): Promise<RepDashboard> {
|
||||
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
||||
@@ -18,6 +27,7 @@ export class DashboardController {
|
||||
|
||||
@Get('supervisor')
|
||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
this.assertGestor();
|
||||
return this.dashboard.supervisorDashboard();
|
||||
}
|
||||
|
||||
@@ -26,6 +36,7 @@ export class DashboardController {
|
||||
@Query('mes') mes?: string,
|
||||
@Query('ano') ano?: string,
|
||||
): Promise<ManagerDashboard> {
|
||||
this.assertGestor();
|
||||
return this.dashboard.managerDashboard(
|
||||
mes ? parseInt(mes, 10) : undefined,
|
||||
ano ? parseInt(ano, 10) : undefined,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface';
|
||||
import {
|
||||
SubscribePayloadSchema,
|
||||
UnsubscribePayloadSchema,
|
||||
type SubscribePayload,
|
||||
} from '@sar/api-interface';
|
||||
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
||||
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
|
||||
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
@@ -18,8 +23,8 @@ export class NotificationsController {
|
||||
|
||||
@Delete('unsubscribe')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
|
||||
await this.svc.unsubscribe(body.endpoint);
|
||||
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
|
||||
await this.svc.unsubscribe(req.user.sub, body.endpoint);
|
||||
}
|
||||
|
||||
@Get('pending-count')
|
||||
|
||||
@@ -40,11 +40,15 @@ export class NotificationsService {
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(endpoint: string): Promise<void> {
|
||||
// Escopado ao dono: só remove subscription do próprio usuário — quem souber o
|
||||
// endpoint de terceiro não consegue desregistrá-lo.
|
||||
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||
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 codVendedor = userId ? parseInt(userId, 10) : null;
|
||||
|
||||
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
|
||||
await prisma.pushSubscription.deleteMany({ where: { endpoint, idEmpresa, codVendedor } });
|
||||
}
|
||||
|
||||
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
||||
|
||||
@@ -127,6 +127,13 @@ export class OrdersService {
|
||||
dataHistorico.setDate(dataHistorico.getDate() - 90);
|
||||
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
|
||||
|
||||
// Defesa em profundidade: o contrato já exige YYYY-MM-DD, mas como as datas
|
||||
// são interpoladas no SQL abaixo, revalidamos antes de montar a query.
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if ((from && !DATE_RE.test(from)) || (to && !DATE_RE.test(to))) {
|
||||
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
|
||||
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
|
||||
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
|
||||
@@ -301,13 +308,26 @@ export class OrdersService {
|
||||
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);
|
||||
|
||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
||||
// PGD-AUTHZ: rep só lança pedido para cliente da própria carteira.
|
||||
if (role === 'rep') {
|
||||
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||
dto.idCliente,
|
||||
);
|
||||
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency-Key: retorna pedido existente sem re-processar.
|
||||
// Escopado a empresa + vendedor — chave de terceiro não vaza pedido alheio.
|
||||
if (dto.idempotencyKey) {
|
||||
const existing = await prisma.pedido.findUnique({
|
||||
where: { idempotencyKey: dto.idempotencyKey },
|
||||
const existing = await prisma.pedido.findFirst({
|
||||
where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
@@ -399,7 +419,10 @@ export class OrdersService {
|
||||
|
||||
// Rep só transmite o próprio orçamento
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||
const pedido = await prisma.pedido.findFirst({
|
||||
where: { id, idEmpresa, ...repFilter },
|
||||
include: { itens: { orderBy: { ordem: 'asc' } } },
|
||||
});
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||
throw new BadRequestException(
|
||||
@@ -417,6 +440,8 @@ export class OrdersService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
|
||||
|
||||
const now = new Date();
|
||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||
await prisma.historicoPedido.create({
|
||||
@@ -440,6 +465,119 @@ export class OrdersService {
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
||||
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
|
||||
// global — deve caber no limite do grupo do produto (alcada_desconto por
|
||||
// codGrupo; 0 = default), somado a promoção ativa criada pelo gerente para o
|
||||
// produto/grupo. Cobre o desconto lançado no item E desconto disfarçado de
|
||||
// preço unitário reduzido. Preço de tabela: pauta do pedido > promocional >
|
||||
// preço base; produto sem referência valida só pelo campo de desconto.
|
||||
private async assertAlcadaItens(
|
||||
pedido: {
|
||||
descontoPerc: Prisma.Decimal;
|
||||
idPauta: number | null;
|
||||
itens: {
|
||||
ordem: number;
|
||||
idProduto: number;
|
||||
codProduto: string | null;
|
||||
precoUnitario: Prisma.Decimal;
|
||||
descontoPerc: Prisma.Decimal;
|
||||
}[];
|
||||
},
|
||||
limitMap: Map<number, number>,
|
||||
limiteDefault: number,
|
||||
): Promise<void> {
|
||||
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
if (pedido.itens.length === 0) return;
|
||||
const prodIds = [...new Set(pedido.itens.map((i) => i.idProduto))];
|
||||
|
||||
interface ProdRow {
|
||||
id_erp: number;
|
||||
codigo: string | null;
|
||||
cod_grupo: number | null;
|
||||
vl_preco1: string | null;
|
||||
preco_promocional: string | null;
|
||||
preco_pauta: string | null;
|
||||
}
|
||||
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
|
||||
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
|
||||
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo,
|
||||
p.vl_preco1::text, p.preco_promocional::text,
|
||||
${
|
||||
pedido.idPauta != null
|
||||
? `(SELECT pp.preco1::text FROM vw_pauta_produtos pp
|
||||
WHERE pp.id_pauta = ${Number(pedido.idPauta)}
|
||||
AND pp.id_produto = p.id_erp LIMIT 1)`
|
||||
: 'NULL'
|
||||
} AS preco_pauta
|
||||
FROM vw_produtos p
|
||||
WHERE p.id_empresa = ${idMatriz}
|
||||
AND p.id_erp IN (${prodIds.join(',')})
|
||||
`);
|
||||
const prodMap = new Map(prodRows.map((p) => [Number(p.id_erp), p]));
|
||||
|
||||
const hoje = new Date();
|
||||
const promos = await prisma.promocao.findMany({
|
||||
where: {
|
||||
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||
ativa: true,
|
||||
dataInicio: { lte: hoje },
|
||||
dataFim: { gte: hoje },
|
||||
},
|
||||
});
|
||||
|
||||
const descGlobal = Number(pedido.descontoPerc);
|
||||
const problemas: string[] = [];
|
||||
|
||||
for (const it of pedido.itens) {
|
||||
const prod = prodMap.get(it.idProduto);
|
||||
const codGrupo = prod?.cod_grupo != null ? Number(prod.cod_grupo) : null;
|
||||
const codigo = prod?.codigo?.trim() || it.codProduto || String(it.idProduto);
|
||||
|
||||
const promoPct = promos
|
||||
.filter(
|
||||
(p) =>
|
||||
(p.codProduto && prod?.codigo && p.codProduto.trim() === prod.codigo.trim()) ||
|
||||
(p.grpProd && codGrupo != null && p.grpProd.trim() === String(codGrupo)),
|
||||
)
|
||||
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||
|
||||
const limiteItem =
|
||||
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) + promoPct;
|
||||
|
||||
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
||||
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
||||
const precoBase = precoPauta > 0 ? precoPauta : Number(prod?.vl_preco1 ?? 0);
|
||||
const precoPromo = Number(prod?.preco_promocional ?? 0);
|
||||
const tabela = precoPromo > 0 && precoPromo < precoBase ? precoPromo : precoBase;
|
||||
|
||||
const descItem = Number(it.descontoPerc);
|
||||
const precoLiquido = Number(it.precoUnitario) * (1 - descItem / 100) * (1 - descGlobal / 100);
|
||||
|
||||
// Desconto efetivo: vs tabela quando há referência; senão só os percentuais
|
||||
const descEfetivo =
|
||||
tabela > 0
|
||||
? (1 - precoLiquido / tabela) * 100
|
||||
: (1 - (1 - descItem / 100) * (1 - descGlobal / 100)) * 100;
|
||||
|
||||
if (descEfetivo > limiteItem + 0.01) {
|
||||
problemas.push(
|
||||
`item ${it.ordem} (${codigo}) com desconto efetivo de ${descEfetivo.toFixed(1)}% — máximo permitido ${limiteItem}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (problemas.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Controller, Get, Post, Patch, Param, ParseIntPipe, Body } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { SacService } from './sac.service';
|
||||
|
||||
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
||||
@@ -39,25 +50,42 @@ export class SacRepController {
|
||||
|
||||
@Controller('ger/chamados')
|
||||
export class SacGerController {
|
||||
constructor(private readonly sac: SacService) {}
|
||||
constructor(
|
||||
private readonly sac: SacService,
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
) {}
|
||||
|
||||
// PGD-AUTHZ: visão da empresa toda (detalhe sem checagem de dono) — só gestores.
|
||||
private assertGestor(): void {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') {
|
||||
throw new ForbiddenException(
|
||||
'Apenas supervisores e gerentes podem acessar os chamados da empresa',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
listar() {
|
||||
this.assertGestor();
|
||||
return this.sac.listarEmpresa();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||
this.assertGestor();
|
||||
return this.sac.detalhe(id, 'empresa');
|
||||
}
|
||||
|
||||
@Post(':id/mensagens')
|
||||
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||
this.assertGestor();
|
||||
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
||||
}
|
||||
|
||||
@Patch(':id/resolver')
|
||||
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
||||
this.assertGestor();
|
||||
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user