photum/backend/internal/finance/handler.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

356 lines
9.5 KiB
Go

package finance
import (
"fmt"
"net/http"
"photum-backend/internal/db/generated"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgtype"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// Request DTO
type TransactionRequest struct {
FotID *string `json:"fot_id"`
DataCobranca string `json:"data_cobranca"` // YYYY-MM-DD
TipoEvento string `json:"tipo_evento"`
TipoServico string `json:"tipo_servico"`
ProfessionalName string `json:"professional_name"`
Whatsapp string `json:"whatsapp"`
Cpf string `json:"cpf"`
TabelaFree string `json:"tabela_free"`
ValorFree float64 `json:"valor_free"`
ValorExtra float64 `json:"valor_extra"`
DescricaoExtra string `json:"descricao_extra"`
DataPagamento *string `json:"data_pagamento"`
PgtoOk bool `json:"pgto_ok"`
}
// Helper: Parse Date String to pgtype.Date
func parseDate(dateStr string) pgtype.Date {
if dateStr == "" {
return pgtype.Date{Valid: false}
}
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return pgtype.Date{Valid: false}
}
return pgtype.Date{Time: t, Valid: true}
}
// Helper: Float to Numeric
func floatToNumeric(f float64) pgtype.Numeric {
s := fmt.Sprintf("%.2f", f)
var n pgtype.Numeric
n.Scan(s)
return n
}
// Helper: String UUID to pgtype.UUID
func parseUUID(uuidStr *string) pgtype.UUID {
if uuidStr == nil || *uuidStr == "" {
return pgtype.UUID{Valid: false}
}
var u pgtype.UUID
err := u.Scan(*uuidStr)
if err != nil {
return pgtype.UUID{Valid: false}
}
return u
}
// Helper: String to pgtype.Text
func toText(s string) pgtype.Text {
return pgtype.Text{String: s, Valid: s != ""}
}
func (h *Handler) Create(c *gin.Context) {
var req TransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
params := generated.CreateTransactionParams{
FotID: parseUUID(req.FotID),
DataCobranca: parseDate(req.DataCobranca),
TipoEvento: toText(req.TipoEvento),
TipoServico: toText(req.TipoServico),
ProfessionalName: toText(req.ProfessionalName),
Whatsapp: toText(req.Whatsapp),
Cpf: toText(req.Cpf),
TabelaFree: toText(req.TabelaFree),
ValorFree: floatToNumeric(req.ValorFree),
ValorExtra: floatToNumeric(req.ValorExtra),
DescricaoExtra: toText(req.DescricaoExtra),
TotalPagar: floatToNumeric(req.ValorFree + req.ValorExtra),
PgtoOk: pgtype.Bool{Bool: req.PgtoOk, Valid: true},
}
if req.DataPagamento != nil {
params.DataPagamento = parseDate(*req.DataPagamento)
}
regiao := c.GetString("regiao")
txn, err := h.service.Create(c.Request.Context(), params, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, txn)
}
func (h *Handler) Update(c *gin.Context) {
idStr := c.Param("id")
var idUUID pgtype.UUID
if err := idUUID.Scan(idStr); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var req TransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
params := generated.UpdateTransactionParams{
ID: idUUID,
FotID: parseUUID(req.FotID),
DataCobranca: parseDate(req.DataCobranca),
TipoEvento: toText(req.TipoEvento),
TipoServico: toText(req.TipoServico),
ProfessionalName: toText(req.ProfessionalName),
Whatsapp: toText(req.Whatsapp),
Cpf: toText(req.Cpf),
TabelaFree: toText(req.TabelaFree),
ValorFree: floatToNumeric(req.ValorFree),
ValorExtra: floatToNumeric(req.ValorExtra),
DescricaoExtra: toText(req.DescricaoExtra),
TotalPagar: floatToNumeric(req.ValorFree + req.ValorExtra),
PgtoOk: pgtype.Bool{Bool: req.PgtoOk, Valid: true},
}
if req.DataPagamento != nil {
params.DataPagamento = parseDate(*req.DataPagamento)
}
regiao := c.GetString("regiao")
txn, err := h.service.Update(c.Request.Context(), params, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, txn)
}
func (h *Handler) Delete(c *gin.Context) {
idStr := c.Param("id")
var idUUID pgtype.UUID
if err := idUUID.Scan(idStr); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
regiao := c.GetString("regiao")
if err := h.service.Delete(c.Request.Context(), idUUID, regiao); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func (h *Handler) List(c *gin.Context) {
fotIDStr := c.Query("fot_id")
if fotIDStr != "" {
var fotUUID pgtype.UUID
if err := fotUUID.Scan(fotIDStr); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Fot ID"})
return
}
regiao := c.GetString("regiao")
list, err := h.service.ListByFot(c.Request.Context(), fotUUID, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, list)
return
}
// Pagination
page := 1
limit := 50
if p := c.Query("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if l := c.Query("limit"); l != "" {
fmt.Sscanf(l, "%d", &limit)
}
// Filters
fot := c.Query("fot")
data := c.Query("data")
evento := c.Query("evento")
servico := c.Query("servico")
nome := c.Query("nome")
curso := c.Query("curso")
instituicao := c.Query("instituicao")
ano := c.Query("ano")
empresa := c.Query("empresa")
regiao := c.GetString("regiao")
list, count, err := h.service.ListPaginated(c.Request.Context(), int32(page), int32(limit), FilterParams{
Fot: fot,
Data: data,
Evento: evento,
Servico: servico,
Nome: nome,
Curso: curso,
Instituicao: instituicao,
Ano: ano,
Empresa: empresa,
}, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"data": list,
"total": count,
"page": page,
"limit": limit,
})
}
func (h *Handler) AutoFill(c *gin.Context) {
fotNumStr := c.Query("fot")
if fotNumStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "FOT Number required"})
return
}
regiao := c.GetString("regiao")
fotData, err := h.service.AutoFillSearch(c.Request.Context(), fotNumStr, regiao)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "FOT not found"})
return
}
c.JSON(http.StatusOK, fotData)
}
func (h *Handler) GetFotEvents(c *gin.Context) {
fotNumStr := c.Query("fot_id") // Accepts UUID string currently, or we can look up by number if needed.
// User has UUID from AutoFill
var fotUUID pgtype.UUID
if err := fotUUID.Scan(fotNumStr); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid FOT ID"})
return
}
regiao := c.GetString("regiao")
events, err := h.service.ListFotEvents(c.Request.Context(), fotUUID, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, events)
}
func (h *Handler) SearchProfessionals(c *gin.Context) {
query := c.Query("q") // can be empty if function is provided potentially? No, search usually needs text.
functionName := c.Query("function")
// If function is provided, we might want to list all if query is empty?
// For now let's enforce query length if function is missing, or allow empty query if function is present.
if query == "" && functionName == "" {
c.JSON(http.StatusOK, []interface{}{})
return
}
regiao := c.GetString("regiao")
if functionName != "" {
pros, err := h.service.SearchProfessionalsByFunction(c.Request.Context(), query, functionName, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, pros)
return
}
pros, err := h.service.SearchProfessionals(c.Request.Context(), query, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, pros)
}
func (h *Handler) GetPrice(c *gin.Context) {
eventName := c.Query("event")
serviceName := c.Query("service")
if eventName == "" || serviceName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing event or service parameters"})
return
}
regiao := c.GetString("regiao")
price, err := h.service.GetStandardPrice(c.Request.Context(), eventName, serviceName, regiao)
if err != nil {
// likely not found, return 0 or 404
c.JSON(http.StatusOK, gin.H{"valor": 0})
return
}
val, _ := price.Float64Value()
c.JSON(http.StatusOK, gin.H{"valor": val.Float64})
}
func (h *Handler) SearchFot(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusOK, []interface{}{})
return
}
regiao := c.GetString("regiao")
results, err := h.service.SearchFot(c.Request.Context(), query, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, results)
}
func (h *Handler) Import(c *gin.Context) {
var items []ImportFinanceItem
if err := c.ShouldBindJSON(&items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
regiao := c.GetString("regiao")
result, err := h.service.Import(c.Request.Context(), items, regiao)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}