feat(api,web): SAC de chamados sobre pedidos

Representante abre chamado a partir de um pedido e conversa com a empresa
por thread de mensagens; o gerente responde e resolve.

- API: `SacModule` com 5 rotas do rep (listar, detalhe, criar, responder,
  cancelar) e 4 da empresa (/ger/chamados: listar, detalhe, responder,
  resolver). Escopo do rep e por cod_vendedor; o da empresa, por id_empresa.
- Modelos `Chamado` + `ChamadoMensagem` e migrations correspondentes.
- O chamado pode referenciar tanto um pedido nascido no SAR (id_pedido +
  num_ped_sar) quanto um pedido historico do ERP (num_ped_erp) — dai
  id_pedido e num_ped_sar serem nullable. `nome_cliente` fica
  desnormalizado para evitar join em toda listagem.
- Web: `ChamadosPage` (rep), `GerChamadosPage` (gerente) e
  `AbrirChamadoModal`, acessivel tambem pelo detalhe do pedido. Entradas
  "SAC" no menu do rep e do gerente.
- `useOrderErpConsulta` ganha flag `enabled` para a busca de pedido ERP so
  disparar quando o modal esta aberto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 18:16:16 +00:00
parent 264305386d
commit 2649bc9e94
17 changed files with 1553 additions and 6 deletions

View File

@@ -0,0 +1,33 @@
-- CreateTable chamados
CREATE TABLE IF NOT EXISTS sar.chamados (
id SERIAL PRIMARY KEY,
id_empresa INTEGER NOT NULL,
cod_vendedor INTEGER NOT NULL,
id_pedido UUID NOT NULL,
num_ped_sar INTEGER NOT NULL,
id_cliente INTEGER NOT NULL,
tipo VARCHAR(50) NOT NULL,
assunto VARCHAR(200) NOT NULL,
descricao TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'aberto',
resolucao VARCHAR(50),
nota_resolucao TEXT,
itens_afetados JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_status ON sar.chamados(id_empresa, status);
CREATE INDEX IF NOT EXISTS idx_chamados_pedido ON sar.chamados(id_pedido);
-- CreateTable chamado_mensagens
CREATE TABLE IF NOT EXISTS sar.chamado_mensagens (
id SERIAL PRIMARY KEY,
id_chamado INTEGER NOT NULL REFERENCES sar.chamados(id) ON DELETE CASCADE,
autor VARCHAR(10) NOT NULL,
texto TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado);

View File

@@ -0,0 +1,12 @@
-- Torna id_pedido e num_ped_sar opcionais (chamados podem referenciar pedidos ERP)
ALTER TABLE sar.chamados ALTER COLUMN id_pedido DROP NOT NULL;
ALTER TABLE sar.chamados ALTER COLUMN num_ped_sar DROP NOT NULL;
-- Referência ao pedido ERP (numero inteiro do SIG)
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS num_ped_erp INTEGER;
-- Nome do cliente desnormalizado (evita join em toda consulta)
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS nome_cliente VARCHAR(200);
-- Remove index por id_pedido (agora nullable, menos útil como index único)
DROP INDEX IF EXISTS sar.idx_chamados_pedido;

View File

@@ -197,6 +197,50 @@ model Oportunidade {
@@map("oportunidades")
}
// ─── Chamados SAC ────────────────────────────────────────────────────────────
//
// Chamado aberto pelo representante vinculado a um pedido SAR.
// tipo: avaria | quantidade_divergente | produto_errado | prazo_entrega | outro
// status: aberto | em_analise | aguardando_rep | resolvido | cancelado
// resolucao: reposicao | desconto | cancelamento_item | sem_acao | outro
model Chamado {
id Int @id @default(autoincrement())
idEmpresa Int @map("id_empresa")
codVendedor Int @map("cod_vendedor")
idPedido String? @db.Uuid @map("id_pedido")
numPedSar Int? @map("num_ped_sar")
numPedErp Int? @map("num_ped_erp")
idCliente Int @map("id_cliente")
nomeCliente String? @db.VarChar(200) @map("nome_cliente")
tipo String @db.VarChar(50)
assunto String @db.VarChar(200)
descricao String
status String @default("aberto") @db.VarChar(30)
resolucao String? @db.VarChar(50)
notaResolucao String? @map("nota_resolucao")
itensAfetados Json? @map("itens_afetados")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
mensagens ChamadoMensagem[]
@@index([idEmpresa, codVendedor])
@@index([idEmpresa, status])
@@map("chamados")
}
model ChamadoMensagem {
id Int @id @default(autoincrement())
idChamado Int @map("id_chamado")
autor String @db.VarChar(10)
texto String
createdAt DateTime @default(now()) @map("created_at")
chamado Chamado @relation(fields: [idChamado], references: [id], onDelete: Cascade)
@@index([idChamado])
@@map("chamado_mensagens")
}
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
//
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.

View File

@@ -17,6 +17,7 @@ import { ReportsModule } from './reports/reports.module';
import { PoliticasModule } from './politicas/politicas.module';
import { EquipeModule } from './equipe/equipe.module';
import { FunilModule } from './funil/funil.module';
import { SacModule } from './sac/sac.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -37,6 +38,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
PoliticasModule,
EquipeModule,
FunilModule,
SacModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -0,0 +1,63 @@
import { Controller, Get, Post, Patch, Param, ParseIntPipe, Body } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
import { SacService } from './sac.service';
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
class AddMensagemDto extends createZodDto(AddMensagemSchema) {}
class ResolverDto extends createZodDto(ResolverChamadoSchema) {}
@Controller('chamados')
export class SacRepController {
constructor(private readonly sac: SacService) {}
@Get()
listar() {
return this.sac.listarRep();
}
@Get(':id')
detalhe(@Param('id', ParseIntPipe) id: number) {
return this.sac.detalhe(id, 'rep');
}
@Post()
criar(@Body() dto: CreateChamadoDto) {
return this.sac.criar(CreateChamadoSchema.parse(dto));
}
@Post(':id/mensagens')
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
return this.sac.addMensagemRep(id, AddMensagemSchema.parse(dto));
}
@Patch(':id/cancelar')
cancelar(@Param('id', ParseIntPipe) id: number) {
return this.sac.cancelarRep(id);
}
}
@Controller('ger/chamados')
export class SacGerController {
constructor(private readonly sac: SacService) {}
@Get()
listar() {
return this.sac.listarEmpresa();
}
@Get(':id')
detalhe(@Param('id', ParseIntPipe) id: number) {
return this.sac.detalhe(id, 'empresa');
}
@Post(':id/mensagens')
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
}
@Patch(':id/resolver')
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SacService } from './sac.service';
import { SacRepController, SacGerController } from './sac.controller';
@Module({
controllers: [SacRepController, SacGerController],
providers: [SacService],
})
export class SacModule {}

View File

@@ -0,0 +1,177 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { CreateChamadoDto, AddMensagemDto, ResolverChamadoDto } from '@sar/api-interface';
type PrismaCtx = {
chamado: {
findMany: (args: object) => Promise<unknown[]>;
findFirst: (args: object) => Promise<unknown | null>;
findUnique: (args: object) => Promise<unknown | null>;
create: (args: object) => Promise<unknown>;
update: (args: object) => Promise<unknown>;
};
chamadoMensagem: { create: (args: object) => Promise<unknown> };
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
};
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
@Injectable()
export class SacService {
constructor(private readonly cls: ClsService) {}
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
const prisma = this.cls.get('prisma') as PrismaCtx;
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa') as number;
const userId = this.cls.get('userId') as string | undefined;
const codVendedor = userId ? parseInt(userId, 10) : 0;
return { prisma, idEmpresa, codVendedor };
}
private async enrichNomes(
prisma: PrismaCtx,
idEmpresa: number,
rows: { idCliente: number }[],
): Promise<Map<number, string>> {
const ids = [...new Set(rows.map((r) => r.idCliente))];
if (!ids.length) return new Map();
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids.join(',')}) AND id_empresa = ${idEmpresa}`,
);
const map = new Map<number, string>();
for (const c of clientes) {
map.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
}
return map;
}
async listarRep() {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const all = (await prisma.chamado.findMany({
where: { idEmpresa, codVendedor },
orderBy: { updatedAt: 'desc' },
})) as { idCliente: number; status: string; [k: string]: unknown }[];
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
const enrich = (row: (typeof all)[0]) => ({
...row,
nomeCliente:
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
});
return {
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
};
}
async listarEmpresa() {
const { prisma, idEmpresa } = this.ctx();
const all = (await prisma.chamado.findMany({
where: { idEmpresa },
orderBy: { updatedAt: 'desc' },
})) as { idCliente: number; status: string; [k: string]: unknown }[];
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
const enrich = (row: (typeof all)[0]) => ({
...row,
nomeCliente:
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
});
return {
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
};
}
async detalhe(id: number, role: 'rep' | 'empresa') {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const chamado = (await prisma.chamado.findFirst({
where: { id, idEmpresa },
include: { mensagens: { orderBy: { createdAt: 'asc' } } },
})) 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();
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
}
async criar(dto: CreateChamadoDto) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const chamado = (await prisma.chamado.create({
data: {
idEmpresa,
codVendedor,
idPedido: dto.idPedido ?? null,
numPedSar: dto.numPedSar ?? null,
numPedErp: dto.numPedErp ?? null,
nomeCliente: dto.nomeCliente ?? null,
idCliente: dto.idCliente,
tipo: dto.tipo,
assunto: dto.assunto,
descricao: dto.descricao,
itensAfetados: dto.itensAfetados ?? [],
},
include: { mensagens: true },
})) as { idCliente: number; nomeCliente: string | null; [k: string]: unknown };
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
return { ...chamado, nomeCliente: chamado.nomeCliente ?? nomes.get(chamado.idCliente) ?? null };
}
async addMensagemRep(id: number, dto: AddMensagemDto) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
codVendedor: number;
} | null;
if (!c) throw new NotFoundException();
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'rep', texto: dto.texto },
});
await prisma.chamado.update({ where: { id }, data: { status: 'em_analise' } });
return this.detalhe(id, 'rep');
}
async addMensagemEmpresa(id: number, dto: AddMensagemDto) {
const { prisma, idEmpresa } = this.ctx();
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
if (!c) throw new NotFoundException();
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'empresa', texto: dto.texto },
});
await prisma.chamado.update({ where: { id }, data: { status: 'aguardando_rep' } });
return this.detalhe(id, 'empresa');
}
async resolver(id: number, dto: ResolverChamadoDto) {
const { prisma, idEmpresa } = this.ctx();
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
if (!c) throw new NotFoundException();
if (dto.mensagemFinal) {
await prisma.chamadoMensagem.create({
data: { idChamado: id, autor: 'empresa', texto: dto.mensagemFinal },
});
}
await prisma.chamado.update({
where: { id },
data: {
status: 'resolvido',
resolucao: dto.resolucao,
notaResolucao: dto.notaResolucao ?? null,
},
});
return this.detalhe(id, 'empresa');
}
async cancelarRep(id: number) {
const { prisma, idEmpresa, codVendedor } = this.ctx();
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
codVendedor: number;
status: string;
} | null;
if (!c) throw new NotFoundException();
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
if (['resolvido', 'cancelado'].includes(c.status))
throw new ForbiddenException('Chamado já encerrado');
await prisma.chamado.update({ where: { id }, data: { status: 'cancelado' } });
return this.detalhe(id, 'rep');
}
}