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/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/web/src/cockpits/ger/PoliticasPage.tsx b/apps/web/src/cockpits/ger/PoliticasPage.tsx index 5e7cfbe..064c51f 100644 --- a/apps/web/src/cockpits/ger/PoliticasPage.tsx +++ b/apps/web/src/cockpits/ger/PoliticasPage.tsx @@ -8,6 +8,7 @@ import { InputNumber, Modal, Popconfirm, + Select, Skeleton, Space, Table, @@ -22,7 +23,13 @@ import type { TableColumnsType } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs'; -import type { AlcadaDescontoItem, Promocao } from '@sar/api-interface'; +import type { + AlcadaDescontoItem, + Promocao, + RegraDesconto, + RepStats, + GrupoProdutoItem, +} from '@sar/api-interface'; import { usePoliticasDescontos, usePromocoes, @@ -30,6 +37,12 @@ import { useCreatePromocao, useUpdatePromocao, useDeletePromocao, + useEquipe, + useRegrasDesconto, + useGruposProduto, + useCreateRegraDesconto, + useUpdateRegraDesconto, + useDeleteRegraDesconto, } from '../../lib/queries/gerente'; const { Title, Text } = Typography; @@ -370,6 +383,294 @@ function TabPromocoes() { ); } +// ─── Tab Regras de Desconto ─────────────────────────────────────────────────── + +interface RegraForm { + descricao: string; + codVendedor?: number; + codGrupo?: number; + codSubgrupo?: number; + descPct: number; + periodo: [dayjs.Dayjs, dayjs.Dayjs]; +} + +function TabRegras() { + const { data, isLoading } = useRegrasDesconto(); + const { data: equipe } = useEquipe(); + const { data: gruposData } = useGruposProduto(); + const createMutation = useCreateRegraDesconto(); + const updateMutation = useUpdateRegraDesconto(); + const deleteMutation = useDeleteRegraDesconto(); + const [form] = Form.useForm(); + 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)} + /> + + +