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:
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