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:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user