feat(web+api): tela de cadastro de cliente novo (CNPJ/CPF) com sync ERP

Web (NewClientPage):
- Segmentado PJ/CPF com campos específicos por categoria
- PJ: razão social, nome fantasia, CNPJ, IE, indicador IE, consumidor final
- PF: nome completo, CPF
- Endereço com auto-preenchimento via ViaCEP (CEP → rua/bairro/município)
- Município: Select com busca server-side (/catalog/municipios)
- Contato: DDD, telefone, email
- Comercial: forma pagamento, tabela de preços, limite crédito
- Feedback: código ERP exibido no sucesso; alerta de erro de sync sem bloquear

API:
- GET /catalog/municipios?q= — busca municipios da vw_municipios
- POST /clients/novo — INSERT em sar.clientes_novos; trigger sincroniza para sig.corrent
- Retorna { id, idCorrentErp, sincronizado, erroSync } após o trigger disparar
- Fix trigger fn_sync_cliente_to_erp: NULLIF para cgcpf vazio (evita violação
  do unique index key_corrent_cgcpf quando cliente não tem CPF/CNPJ)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:06:27 +00:00
parent ec0b7c1611
commit 236dd69eba
11 changed files with 638 additions and 9 deletions

View File

@@ -4,6 +4,7 @@ import {
ProdutoListQuerySchema,
type EmpresaInfo,
type FormaPagamento,
type Municipio,
type Pauta,
type ProdutoDetail,
type ProdutoListQuery,
@@ -19,6 +20,11 @@ class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
export class CatalogController {
constructor(private readonly catalog: CatalogService) {}
@Get('municipios')
municipios(@Query('q') q?: string): Promise<Municipio[]> {
return this.catalog.municipios(q);
}
@Get('pautas')
pautas(): Promise<Pauta[]> {
return this.catalog.pautas();

View File

@@ -3,6 +3,7 @@ import { ClsService } from 'nestjs-cls';
import type {
EmpresaInfo,
FormaPagamento,
Municipio,
Pauta,
ProdutoDetail,
ProdutoListQuery,
@@ -122,6 +123,35 @@ export class CatalogService {
};
}
async municipios(q?: string): Promise<Municipio[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
interface Row {
id_municipio: number;
nome: string;
uf: string;
codigo_ibge: number | null;
}
const searchFilter = q ? `WHERE UPPER(nome) LIKE UPPER('%${escSql(q)}%')` : '';
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id_municipio, TRIM(nome) AS nome, uf::text AS uf, codigo_ibge
FROM sar.vw_municipios
${searchFilter}
ORDER BY nome
LIMIT 100
`);
return rows.map((r) => ({
idMunicipio: Number(r.id_municipio),
nome: r.nome,
uf: r.uf,
codigoIbge: r.codigo_ibge !== null ? Number(r.codigo_ibge) : null,
}));
}
async pautas(): Promise<Pauta[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');

View File

@@ -1,14 +1,18 @@
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ClientListQuerySchema,
CreateClientNovoSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientNovoResult,
type CreateClientNovo,
} from '@sar/api-interface';
import { ClientsService } from './clients.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
class CreateClientNovoDto extends createZodDto(CreateClientNovoSchema) {}
@Controller({ path: 'clients' })
export class ClientsController {
@@ -20,6 +24,12 @@ export class ClientsController {
return this.clients.list(parsed);
}
@Post('novo')
createNovo(@Body() body: CreateClientNovoDto): Promise<ClientNovoResult> {
const parsed = CreateClientNovoSchema.parse(body) as CreateClientNovo;
return this.clients.createNovo(parsed);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): Promise<ClientDetail> {
return this.clients.findOne(id);

View File

@@ -1,11 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
ActivityStatus,
ClientDetail,
ClientListQuery,
ClientListResponse,
ClientNovoResult,
ClientSummary,
ActivityStatus,
CreateClientNovo,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -187,6 +189,65 @@ export class ClientsService {
return { data: mapped, total, page, limit };
}
async createNovo(dto: CreateClientNovo): Promise<ClientNovoResult> {
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');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface Row {
id: string;
id_corrent_erp: number | null;
sincronizado: boolean;
erro_sync: string | null;
}
const esc = (s: string) => s.replace(/'/g, "''");
const opt = (v: string | undefined) => (v != null ? `'${esc(v)}'` : 'NULL');
const optN = (v: number | undefined) => (v != null ? String(v) : 'NULL');
// INSERT retorna id — RETURNING lê valores pré-trigger, então
// fazemos SELECT separado para capturar o que o trigger gravou
const insertRows = await prisma.$queryRawUnsafe<[{ id: string }]>(`
INSERT INTO sar.clientes_novos (
id_empresa, cod_vendedor, pesso, consfinal,
nome, razao, cgcpf, suf_cgcpf, inscr, indicador_ie,
endereco, num_endereco, bairro, id_municipio, cep,
ddd, telefone, email, limite_credito, cod_formapag, cod_pauta
) VALUES (
${idEmpresa}, ${codVendedor}, ${dto.pesso}, ${dto.consfinal},
'${esc(dto.nome)}', '${esc(dto.razao)}',
${opt(dto.cgcpf)}, '${esc(dto.sufCgcpf ?? '')}',
'${esc(dto.inscr ?? '')}', ${optN(dto.indicadorIe)},
'${esc(dto.endereco ?? '')}', '${esc(dto.numEndereco ?? '')}',
'${esc(dto.bairro ?? '')}', ${dto.idMunicipio}, '${esc(dto.cep ?? '')}',
'${esc(dto.ddd ?? '')}', '${esc(dto.telefone ?? '')}',
'${esc(dto.email ?? '')}', ${dto.limiteCredito ?? 0},
${optN(dto.codFormapag)}, ${optN(dto.codPauta)}
)
RETURNING id::text
`);
const id = insertRows[0]?.id;
if (!id) throw new Error('Falha ao inserir cliente');
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id::text, id_corrent_erp, sincronizado, erro_sync
FROM sar.clientes_novos WHERE id = '${id}'
`);
const r = rows[0];
if (!r) throw new Error('Falha ao recuperar cliente inserido');
return {
id: r.id,
idCorrentErp: r.id_corrent_erp !== null ? Number(r.id_corrent_erp) : null,
sincronizado: r.sincronizado,
erroSync: r.erro_sync,
};
}
async findOne(idCliente: number): Promise<ClientDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');