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

@@ -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');