Files
sar/apps/api/src/app/politicas/politicas.service.ts
julian 76cd90ae95 feat(api,web): politicas comerciais com multi-selecao de produtos, grupos e subgrupos
- Promocao: produtos com busca remota por codigo/descricao (multi) e
  grupos com busca por nome/codigo (multi) - backend cria uma promocao
  por alvo na mesma transacao ({ids})
- Regra de desconto: grupos e subgrupos multi (subgrupos filtram pelos
  grupos escolhidos) - uma regra por alvo
- Edicao continua por linha, com selects de busca no lugar dos inputs
  de texto livre
- Modais maiores (760px de largura, corpo com altura minima e rolagem)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:52:58 +00:00

433 lines
16 KiB
TypeScript

import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
AlcadaDescontosResponse,
UpsertDescontoBody,
PromocoesResponse,
CreatePromocaoBody,
UpdatePromocaoBody,
RegrasDescontoResponse,
CreateRegraDescontoBody,
UpdateRegraDescontoBody,
RegrasVigentesResponse,
PromocoesVigentesResponse,
AlcadaRepResponse,
GruposProdutoResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@Injectable()
export class PoliticasService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
private assertManager(): void {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem gerenciar políticas comerciais');
}
}
async listDescontos(): Promise<AlcadaDescontosResponse> {
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.alcadaDesconto.findMany({
where: { idEmpresa },
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
});
// Busca nomes dos representantes
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
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]));
return {
descontos: rows.map((r) => ({
codVendedor: r.codVendedor,
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
})),
};
}
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
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');
await prisma.alcadaDesconto.upsert({
where: {
codVendedor_idEmpresa_codGrupo: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
},
},
create: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
limitePerc: body.limitePerc,
},
update: { limitePerc: body.limitePerc },
});
}
async listPromocoes(): Promise<PromocoesResponse> {
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.promocao.findMany({
where: { idEmpresa },
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
});
return {
promocoes: rows.map((p) => ({
id: p.id,
descricao: p.descricao,
codProduto: p.codProduto,
grpProd: p.grpProd,
descPct: Number(p.descPct),
dataInicio: p.dataInicio.toISOString().slice(0, 10),
dataFim: p.dataFim.toISOString().slice(0, 10),
ativa: p.ativa,
createdAt: p.createdAt.toISOString(),
createdBy: p.createdBy,
})),
};
}
// Cria uma promoção POR ALVO (produto ou grupo). Sem alvo, cria uma linha
// genérica (comportamento anterior preservado).
async createPromocao(body: CreatePromocaoBody): Promise<{ ids: 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 alvos: { codProduto: string | null; grpProd: string | null }[] = [
...(body.codProdutos ?? []).map((c) => ({ codProduto: c, grpProd: null })),
...(body.grpProds ?? []).map((g) => ({ codProduto: null, grpProd: g })),
];
if (alvos.length === 0) alvos.push({ codProduto: null, grpProd: null });
const ids = await prisma.$transaction(async (tx) => {
const created: number[] = [];
for (const alvo of alvos) {
const p = await tx.promocao.create({
data: {
idEmpresa,
descricao: body.descricao,
codProduto: alvo.codProduto,
grpProd: alvo.grpProd,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
created.push(p.id);
}
return created;
});
return { ids };
}
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
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.promocao.findFirst({ where: { id, idEmpresa } });
if (!exists) throw new NotFoundException('Promoção não encontrada');
await prisma.promocao.update({
where: { id },
data: {
...(body.descricao !== undefined && { descricao: body.descricao }),
...(body.codProduto !== undefined && { codProduto: body.codProduto }),
...(body.grpProd !== undefined && { grpProd: body.grpProd }),
...(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 deletePromocao(id: number): Promise<void> {
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.promocao.findFirst({ where: { id, idEmpresa } });
if (!exists) throw new NotFoundException('Promoção não encontrada');
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<GruposProdutoResponse> {
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<RegrasDescontoResponse> {
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,
})),
};
}
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos.
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: 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 alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })),
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })),
];
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null });
const ids = await prisma.$transaction(async (tx) => {
const created: number[] = [];
for (const alvo of alvos) {
const r = await tx.regraDesconto.create({
data: {
idEmpresa,
descricao: body.descricao,
codGrupo: alvo.codGrupo,
codSubgrupo: alvo.codSubgrupo,
codVendedor: body.codVendedor ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
created.push(r.id);
}
return created;
});
return { ids };
}
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
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<void> {
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 } });
}
// Alçada do próprio usuário logado — o carrinho usa para mostrar o teto de
// desconto por item (codGrupo 0 = default; sem linha 0, o backend assume 5%).
async minhaAlcada(): Promise<AlcadaRepResponse> {
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 = parseInt(this.cls.get('userId') ?? '0', 10);
const rows = await prisma.alcadaDesconto.findMany({
where: { codVendedor, idEmpresa },
});
return {
limites: rows.map((r) => ({
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
})),
};
}
// 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<PromocoesVigentesResponse> {
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<RegrasVigentesResponse> {
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),
})),
};
}
}