diff --git a/apps/api/prisma/migrations/20260722000000_add_regras_desconto/migration.sql b/apps/api/prisma/migrations/20260722000000_add_regras_desconto/migration.sql new file mode 100644 index 0000000..5712813 --- /dev/null +++ b/apps/api/prisma/migrations/20260722000000_add_regras_desconto/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais) +-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou +-- representante, com vigencia. Campos nulos = "todos". +-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL +-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente. +-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui. +CREATE TABLE IF NOT EXISTS sar.regras_desconto ( + id SERIAL PRIMARY KEY, + id_empresa INTEGER NOT NULL, + descricao VARCHAR(200) NOT NULL, + cod_grupo INTEGER, + cod_subgrupo INTEGER, + cod_vendedor INTEGER, + desc_pct NUMERIC(5,2) NOT NULL, + data_inicio DATE NOT NULL, + data_fim DATE NOT NULL, + ativa BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_by VARCHAR(100) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa); +CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a1899e0..8c54bfd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -171,6 +171,30 @@ model Promocao { @@map("promocoes") } +// ─── RegraDesconto (Políticas Comerciais) ──────────────────────────────────── +// +// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou +// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos". + +model RegraDesconto { + id Int @id @default(autoincrement()) + idEmpresa Int @map("id_empresa") + descricao String + codGrupo Int? @map("cod_grupo") + codSubgrupo Int? @map("cod_subgrupo") + codVendedor Int? @map("cod_vendedor") + descPct Decimal @db.Decimal(5, 2) @map("desc_pct") + dataInicio DateTime @db.Date @map("data_inicio") + dataFim DateTime @db.Date @map("data_fim") + ativa Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + createdBy String @map("created_by") + + @@index([idEmpresa]) + @@index([idEmpresa, dataFim]) + @@map("regras_desconto") +} + // ─── Oportunidade (Funil de Vendas) ────────────────────────────────────────── // // Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente) diff --git a/apps/api/src/app/auth/dev-auth.controller.ts b/apps/api/src/app/auth/dev-auth.controller.ts index 685a8cc..5cfa594 100644 --- a/apps/api/src/app/auth/dev-auth.controller.ts +++ b/apps/api/src/app/auth/dev-auth.controller.ts @@ -20,11 +20,13 @@ export class DevAuthController { private readonly secret: Uint8Array; private readonly expiresIn: number; private readonly isProd: boolean; + private readonly devEmpresaId: number; constructor(config: ConfigService) { this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true })); this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true }); this.isProd = config.get('NODE_ENV', { infer: true }) === 'production'; + this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true }); } @Post('token') @@ -33,7 +35,7 @@ export class DevAuthController { if (this.isProd) throw new NotFoundException(); const accessToken = await new SignJWT({ - id_empresa: dto.idEmpresa, + id_empresa: dto.idEmpresa ?? this.devEmpresaId, role: dto.role, }) .setProtectedHeader({ alg: 'HS256' }) diff --git a/apps/api/src/app/auth/jwt-auth.guard.ts b/apps/api/src/app/auth/jwt-auth.guard.ts index 410f07a..9581f99 100644 --- a/apps/api/src/app/auth/jwt-auth.guard.ts +++ b/apps/api/src/app/auth/jwt-auth.guard.ts @@ -51,10 +51,15 @@ export class JwtAuthGuard implements CanActivate { (req as Request & { user: JwtPayload }).user = payload as JwtPayload; - // Em dev: força representante fixo (DEV_REP_CODE / DEV_EMPRESA_ID) ignorando o JWT. - // Em prod: usa os valores reais do JWT. - const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId; - const userId = this.isProd ? payload.sub : this.devRepCode; + // Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token, + // que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE / + // DEV_EMPRESA_ID apenas se o token não trouxer os campos. + // Em prod: usa os valores reais do JWT do master-login. + const idEmpresa = payload.id_empresa ?? (this.isProd ? undefined : this.devEmpresaId); + const userId = payload.sub ?? (this.isProd ? undefined : this.devRepCode); + if (idEmpresa == null || userId == null) { + throw new UnauthorizedException('token sem sub/id_empresa'); + } this.cls.set('idEmpresa', idEmpresa); this.cls.set('userId', userId); this.cls.set('role', payload.role); diff --git a/apps/api/src/app/clients/clients.service.ts b/apps/api/src/app/clients/clients.service.ts index af03018..8cdefe4 100644 --- a/apps/api/src/app/clients/clients.service.ts +++ b/apps/api/src/app/clients/clients.service.ts @@ -18,6 +18,7 @@ import type { 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; @@ -36,6 +37,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 +115,29 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => ` 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'); @@ -119,8 +149,13 @@ export class ClientsService { const { q, status, page, limit } = query; const offset = (page - 1) * limit; - // Rep vê apenas sua carteira (cod_vendedor = seu código) - const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : ''; + // 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)}%')` : ''; @@ -257,6 +292,7 @@ export class ClientsService { } 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'); @@ -316,6 +352,7 @@ export class ClientsService { } 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'); @@ -337,7 +374,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 +407,7 @@ export class ClientsService { } 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'); @@ -430,12 +468,13 @@ export class ClientsService { } 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 = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; + const idEmpresaMatriz = matrizEmpresa(idEmpresa); interface Row { id_produto: number; @@ -493,8 +532,10 @@ export class ClientsService { } 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; @@ -513,6 +554,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 +570,7 @@ export class ClientsService { } 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'); @@ -579,8 +622,10 @@ export class ClientsService { } 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; @@ -602,7 +647,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 +655,7 @@ export class ClientsService { `, idCliente, limit, + idEmpresaMatriz, ); const toDate = (d: Date | string): string => @@ -626,8 +672,12 @@ export class ClientsService { } 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; @@ -653,9 +703,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 +715,8 @@ export class ClientsService { `, idCliente, limit, + idEmpresaMatriz, + idEmpresaFiscal, ); return rows.map((r: NfRow) => ({ diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts index 2be0234..060e0f0 100644 --- a/apps/api/src/app/dashboard/dashboard.controller.ts +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -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, ) {} + // 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 { return this.dashboard.repDashboard(this.cls.get('userId') ?? ''); @@ -18,6 +27,7 @@ export class DashboardController { @Get('supervisor') supervisorDashboard(): Promise { + this.assertGestor(); return this.dashboard.supervisorDashboard(); } @@ -26,6 +36,7 @@ export class DashboardController { @Query('mes') mes?: string, @Query('ano') ano?: string, ): Promise { + this.assertGestor(); return this.dashboard.managerDashboard( mes ? parseInt(mes, 10) : undefined, ano ? parseInt(ano, 10) : undefined, diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 07eb657..fdcf0e3 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -8,6 +8,7 @@ import type { PositivacaoRep, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; +import { getTeamCodes } from '../workspace/team-scope.util'; // Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado // Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado @@ -413,6 +414,10 @@ export class DashboardService { total_clientes: string; clientes_positivados: string; } + interface PositivacaoDiaRow { + dia: string; + clientes: string; + } const [ statsRows, @@ -422,6 +427,7 @@ export class DashboardService { positivacaoRows, metaGrupoRows, realizadoGrupoRows, + positivacaoDiariaRows, ] = await Promise.all([ prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total @@ -518,6 +524,17 @@ export class DashboardService { AND e.dt_pedido <= '${monthEndStr}' GROUP BY p.cod_grupo, grupo `), + prisma.$queryRawUnsafe(` + SELECT dt_pedido::date::text AS dia, + COUNT(DISTINCT id_cliente)::text AS clientes + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} + AND situa NOT IN (1, 5) + AND dt_pedido >= '${monthStartStr}' + AND dt_pedido <= '${monthEndStr}' + GROUP BY dt_pedido::date + ORDER BY dia + `), ]); const pedidosMes = Number(statsRows[0]?.count ?? 0); @@ -590,6 +607,10 @@ export class DashboardService { metasPorGrupo, rankingReps, positivacaoReps, + positivacaoDiaria: positivacaoDiariaRows.map((r) => ({ + dia: r.dia, + clientes: Number(r.clientes), + })), syncedAt: now.toISOString(), }; } @@ -600,9 +621,21 @@ export class DashboardService { const idEmpresa = this.cls.get('idEmpresa'); const now = new Date(); + // Supervisor vê só a própria equipe (cod_supervisor em vw_representantes); + // gerente/admin acessando este painel veem a empresa toda. + const role = this.cls.get('role'); + const userId = this.cls.get('userId'); + const team = + role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null; + const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : ''); + // Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP) const approvalQueue = await prisma.pedido.findMany({ - where: { idEmpresa, situa: SITUA_PENDENTE }, + where: { + idEmpresa, + situa: SITUA_PENDENTE, + ...(team ? { codVendedor: { in: team } } : {}), + }, orderBy: { dtPedido: 'asc' }, take: 50, }); @@ -626,12 +659,14 @@ export class DashboardService { SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}' + ${teamSqlFilter('cod_vendedor')} `), prisma.$queryRawUnsafe(` SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}' + ${teamSqlFilter('cod_vendedor')} `), ]); @@ -651,6 +686,7 @@ export class DashboardService { AND p.id_empresa = ${idEmpresa} AND p.situa != 5 WHERE c.ativo = 1 + ${teamSqlFilter('c.cod_vendedor')} 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)}' diff --git a/apps/api/src/app/notifications/notifications.controller.ts b/apps/api/src/app/notifications/notifications.controller.ts index fd421cf..672077c 100644 --- a/apps/api/src/app/notifications/notifications.controller.ts +++ b/apps/api/src/app/notifications/notifications.controller.ts @@ -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 { - await this.svc.unsubscribe(body.endpoint); + async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise { + await this.svc.unsubscribe(req.user.sub, body.endpoint); } @Get('pending-count') diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts index b0d5e18..14a1b7d 100644 --- a/apps/api/src/app/notifications/notifications.service.ts +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -40,11 +40,15 @@ export class NotificationsService { }); } - async unsubscribe(endpoint: string): Promise { + // 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 { 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 { diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index bde8585..3933459 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -14,6 +14,7 @@ import type { RecusarPedido, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; +import { getTeamCodes } from '../workspace/team-scope.util'; import { NotificationsService } from '../notifications/notifications.service'; // Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado @@ -51,9 +52,13 @@ export class OrdersService { const { idCliente, situa, numPedSar, from, to, page, limit } = query; const offset = (page - 1) * limit; + // Rep vê só os próprios pedidos; supervisor vê os da sua equipe + // (cod_supervisor em vw_representantes); gerente/admin veem tudo. + const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : []; const where: Prisma.PedidoWhereInput = { idEmpresa, ...(role === 'rep' ? { codVendedor } : {}), + ...(role === 'supervisor' ? { codVendedor: { in: team } } : {}), ...(idCliente != null ? { idCliente } : {}), ...(situa != null ? { situa } : {}), ...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}), @@ -127,7 +132,19 @@ export class OrdersService { dataHistorico.setDate(dataHistorico.getDate() - 90); const dataHistoricoStr = dataHistorico.toISOString().split('T')[0]; - const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : ''; + // 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}` + : role === 'supervisor' + ? `AND a.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})` + : ''; const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : ''; const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : ''; const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : ''; @@ -271,6 +288,20 @@ export class OrdersService { return { data, total, page, limit }; } + // Supervisor só age sobre pedidos da própria equipe; gerente/admin passam + // direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes. + private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise { + const role = this.cls.get('role'); + if (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') ?? '0'; + const team = await getTeamCodes(prisma, parseInt(userId, 10)); + if (!team.includes(codVendedorPedido)) { + throw new NotFoundException(`Pedido ${idPedido} não encontrado`); + } + } + async findOne(id: string): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); @@ -279,7 +310,12 @@ export class OrdersService { const userId = this.cls.get('userId'); const codVendedor = userId ? parseInt(userId, 10) : 0; - const repFilter = role === 'rep' ? { codVendedor } : {}; + const repFilter = + role === 'rep' + ? { codVendedor } + : role === 'supervisor' + ? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } } + : {}; const o = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter }, @@ -301,13 +337,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 +448,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 +469,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 +494,146 @@ 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 à maior folga entre promoção ativa + // (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). 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: { + codVendedor: number; + descontoPerc: Prisma.Decimal; + idPauta: number | null; + itens: { + ordem: number; + idProduto: number; + codProduto: string | null; + precoUnitario: Prisma.Decimal; + descontoPerc: Prisma.Decimal; + }[]; + }, + limitMap: Map, + limiteDefault: number, + ): 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 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; + cod_subgrupo: 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(` + SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo, p.cod_subgrupo, + 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 }, + }, + }); + + // Regras de desconto vigentes que alcançam o rep dono do pedido + // (cod_vendedor nulo = todos os reps). + const regras = await prisma.regraDesconto.findMany({ + where: { + idEmpresa: { in: [idEmpresa, idMatriz] }, + ativa: true, + dataInicio: { lte: hoje }, + dataFim: { gte: hoje }, + OR: [{ codVendedor: null }, { codVendedor: pedido.codVendedor }], + }, + }); + + 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); + + // Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou + // subgrupo, o produto precisa casar com o que a regra especifica. + const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null; + const regraPct = regras + .filter( + (r) => + (r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) && + (r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)), + ) + .reduce((max, r) => Math.max(max, Number(r.descPct)), 0); + + const limiteItem = + (codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) + + Math.max(promoPct, regraPct); + + // 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 { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); @@ -453,6 +647,7 @@ export class OrdersService { throw new BadRequestException( `Pedido não está aguardando aprovação (situa: ${pedido.situa})`, ); + await this.assertPedidoDaEquipe(pedido.codVendedor, id); const now = new Date(); const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc); @@ -511,6 +706,7 @@ export class OrdersService { throw new BadRequestException( `Pedido não está aguardando aprovação (situa: ${pedido.situa})`, ); + await this.assertPedidoDaEquipe(pedido.codVendedor, id); const now = new Date(); @@ -754,7 +950,12 @@ export class OrdersService { total: string; } - const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : ''; + const vendedorFilter = + role === 'rep' + ? `AND e.cod_vendedor = ${codVendedor}` + : role === 'supervisor' + ? `AND e.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})` + : ''; const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; const [headerRows, itemRows] = await Promise.all([ diff --git a/apps/api/src/app/politicas/politicas.controller.ts b/apps/api/src/app/politicas/politicas.controller.ts index 8af70fd..3afd980 100644 --- a/apps/api/src/app/politicas/politicas.controller.ts +++ b/apps/api/src/app/politicas/politicas.controller.ts @@ -14,17 +14,27 @@ import { UpsertDescontoBodySchema, CreatePromocaoBodySchema, UpdatePromocaoBodySchema, + CreateRegraDescontoBodySchema, + UpdateRegraDescontoBodySchema, type UpsertDescontoBody, type CreatePromocaoBody, type UpdatePromocaoBody, + type CreateRegraDescontoBody, + type UpdateRegraDescontoBody, type AlcadaDescontosResponse, type PromocoesResponse, + type PromocoesVigentesResponse, + type RegrasDescontoResponse, + type RegrasVigentesResponse, + type GruposProdutoResponse, } from '@sar/api-interface'; import { PoliticasService } from './politicas.service'; class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} +class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {} +class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {} @Controller({ path: 'politicas' }) export class PoliticasController { @@ -46,6 +56,11 @@ export class PoliticasController { return this.politicas.listPromocoes(); } + @Get('promocoes/vigentes') + listPromocoesVigentes(): Promise { + return this.politicas.listPromocoesVigentes(); + } + @Post('promocoes') createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> { return this.politicas.createPromocao(body as unknown as CreatePromocaoBody); @@ -64,4 +79,38 @@ export class PoliticasController { deletePromocao(@Param('id', ParseIntPipe) id: number): Promise { return this.politicas.deletePromocao(id); } + + @Get('regras') + listRegras(): Promise { + return this.politicas.listRegras(); + } + + @Get('regras/vigentes') + listRegrasVigentes(): Promise { + return this.politicas.listRegrasVigentes(); + } + + @Post('regras') + createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> { + return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody); + } + + @Patch('regras/:id') + updateRegra( + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdateRegraDescontoDto, + ): Promise { + return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody); + } + + @Delete('regras/:id') + @HttpCode(204) + deleteRegra(@Param('id', ParseIntPipe) id: number): Promise { + return this.politicas.deleteRegra(id); + } + + @Get('grupos-produto') + listGruposProduto(): Promise { + return this.politicas.listGruposProduto(); + } } diff --git a/apps/api/src/app/politicas/politicas.service.ts b/apps/api/src/app/politicas/politicas.service.ts index 17af67a..3cc487b 100644 --- a/apps/api/src/app/politicas/politicas.service.ts +++ b/apps/api/src/app/politicas/politicas.service.ts @@ -6,6 +6,12 @@ import type { PromocoesResponse, CreatePromocaoBody, UpdatePromocaoBody, + RegrasDescontoResponse, + CreateRegraDescontoBody, + UpdateRegraDescontoBody, + RegrasVigentesResponse, + PromocoesVigentesResponse, + GruposProdutoResponse, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; @@ -158,4 +164,219 @@ export class PoliticasService { await prisma.promocao.delete({ where: { id } }); } + + // ─── Regras de Desconto ───────────────────────────────────────────────────── + + // Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e + // resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código. + private async loadGruposProduto(): Promise< + { + cod_grupo: number | null; + grupo: string | null; + cod_subgrupo: number | null; + subgrupo: string | 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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; + + return prisma.$queryRawUnsafe( + `SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo + FROM vw_produtos + WHERE id_empresa = ${idMatriz} + AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL) + ORDER BY grupo, subgrupo`, + ); + } + + async listGruposProduto(): Promise { + this.assertManager(); + const rows = await this.loadGruposProduto(); + return { + grupos: rows.map((r) => ({ + codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null, + grupo: r.grupo, + codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null, + subgrupo: r.subgrupo, + })), + }; + } + + async listRegras(): Promise { + this.assertManager(); + 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.regraDesconto.findMany({ + where: { idEmpresa }, + orderBy: [{ dataFim: 'desc' }, { id: 'desc' }], + }); + + const codigos = [ + ...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)), + ]; + const repRows = + codigos.length > 0 + ? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>( + `SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`, + ) + : []; + const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome])); + + const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null) + ? await this.loadGruposProduto() + : []; + const grupoNameMap = new Map( + grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]), + ); + const subgrupoNameMap = new Map( + grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), + ); + + return { + regras: rows.map((r) => ({ + id: r.id, + descricao: r.descricao, + codGrupo: r.codGrupo, + grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, + codSubgrupo: r.codSubgrupo, + subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, + codVendedor: r.codVendedor, + nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null, + descPct: Number(r.descPct), + dataInicio: r.dataInicio.toISOString().slice(0, 10), + dataFim: r.dataFim.toISOString().slice(0, 10), + ativa: r.ativa, + createdAt: r.createdAt.toISOString(), + createdBy: r.createdBy, + })), + }; + } + + async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> { + this.assertManager(); + 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') ?? 'system'; + + const r = await prisma.regraDesconto.create({ + data: { + idEmpresa, + descricao: body.descricao, + codGrupo: body.codGrupo ?? null, + codSubgrupo: body.codSubgrupo ?? null, + codVendedor: body.codVendedor ?? null, + descPct: body.descPct, + dataInicio: new Date(body.dataInicio), + dataFim: new Date(body.dataFim), + createdBy: userId, + }, + }); + return { id: r.id }; + } + + async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise { + this.assertManager(); + 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 exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } }); + if (!exists) throw new NotFoundException('Regra de desconto não encontrada'); + + await prisma.regraDesconto.update({ + where: { id }, + data: { + ...(body.descricao !== undefined && { descricao: body.descricao }), + ...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }), + ...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }), + ...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }), + ...(body.descPct !== undefined && { descPct: body.descPct }), + ...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }), + ...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }), + ...(body.ativa !== undefined && { ativa: body.ativa }), + }, + }); + } + + async deleteRegra(id: number): Promise { + this.assertManager(); + 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 exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } }); + if (!exists) throw new NotFoundException('Regra de desconto não encontrada'); + + await prisma.regraDesconto.delete({ where: { id } }); + } + + // Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para + // sinalizar no catálogo que o produto tem condição comercial diferenciada. + async listPromocoesVigentes(): 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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; + + const hoje = new Date(); + const rows = await prisma.promocao.findMany({ + where: { + idEmpresa: { in: [idEmpresa, idMatriz] }, + ativa: true, + dataInicio: { lte: hoje }, + dataFim: { gte: hoje }, + }, + orderBy: [{ descPct: 'desc' }], + }); + + return { + promocoes: rows.map((p) => ({ + id: p.id, + descricao: p.descricao, + codProduto: p.codProduto, + grpProd: p.grpProd, + descPct: Number(p.descPct), + dataFim: p.dataFim.toISOString().slice(0, 10), + })), + }; + } + + // Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o + // alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas. + async listRegrasVigentes(): 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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; + const role = this.cls.get('role') ?? 'rep'; + const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10); + + const hoje = new Date(); + const rows = await prisma.regraDesconto.findMany({ + where: { + idEmpresa: { in: [idEmpresa, idMatriz] }, + ativa: true, + dataInicio: { lte: hoje }, + dataFim: { gte: hoje }, + ...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}), + }, + orderBy: [{ descPct: 'desc' }], + }); + + return { + regras: rows.map((r) => ({ + id: r.id, + descricao: r.descricao, + codGrupo: r.codGrupo, + codSubgrupo: r.codSubgrupo, + descPct: Number(r.descPct), + dataFim: r.dataFim.toISOString().slice(0, 10), + })), + }; + } } diff --git a/apps/api/src/app/reports/reports.service.ts b/apps/api/src/app/reports/reports.service.ts index bda3ce3..e46c9ac 100644 --- a/apps/api/src/app/reports/reports.service.ts +++ b/apps/api/src/app/reports/reports.service.ts @@ -9,6 +9,7 @@ import type { AbcProduto, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; +import { getTeamCodes } from '../workspace/team-scope.util'; function d(v: unknown): string { return v != null ? String(v) : '0'; @@ -77,6 +78,12 @@ export class ReportsService { const userId = this.cls.get('userId'); const codVendedor = userId ? parseInt(userId, 10) : 0; + // Rep vê a própria carteira; supervisor vê as carteiras da equipe + const role = this.cls.get('role'); + const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null; + const vendCond = (col: string) => + team ? `${col} IN (${team.join(',')})` : `${col} = ${codVendedor}`; + interface CarteiraRow { id_cliente: unknown; nome: string | null; @@ -102,7 +109,7 @@ export class ReportsService { COALESCE(SUM(total), 0)::numeric AS faturamento_total FROM vw_pedidos_erp WHERE id_empresa = ${idEmpresa} - AND cod_vendedor = ${codVendedor} + AND ${vendCond('cod_vendedor')} AND situa NOT IN (1, 5) GROUP BY id_cliente ) @@ -118,7 +125,7 @@ export class ReportsService { COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total FROM vw_clientes c LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente - WHERE c.cod_vendedor = ${codVendedor} + WHERE ${vendCond('c.cod_vendedor')} AND c.ativo = 1 ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC `); @@ -176,6 +183,13 @@ export class ReportsService { const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : ''; const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : ''; + // Rep vê os próprios pedidos; supervisor vê os da equipe + const role = this.cls.get('role'); + const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null; + const vendCond = team + ? `p.cod_vendedor IN (${team.join(',')})` + : `p.cod_vendedor = ${codVendedor}`; + interface AbcRow { cod_produto: string | null; desc_produto: string | null; @@ -196,7 +210,7 @@ export class ReportsService { FROM sar.pedido_itens pi JOIN sar.pedidos p ON p.id = pi.id_pedido WHERE p.id_empresa = ${idEmpresa} - AND p.cod_vendedor = ${codVendedor} + AND ${vendCond} AND p.situa NOT IN (0, 3) ${mesFilter} ${anoFilter} diff --git a/apps/api/src/app/sac/sac.controller.ts b/apps/api/src/app/sac/sac.controller.ts index 5333f11..c14ce99 100644 --- a/apps/api/src/app/sac/sac.controller.ts +++ b/apps/api/src/app/sac/sac.controller.ts @@ -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, + ) {} + + // 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)); } } diff --git a/apps/api/src/app/sac/sac.service.ts b/apps/api/src/app/sac/sac.service.ts index de654d7..62be66b 100644 --- a/apps/api/src/app/sac/sac.service.ts +++ b/apps/api/src/app/sac/sac.service.ts @@ -64,10 +64,24 @@ export class SacService { }; } + // Códigos da equipe do supervisor (cod_supervisor em vw_representantes), + // incluindo o próprio. Retorna null para gerente/admin (sem filtro). + private async teamCodes(): Promise { + const role = (this.cls.get('role') as string | undefined) ?? 'rep'; + if (role !== 'supervisor') return null; + const { prisma, codVendedor } = this.ctx(); + const rows = await prisma.$queryRawUnsafe<{ codigo: number }>( + `SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codVendedor}`, + ); + return [...new Set([codVendedor, ...rows.map((r) => Number(r.codigo))])]; + } + async listarEmpresa() { const { prisma, idEmpresa } = this.ctx(); + // Supervisor vê só os chamados da sua equipe; gerente/admin veem a empresa toda + const team = await this.teamCodes(); const all = (await prisma.chamado.findMany({ - where: { idEmpresa }, + where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) }, orderBy: { updatedAt: 'desc' }, })) as { idCliente: number; status: string; [k: string]: unknown }[]; const nomes = await this.enrichNomes(prisma, idEmpresa, all); @@ -90,6 +104,10 @@ export class SacService { })) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null; if (!chamado) throw new NotFoundException('Chamado não encontrado'); if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException(); + if (role === 'empresa') { + const team = await this.teamCodes(); + if (team && !team.includes(chamado.codVendedor)) throw new ForbiddenException(); + } const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]); return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null }; } diff --git a/apps/api/src/app/workspace/team-scope.util.ts b/apps/api/src/app/workspace/team-scope.util.ts new file mode 100644 index 0000000..d1da6f2 --- /dev/null +++ b/apps/api/src/app/workspace/team-scope.util.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client'; + +// Códigos de vendedor da equipe de um supervisor (vw_representantes.cod_supervisor +// aponta para o código do supervisor), incluindo o próprio supervisor. Usado para +// escopar as telas do supervisor à sua equipe — gerente/admin veem a empresa toda. +// codSupervisor vem do JWT (parseInt) — interpolação segura. +export async function getTeamCodes(prisma: PrismaClient, codSupervisor: number): Promise { + const rows = await prisma.$queryRawUnsafe<{ codigo: number }[]>( + `SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codSupervisor}`, + ); + const codes = new Set(rows.map((r) => Number(r.codigo))); + codes.add(codSupervisor); + return [...codes]; +} diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index c21e791..5f00edb 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -18,7 +18,7 @@ const CACHEABLE_API = [ // ── Fetch ────────────────────────────────────────────────────────────────────── -self.addEventListener('fetch', (event) => { +globalThis.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') return; @@ -102,10 +102,10 @@ function offlineResponse() { // ── Push (C6) ───────────────────────────────────────────────────────────────── -self.addEventListener('push', (event) => { +globalThis.addEventListener('push', (event) => { const data = event.data?.json() ?? {}; event.waitUntil( - self.registration.showNotification(data.title ?? 'SAR', { + globalThis.registration.showNotification(data.title ?? 'SAR', { body: data.body ?? '', icon: '/sar-icon.png', badge: '/sar-icon.png', @@ -114,7 +114,7 @@ self.addEventListener('push', (event) => { ); }); -self.addEventListener('notificationclick', (event) => { +globalThis.addEventListener('notificationclick', (event) => { event.notification.close(); const url = event.notification.data?.url; if (url) { diff --git a/apps/web/src/cockpits/ger/GerPainel.tsx b/apps/web/src/cockpits/ger/GerPainel.tsx index 7028356..69ce83b 100644 --- a/apps/web/src/cockpits/ger/GerPainel.tsx +++ b/apps/web/src/cockpits/ger/GerPainel.tsx @@ -5,6 +5,7 @@ import { Col, DatePicker, Flex, + InputNumber, Progress, Row, Skeleton, @@ -23,15 +24,40 @@ import { faAddressCard, faBullseye, faArrowTrendUp, + faCalendarDay, faLayerGroup, } from '@fortawesome/free-solid-svg-icons'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + Tooltip as ChartTooltip, + Legend, +} from 'chart.js'; +import { Chart } from 'react-chartjs-2'; import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; -import type { RankingRep, PositivacaoRep, MetaItem } from '@sar/api-interface'; +import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface'; import { useManagerDashboard } from '../../lib/queries/gerente'; +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + ChartTooltip, + Legend, +); + const { Title, Text } = Typography; +// Meta diária de positivação é preferência local do gerente (não vem do ERP) +const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia'; + function fmt(v: number): string { return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } @@ -185,6 +211,127 @@ const rankingColumns: TableColumnsType = [ }, ]; +function PositivacaoDiariaCard({ + dados, + periodo, + periodoLabel, + isMesAtual, +}: { + dados: PositivacaoDia[]; + periodo: Dayjs; + periodoLabel: string; + isMesAtual: boolean; +}) { + const [meta, setMeta] = useState(() => { + const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY)); + return Number.isFinite(saved) && saved > 0 ? saved : 0; + }); + + function changeMeta(v: number | null) { + const val = v ?? 0; + setMeta(val); + localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val)); + } + + // Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores + const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date(); + const porDia = new Map(dados.map((d) => [dayjs(d.dia).date(), d.clientes])); + const labels = Array.from({ length: ultimoDia }, (_, i) => String(i + 1)); + const valores = labels.map((d) => porDia.get(Number(d)) ?? 0); + const diasComMeta = meta > 0 ? valores.filter((v) => v >= meta).length : 0; + + const chartData = { + labels, + datasets: [ + { + type: 'bar' as const, + label: 'Clientes positivados', + data: valores, + backgroundColor: 'rgba(0, 74, 153, 0.8)', + borderRadius: 4, + order: 2, + }, + ...(meta > 0 + ? [ + { + type: 'line' as const, + label: `Meta (${meta}/dia)`, + data: labels.map(() => meta), + borderColor: '#f5222d', + backgroundColor: 'transparent', + borderWidth: 2, + borderDash: [5, 4], + pointRadius: 0, + order: 1, + }, + ] + : []), + ], + }; + + const options = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index' as const, intersect: false }, + plugins: { + legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } }, + tooltip: { + callbacks: { + title: (items: { label?: string }[]) => `Dia ${items[0]?.label ?? ''}`, + label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => { + const val = ctx.parsed.y; + if (val == null) return ''; + return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR')}`; + }, + }, + }, + }, + scales: { + x: { grid: { display: false } }, + y: { + beginAtZero: true, + ticks: { precision: 0, font: { size: 10 } }, + grid: { color: '#f0f0f0' }, + }, + }, + }; + + return ( + + + Positivação Diária de Clientes — {periodoLabel} + + } + extra={ + + {meta > 0 && ( + + {diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta + + )} + + Meta/dia: + + 0 ? meta : undefined} + onChange={changeMeta} + placeholder="—" + style={{ width: 80 }} + /> + + } + > +
+ +
+
+ ); +} + export function GerPainel() { const [periodo, setPeriodo] = useState(dayjs()); const mes = periodo.month() + 1; @@ -223,6 +370,7 @@ export function GerPainel() { metasPorGrupo, rankingReps, positivacaoReps, + positivacaoDiaria, syncedAt, } = data; @@ -478,6 +626,14 @@ export function GerPainel() { /> + {/* Positivação Diária */} + + {/* Positivação por Representante */} (); + const [modalOpen, setModalOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [msg, msgCtx] = message.useMessage(); + const grupoSelecionado = Form.useWatch('codGrupo', form); + + const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({ + value: r.codVendedor, + label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`, + })); + + const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? []; + const grupoMap = new Map(); + const subgrupoMap = new Map(); + for (const g of grupoRows) { + if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) { + grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` }); + } + if ( + g.codSubgrupo != null && + (grupoSelecionado == null || g.codGrupo === grupoSelecionado) && + !subgrupoMap.has(g.codSubgrupo) + ) { + subgrupoMap.set(g.codSubgrupo, { + value: g.codSubgrupo, + label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`, + }); + } + } + const grupoOptions = [...grupoMap.values()]; + const subgrupoOptions = [...subgrupoMap.values()]; + + function openCreate() { + setEditTarget(null); + form.resetFields(); + setModalOpen(true); + } + + function openEdit(r: RegraDesconto) { + setEditTarget(r); + form.setFieldsValue({ + descricao: r.descricao, + codVendedor: r.codVendedor ?? undefined, + codGrupo: r.codGrupo ?? undefined, + codSubgrupo: r.codSubgrupo ?? undefined, + descPct: r.descPct, + periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)], + }); + setModalOpen(true); + } + + async function handleSubmit(values: RegraForm) { + const [inicio, fim] = values.periodo; + const body = { + descricao: values.descricao, + codVendedor: values.codVendedor ?? null, + codGrupo: values.codGrupo ?? null, + codSubgrupo: values.codSubgrupo ?? null, + descPct: values.descPct, + dataInicio: inicio.format('YYYY-MM-DD'), + dataFim: fim.format('YYYY-MM-DD'), + }; + try { + if (editTarget) { + await updateMutation.mutateAsync({ id: editTarget.id, body }); + void msg.success('Regra atualizada'); + } else { + await createMutation.mutateAsync(body); + void msg.success('Regra criada'); + } + } catch { + return; // erro já notificado pelo handler global do QueryClient; modal fica aberto + } + setModalOpen(false); + } + + async function handleDelete(id: number) { + try { + await deleteMutation.mutateAsync(id); + } catch { + return; // erro já notificado pelo handler global do QueryClient + } + void msg.success('Regra removida'); + } + + const today = dayjs().format('YYYY-MM-DD'); + + const columns: TableColumnsType = [ + { + title: 'Descricao', + dataIndex: 'descricao', + }, + { + title: 'Representante', + width: 180, + render: (_: unknown, row: RegraDesconto) => + row.codVendedor == null ? ( + Todos + ) : ( + {row.nomeVendedor ?? `Cód. ${row.codVendedor}`} + ), + }, + { + title: 'Grupo / Subgrupo', + width: 220, + render: (_: unknown, row: RegraDesconto) => { + if (row.codGrupo == null && row.codSubgrupo == null) return Todos; + const partes = [ + row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null, + row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null, + ].filter(Boolean); + return {partes.join(' / ')}; + }, + }, + { + title: 'Desconto', + dataIndex: 'descPct', + width: 100, + align: 'right', + render: (v: number) => {v}%, + }, + { + title: 'Vigencia', + width: 200, + render: (_: unknown, row: RegraDesconto) => ( + + {dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')} + + ), + }, + { + title: 'Status', + width: 90, + render: (_: unknown, row: RegraDesconto) => { + if (!row.ativa) return Inativa; + if (row.dataFim < today) return Expirada; + if (row.dataInicio > today) return Futura; + return Ativa; + }, + }, + { + title: '', + width: 100, + render: (_: unknown, row: RegraDesconto) => ( + + + + + + + rowKey="id" + columns={columns} + dataSource={data.regras} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }} + /> + + A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica + o desconto automaticamente nos pedidos durante a vigencia. + + + setModalOpen(false)} + onOk={() => void form.validateFields().then(handleSubmit)} + okText={editTarget ? 'Salvar' : 'Criar'} + confirmLoading={createMutation.isPending || updateMutation.isPending} + width={520} + centered + > +
+ + + + + + form.setFieldValue('codSubgrupo', undefined)} + /> + + +