photum/backend/internal/cadastro_fot/service.go
NANDO9322 f8bb2e66dd feat: suporte completo multi-região (SP/MG) e melhorias na validação de importação
Detalhes das alterações:

[Banco de Dados]
- Ajuste nas constraints UNIQUE das tabelas de catálogo (cursos, empresas, tipos_eventos, etc.) para incluir a coluna `regiao`, permitindo dados duplicados entre regiões mas únicos por região.
- Correção crítica na constraint da tabela `precos_tipos_eventos` para evitar conflitos de UPSERT (ON CONFLICT) durante a inicialização.
- Implementação de lógica de Seed para a região 'MG':
  - Clonagem automática de catálogos base de 'SP' para 'MG' (Tipos de Evento, Serviços, etc.).
  - Inserção de tabela de preços específica para 'MG' via script de migração.

[Backend - Go]
- Atualização geral dos Handlers e Services para filtrar dados baseados no cabeçalho `x-regiao`.
- Ajuste no Middleware de autenticação para processar e repassar o contexto da região.
- Correção de queries SQL (geradas pelo sqlc) para suportar os novos filtros regionais.

[Frontend - React]
- Implementação do envio global do cabeçalho `x-regiao` nas requisições da API.
- Correção no componente [PriceTableEditor](cci:1://file:///c:/Projetos/photum/frontend/components/System/PriceTableEditor.tsx:26:0-217:2) para carregar e salvar preços respeitando a região selecionada (fix de "Preços zerados" em MG).
- Refatoração profunda na tela de Importação ([ImportData.tsx](cci:7://file:///c:/Projetos/photum/frontend/pages/ImportData.tsx:0:0-0:0)):
  - Adição de feedback visual detalhado para registros ignorados.
  - Categorização explícita de erros: "CPF Inválido", "Região Incompatível", "Linha Vazia/Separador".
  - Correção na lógica de contagem para considerar linhas vazias explicitamente no relatório final, garantindo que o total bata com o Excel.

[Geral]
- Correção de diversos erros de lint e tipagem TSX.
- Padronização de logs de erro no backend para facilitar debug.
2026-02-05 16:18:40 -03:00

283 lines
9.9 KiB
Go

package cadastro_fot
import (
"context"
"errors"
"strconv"
"strings"
"photum-backend/internal/db/generated"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
queries *generated.Queries
}
func NewService(queries *generated.Queries) *Service {
return &Service{queries: queries}
}
type CreateInput struct {
Fot string `json:"fot"`
EmpresaID string `json:"empresa_id"`
CursoID string `json:"curso_id"`
AnoFormaturaID string `json:"ano_formatura_id"`
Instituicao string `json:"instituicao"`
Cidade string `json:"cidade"`
Estado string `json:"estado"`
Observacoes string `json:"observacoes"`
GastosCaptacao float64 `json:"gastos_captacao"`
PreVenda bool `json:"pre_venda"`
}
func (s *Service) Create(ctx context.Context, input CreateInput, regiao string) (*generated.CadastroFot, error) {
empresaUUID, _ := uuid.Parse(input.EmpresaID)
cursoUUID, _ := uuid.Parse(input.CursoID)
anoUUID, _ := uuid.Parse(input.AnoFormaturaID)
res, err := s.queries.CreateCadastroFot(ctx, generated.CreateCadastroFotParams{
Fot: input.Fot,
EmpresaID: pgtype.UUID{Bytes: empresaUUID, Valid: true},
CursoID: pgtype.UUID{Bytes: cursoUUID, Valid: true},
AnoFormaturaID: pgtype.UUID{Bytes: anoUUID, Valid: true},
Instituicao: pgtype.Text{String: input.Instituicao, Valid: true},
Cidade: pgtype.Text{String: input.Cidade, Valid: true},
Estado: pgtype.Text{String: input.Estado, Valid: true},
Observacoes: pgtype.Text{String: input.Observacoes, Valid: true},
GastosCaptacao: toPgNumeric(input.GastosCaptacao),
PreVenda: pgtype.Bool{Bool: input.PreVenda, Valid: true},
})
if err != nil {
return nil, err
}
return &res, nil
}
func (s *Service) List(ctx context.Context, regiao string) ([]generated.ListCadastroFotRow, error) {
return s.queries.ListCadastroFot(ctx, pgtype.Text{String: regiao, Valid: true})
}
func (s *Service) ListByEmpresa(ctx context.Context, empresaID string, regiao string) ([]generated.ListCadastroFotByEmpresaRow, error) {
uuidVal, err := uuid.Parse(empresaID)
if err != nil {
return nil, errors.New("invalid empresa_id")
}
// Note: ListCadastroFotByEmpresaRow is nearly identical to ListCadastroFotRow but we use the generated type
return s.queries.ListCadastroFotByEmpresa(ctx, generated.ListCadastroFotByEmpresaParams{
EmpresaID: pgtype.UUID{Bytes: uuidVal, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
}
func (s *Service) GetByID(ctx context.Context, id string, regiao string) (*generated.GetCadastroFotByIDRow, error) {
uuidVal, err := uuid.Parse(id)
if err != nil {
return nil, errors.New("invalid id")
}
item, err := s.queries.GetCadastroFotByID(ctx, generated.GetCadastroFotByIDParams{
ID: pgtype.UUID{Bytes: uuidVal, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
return &item, err
}
func (s *Service) Update(ctx context.Context, id string, input CreateInput, regiao string) (*generated.CadastroFot, error) {
uuidVal, err := uuid.Parse(id)
if err != nil {
return nil, errors.New("invalid id")
}
empresaUUID, _ := uuid.Parse(input.EmpresaID)
cursoUUID, _ := uuid.Parse(input.CursoID)
anoUUID, _ := uuid.Parse(input.AnoFormaturaID)
item, err := s.queries.UpdateCadastroFot(ctx, generated.UpdateCadastroFotParams{
ID: pgtype.UUID{Bytes: uuidVal, Valid: true},
Fot: input.Fot,
EmpresaID: pgtype.UUID{Bytes: empresaUUID, Valid: true},
CursoID: pgtype.UUID{Bytes: cursoUUID, Valid: true},
AnoFormaturaID: pgtype.UUID{Bytes: anoUUID, Valid: true},
Instituicao: pgtype.Text{String: input.Instituicao, Valid: true},
Cidade: pgtype.Text{String: input.Cidade, Valid: true},
Estado: pgtype.Text{String: input.Estado, Valid: true},
Observacoes: pgtype.Text{String: input.Observacoes, Valid: true},
GastosCaptacao: toPgNumeric(input.GastosCaptacao),
PreVenda: pgtype.Bool{Bool: input.PreVenda, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
return &item, err
}
func (s *Service) Delete(ctx context.Context, id string, regiao string) error {
uuidVal, err := uuid.Parse(id)
if err != nil {
return errors.New("invalid id")
}
return s.queries.DeleteCadastroFot(ctx, generated.DeleteCadastroFotParams{
ID: pgtype.UUID{Bytes: uuidVal, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
}
// Helper to convert float to numeric robustly
func toPgNumeric(f float64) pgtype.Numeric {
var n pgtype.Numeric
s := strconv.FormatFloat(f, 'f', -1, 64)
if err := n.Scan(s); err != nil {
return pgtype.Numeric{Valid: false}
}
return n
}
// ImportInput for Batch Import
type ImportInput struct {
Fot string `json:"fot"`
EmpresaNome string `json:"empresa_nome"`
CursoNome string `json:"curso_nome"`
AnoFormaturaLabel string `json:"ano_formatura_label"`
Instituicao string `json:"instituicao"`
Cidade string `json:"cidade"`
Estado string `json:"estado"`
Observacoes string `json:"observacoes"`
GastosCaptacao float64 `json:"gastos_captacao"`
PreVenda bool `json:"pre_venda"`
}
type ImportResult struct {
SuccessCount int
Errors []string
}
func (s *Service) BatchImport(ctx context.Context, items []ImportInput, regiao string) ImportResult {
result := ImportResult{Errors: []string{}}
for i, item := range items {
// 1. Resolve Empresa
empresa, err := s.queries.GetEmpresaByNome(ctx, generated.GetEmpresaByNomeParams{
Nome: item.EmpresaNome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
var empresaID pgtype.UUID
if err != nil {
// Try Create
newEmp, errCreate := s.queries.CreateEmpresa(ctx, generated.CreateEmpresaParams{
Nome: item.EmpresaNome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if errCreate != nil {
// Retry Get if duplicate
if strings.Contains(errCreate.Error(), "duplicate key") || strings.Contains(errCreate.Error(), "23505") {
empresaRetry, errRetry := s.queries.GetEmpresaByNome(ctx, generated.GetEmpresaByNomeParams{
Nome: item.EmpresaNome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if errRetry != nil {
result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error resolving empresa (retry): "+errRetry.Error())
continue
}
empresaID = empresaRetry.ID
} else {
result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error creating empresa: "+errCreate.Error())
continue
}
} else {
empresaID = newEmp.ID
}
} else {
empresaID = empresa.ID
}
// 2. Resolve Curso
curso, err := s.queries.GetCursoByNome(ctx, generated.GetCursoByNomeParams{
Nome: item.CursoNome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
var cursoID pgtype.UUID
if err != nil {
newCurso, errCreate := s.queries.CreateCurso(ctx, generated.CreateCursoParams{
Nome: item.CursoNome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if errCreate != nil {
if strings.Contains(errCreate.Error(), "duplicate key") || strings.Contains(errCreate.Error(), "23505") {
cursoRetry, errRetry := s.queries.GetCursoByNome(ctx, generated.GetCursoByNomeParams{
Nome: item.CursoNome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if errRetry != nil {
result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error resolving curso (retry): "+errRetry.Error())
continue
}
cursoID = cursoRetry.ID
} else {
result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error creating curso: "+errCreate.Error())
continue
}
} else {
cursoID = newCurso.ID
}
} else {
cursoID = curso.ID
}
// 3. Resolve Ano Formatura
ano, err := s.queries.GetAnoFormaturaByNome(ctx, generated.GetAnoFormaturaByNomeParams{
AnoSemestre: item.AnoFormaturaLabel,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
var anoID pgtype.UUID
if err != nil {
newAno, errCreate := s.queries.CreateAnoFormatura(ctx, generated.CreateAnoFormaturaParams{
AnoSemestre: item.AnoFormaturaLabel,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if errCreate != nil {
if strings.Contains(errCreate.Error(), "duplicate key") || strings.Contains(errCreate.Error(), "23505") {
anoRetry, errRetry := s.queries.GetAnoFormaturaByNome(ctx, generated.GetAnoFormaturaByNomeParams{
AnoSemestre: item.AnoFormaturaLabel,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if errRetry != nil {
result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error resolving ano (retry): "+errRetry.Error())
continue
}
anoID = anoRetry.ID
} else {
result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error creating ano: "+errCreate.Error())
continue
}
} else {
anoID = newAno.ID
}
} else {
anoID = ano.ID
}
// 4. Insert Cadastro FOT
// Check if exists? Or try create and catch duplicate
// Ideally we update if exists? Let's just try Create for now.
_, err = s.queries.CreateCadastroFot(ctx, generated.CreateCadastroFotParams{
Fot: item.Fot,
EmpresaID: empresaID,
CursoID: cursoID,
AnoFormaturaID: anoID,
Instituicao: pgtype.Text{String: item.Instituicao, Valid: true},
Cidade: pgtype.Text{String: item.Cidade, Valid: true},
Estado: pgtype.Text{String: item.Estado, Valid: true},
Observacoes: pgtype.Text{String: item.Observacoes, Valid: true},
GastosCaptacao: toPgNumeric(item.GastosCaptacao),
PreVenda: pgtype.Bool{Bool: item.PreVenda, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
result.Errors = append(result.Errors, "Row "+strconv.Itoa(i+1)+": Error inserting FOT "+item.Fot+": "+err.Error())
} else {
result.SuccessCount++
}
}
return result
}