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:
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
63
apps/api/src/app/sac/sac.controller.ts
Normal file
63
apps/api/src/app/sac/sac.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/sac/sac.module.ts
Normal file
9
apps/api/src/app/sac/sac.module.ts
Normal 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 {}
|
||||
177
apps/api/src/app/sac/sac.service.ts
Normal file
177
apps/api/src/app/sac/sac.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user