photum/backend/internal/finance/service.go
NANDO9322 a51401d9ba feat(finance): overhaul completo do financeiro (Import, Filtros, UI)
- Melhora Importação: ignora linhas vazias/inválidas automaticamente.
- Filtros Server-Side: busca em todas as páginas (FOT, Nome, etc.).
- Colunas Novas: adiciona Curso, Instituição, Ano e Empresa na tabela.
- UI/UX: Corrige ordenação (vazios no fim) e adiciona scrollbar no topo.
2026-02-02 19:16:37 -03:00

371 lines
10 KiB
Go

package finance
import (
"context"
"fmt"
"photum-backend/internal/db/generated"
"strings"
"time"
"photum-backend/internal/profissionais"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
queries *generated.Queries
profService *profissionais.Service
}
func NewService(queries *generated.Queries, profService *profissionais.Service) *Service {
return &Service{queries: queries, profService: profService}
}
func (s *Service) Create(ctx context.Context, params generated.CreateTransactionParams) (generated.FinancialTransaction, error) {
txn, err := s.queries.CreateTransaction(ctx, params)
if err != nil {
return generated.FinancialTransaction{}, err
}
if params.FotID.Valid {
_ = s.updateFotExpenses(ctx, params.FotID)
}
return txn, nil
}
func (s *Service) Update(ctx context.Context, params generated.UpdateTransactionParams) (generated.FinancialTransaction, error) {
txn, err := s.queries.UpdateTransaction(ctx, params)
if err != nil {
return generated.FinancialTransaction{}, err
}
// Recalculate for the new FOT (if changed, we should technically recalc old one too, but simpler for now)
if params.FotID.Valid {
_ = s.updateFotExpenses(ctx, params.FotID)
}
return txn, nil
}
func (s *Service) Delete(ctx context.Context, id pgtype.UUID) error {
// First fetch to get FotID
txn, err := s.queries.GetTransaction(ctx, id)
if err != nil {
return err
}
err = s.queries.DeleteTransaction(ctx, id)
if err != nil {
return err
}
if txn.FotID.Valid {
_ = s.updateFotExpenses(ctx, txn.FotID)
}
return nil
}
func (s *Service) ListByFot(ctx context.Context, fotID pgtype.UUID) ([]generated.FinancialTransaction, error) {
return s.queries.ListTransactionsByFot(ctx, fotID)
}
func (s *Service) ListAll(ctx context.Context) ([]generated.ListTransactionsRow, error) {
return s.queries.ListTransactions(ctx)
}
type FilterParams struct {
Fot string
Data string
Evento string
Servico string
Nome string
}
func (s *Service) ListPaginated(ctx context.Context, page int32, limit int32, filters FilterParams) ([]generated.ListTransactionsPaginatedFilteredRow, int64, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 50
}
offset := (page - 1) * limit
rows, err := s.queries.ListTransactionsPaginatedFiltered(ctx, generated.ListTransactionsPaginatedFilteredParams{
Limit: limit,
Offset: offset,
Fot: filters.Fot,
Data: filters.Data,
Evento: filters.Evento,
Servico: filters.Servico,
Nome: filters.Nome,
})
if err != nil {
return nil, 0, err
}
count, err := s.queries.CountTransactionsFiltered(ctx, generated.CountTransactionsFilteredParams{
Fot: filters.Fot,
Data: filters.Data,
Evento: filters.Evento,
Servico: filters.Servico,
Nome: filters.Nome,
})
if err != nil {
count = 0
}
return rows, count, nil
}
func (s *Service) AutoFillSearch(ctx context.Context, fotNumber string) (generated.GetCadastroFotByFotJoinRow, error) {
return s.queries.GetCadastroFotByFotJoin(ctx, fotNumber)
}
func (s *Service) ListFotEvents(ctx context.Context, fotID pgtype.UUID) ([]generated.ListAgendasByFotRow, error) {
return s.queries.ListAgendasByFot(ctx, fotID)
}
func (s *Service) SearchProfessionals(ctx context.Context, query string) ([]generated.SearchProfissionaisRow, error) {
return s.queries.SearchProfissionais(ctx, pgtype.Text{String: query, Valid: true})
}
func (s *Service) SearchProfessionalsByFunction(ctx context.Context, query string, functionName string) ([]generated.SearchProfissionaisByFunctionRow, error) {
return s.queries.SearchProfissionaisByFunction(ctx, generated.SearchProfissionaisByFunctionParams{
Column1: pgtype.Text{String: query, Valid: true}, // $1 - Name
Nome: functionName, // $2 - Function Name
})
}
func (s *Service) GetStandardPrice(ctx context.Context, eventName string, serviceName string) (pgtype.Numeric, error) {
// serviceName here is the Function Name (e.g. Fotógrafo)
return s.queries.GetStandardPrice(ctx, generated.GetStandardPriceParams{
Nome: eventName, // $1 - Event Name
Nome_2: serviceName, // $2 - Function Name
})
}
func (s *Service) SearchFot(ctx context.Context, query string) ([]generated.SearchFotRow, error) {
return s.queries.SearchFot(ctx, pgtype.Text{String: query, Valid: true})
}
func (s *Service) updateFotExpenses(ctx context.Context, fotID pgtype.UUID) error {
total, err := s.queries.SumTotalByFot(ctx, fotID)
if err != nil {
return err
}
return s.queries.UpdateCadastroFotGastos(ctx, generated.UpdateCadastroFotGastosParams{
ID: fotID,
GastosCaptacao: total,
})
}
// Import Logic
type ImportFinanceItem struct {
FOT string `json:"fot"`
Data string `json:"data"` // YYYY-MM-DD
TipoEvento string `json:"tipo_evento"`
TipoServico string `json:"tipo_servico"`
Nome string `json:"nome"`
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"`
TotalPagar float64 `json:"total_pagar"`
DataPgto string `json:"data_pgto"`
PgtoOK bool `json:"pgto_ok"`
}
type ImportFinanceResult struct {
Created int
Errors []string
}
func (s *Service) Import(ctx context.Context, items []ImportFinanceItem) (ImportFinanceResult, error) {
result := ImportFinanceResult{}
// Fetch all funcoes to map Name -> ID
funcs, err := s.queries.ListFuncoes(ctx)
if err != nil {
return result, fmt.Errorf("failed to list functions: %w", err)
}
funcMap := make(map[string]pgtype.UUID)
var defaultFuncID pgtype.UUID
if len(funcs) > 0 {
defaultFuncID = funcs[0].ID
}
for _, f := range funcs {
funcMap[strings.ToLower(f.Nome)] = f.ID
}
// 1. Bulk Upsert Professionals
profMap := make(map[string]profissionais.CreateProfissionalInput)
for _, item := range items {
if item.CPF == "" {
continue
}
cleanCPF := strings.ReplaceAll(item.CPF, ".", "")
cleanCPF = strings.ReplaceAll(cleanCPF, "-", "")
cleanCPF = strings.TrimSpace(cleanCPF)
if len(cleanCPF) > 20 {
cleanCPF = cleanCPF[:20]
}
if _, exists := profMap[cleanCPF]; !exists {
nm := item.Nome
if len(nm) > 100 {
nm = nm[:100]
}
cpf := cleanCPF
phone := item.Whatsapp
if len(phone) > 20 {
phone = phone[:20]
}
profMap[cleanCPF] = profissionais.CreateProfissionalInput{
Nome: nm,
CpfCnpjTitular: &cpf,
Whatsapp: &phone,
FuncaoProfissionalID: func() string {
if id, ok := funcMap[strings.ToLower(item.TipoServico)]; ok {
if id.Valid {
return fmt.Sprintf("%x", id.Bytes)
}
}
// Mapping heuristics for specific terms if needed
if strings.Contains(strings.ToLower(item.TipoServico), "foto") {
for k, v := range funcMap {
if strings.Contains(k, "foto") && v.Valid {
return fmt.Sprintf("%x", v.Bytes)
}
}
}
if defaultFuncID.Valid {
return fmt.Sprintf("%x", defaultFuncID.Bytes)
}
return ""
}(),
}
}
}
var profInputs []profissionais.CreateProfissionalInput
for _, p := range profMap {
profInputs = append(profInputs, p)
}
if len(profInputs) > 0 {
stats, errs := s.profService.Import(ctx, profInputs)
if len(errs) > 0 {
for _, e := range errs {
result.Errors = append(result.Errors, fmt.Sprintf("Professional Import Error: %v", e))
}
}
fmt.Printf("Professionals Imported: Created=%d, Updated=%d\n", stats.Created, stats.Updated)
}
// 2. Process Transactions
for i, item := range items {
if item.FOT == "" {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Missing FOT", i))
continue
}
fotRow, err := s.queries.GetCadastroFotByFOT(ctx, item.FOT)
var fotID pgtype.UUID
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: FOT %s not found", i, item.FOT))
} else {
fotID = fotRow.ID
}
var profID pgtype.UUID
if item.CPF != "" {
cleanCPF := strings.ReplaceAll(item.CPF, ".", "")
cleanCPF = strings.ReplaceAll(cleanCPF, "-", "")
cleanCPF = strings.TrimSpace(cleanCPF)
if len(cleanCPF) > 20 {
cleanCPF = cleanCPF[:20]
}
prof, err := s.queries.GetProfissionalByCPF(ctx, pgtype.Text{String: cleanCPF, Valid: true})
if err == nil {
profID = prof.ID
}
}
var dataCobranca pgtype.Date
if item.Data != "" {
t, err := time.Parse("2006-01-02", item.Data)
if err == nil {
dataCobranca = pgtype.Date{Time: t, Valid: true}
} else {
t2, err2 := time.Parse("02/01/2006", item.Data)
if err2 == nil {
dataCobranca = pgtype.Date{Time: t2, Valid: true}
}
}
}
var dataPgto pgtype.Date
if item.DataPgto != "" {
t, err := time.Parse("2006-01-02", item.DataPgto)
if err == nil {
dataPgto = pgtype.Date{Time: t, Valid: true}
} else {
t2, err2 := time.Parse("02/01/2006", item.DataPgto)
if err2 == nil {
dataPgto = pgtype.Date{Time: t2, Valid: true}
}
}
}
params := generated.CreateTransactionParams{
FotID: fotID,
DataCobranca: dataCobranca,
TipoEvento: pgtype.Text{String: item.TipoEvento, Valid: item.TipoEvento != ""},
TipoServico: pgtype.Text{String: item.TipoServico, Valid: item.TipoServico != ""},
ProfessionalName: pgtype.Text{String: item.Nome, Valid: item.Nome != ""},
Whatsapp: pgtype.Text{String: limitStr(item.Whatsapp, 50), Valid: item.Whatsapp != ""},
Cpf: pgtype.Text{String: limitStr(item.CPF, 20), Valid: item.CPF != ""},
TabelaFree: pgtype.Text{String: item.TabelaFree, Valid: item.TabelaFree != ""},
ValorFree: toNumeric(item.ValorFree),
ValorExtra: toNumeric(item.ValorExtra),
DescricaoExtra: pgtype.Text{String: item.DescricaoExtra, Valid: item.DescricaoExtra != ""},
TotalPagar: toNumeric(item.TotalPagar),
DataPagamento: dataPgto,
PgtoOk: pgtype.Bool{Bool: item.PgtoOK, Valid: true},
ProfissionalID: profID,
}
_, err = s.Create(ctx, params)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Saved Error %v", i, err))
} else {
result.Created++
}
}
return result, nil
}
func toNumeric(f float64) pgtype.Numeric {
var n pgtype.Numeric
n.Scan(fmt.Sprintf("%.2f", f))
return n
}
func limitStr(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}