photum/backend/internal/agenda/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

1023 lines
37 KiB
Go

package agenda
import (
"context"
"encoding/json"
"fmt"
"log"
"sort"
"strconv"
"strings"
"time"
"photum-backend/internal/config"
"photum-backend/internal/db/generated"
"photum-backend/internal/notification"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
queries *generated.Queries
notification *notification.Service
cfg *config.Config
}
func NewService(db *generated.Queries, notif *notification.Service, cfg *config.Config) *Service {
return &Service{
queries: db,
notification: notif,
cfg: cfg,
}
}
type CreateAgendaRequest struct {
FotID uuid.UUID `json:"fot_id"`
DataEvento time.Time `json:"data_evento"`
TipoEventoID uuid.UUID `json:"tipo_evento_id"`
ObservacoesEvento string `json:"observacoes_evento"`
LocalEvento string `json:"local_evento"`
Endereco string `json:"endereco"`
Horario string `json:"horario"`
QtdFormandos int32 `json:"qtd_formandos"`
QtdFotografos int32 `json:"qtd_fotografos"`
QtdRecepcionistas int32 `json:"qtd_recepcionistas"`
QtdCinegrafistas int32 `json:"qtd_cinegrafistas"`
QtdEstudios int32 `json:"qtd_estudios"`
QtdPontoFoto int32 `json:"qtd_ponto_foto"`
QtdPontoID int32 `json:"qtd_ponto_id"`
QtdPontoDecorado int32 `json:"qtd_ponto_decorado"`
QtdPontosLed int32 `json:"qtd_pontos_led"`
QtdPlataforma360 int32 `json:"qtd_plataforma_360"`
StatusProfissionais string `json:"status_profissionais"`
FotoFaltante int32 `json:"foto_faltante"`
RecepFaltante int32 `json:"recep_faltante"`
CineFaltante int32 `json:"cine_faltante"`
LogisticaObservacoes string `json:"logistica_observacoes"`
PreVenda bool `json:"pre_venda"`
}
type Assignment struct {
ProfessionalID string `json:"professional_id"`
Status string `json:"status"`
MotivoRejeicao *string `json:"motivo_rejeicao"`
FuncaoID *string `json:"funcao_id"`
}
type AgendaResponse struct {
generated.ListAgendasRow
ParsedAssignments []Assignment `json:"assignments"`
}
func (s *Service) CalculateStatus(fotoFaltante, recepFaltante, cineFaltante int32) string {
if fotoFaltante < 0 || recepFaltante < 0 || cineFaltante < 0 {
return "ERRO"
}
sum := fotoFaltante + recepFaltante + cineFaltante
if sum == 0 {
return "OK"
} else if sum > 0 {
return "FALTA"
}
return "ERRO"
}
func (s *Service) Create(ctx context.Context, userID uuid.UUID, req CreateAgendaRequest, regiao string) (generated.Agenda, error) {
status := s.CalculateStatus(req.FotoFaltante, req.RecepFaltante, req.CineFaltante)
params := generated.CreateAgendaParams{
FotID: pgtype.UUID{Bytes: req.FotID, Valid: true},
DataEvento: pgtype.Date{Time: req.DataEvento, Valid: true},
TipoEventoID: pgtype.UUID{Bytes: req.TipoEventoID, Valid: true},
ObservacoesEvento: pgtype.Text{String: req.ObservacoesEvento, Valid: req.ObservacoesEvento != ""},
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
QtdCinegrafistas: pgtype.Int4{Int32: req.QtdCinegrafistas, Valid: true},
QtdEstudios: pgtype.Int4{Int32: req.QtdEstudios, Valid: true},
QtdPontoFoto: pgtype.Int4{Int32: req.QtdPontoFoto, Valid: true},
QtdPontoID: pgtype.Int4{Int32: req.QtdPontoID, Valid: true},
QtdPontoDecorado: pgtype.Int4{Int32: req.QtdPontoDecorado, Valid: true},
QtdPontosLed: pgtype.Int4{Int32: req.QtdPontosLed, Valid: true},
QtdPlataforma360: pgtype.Int4{Int32: req.QtdPlataforma360, Valid: true},
StatusProfissionais: pgtype.Text{String: status, Valid: true},
FotoFaltante: pgtype.Int4{Int32: req.FotoFaltante, Valid: true},
RecepFaltante: pgtype.Int4{Int32: req.RecepFaltante, Valid: true},
CineFaltante: pgtype.Int4{Int32: req.CineFaltante, Valid: true},
LogisticaObservacoes: pgtype.Text{String: req.LogisticaObservacoes, Valid: req.LogisticaObservacoes != ""},
PreVenda: pgtype.Bool{Bool: req.PreVenda, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
}
return s.queries.CreateAgenda(ctx, params)
}
func (s *Service) List(ctx context.Context, userID uuid.UUID, role string, regiao string) ([]AgendaResponse, error) {
var rows []generated.ListAgendasRow
var err error
// If role is CLIENT (cliente) or EVENT_OWNER
if role == "cliente" || role == "EVENT_OWNER" {
// New Logic: Fetch User's Company
user, err := s.queries.GetUsuarioByID(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, fmt.Errorf("erro ao buscar usuário para filtro de empresa: %v", err)
}
if !user.EmpresaID.Valid {
// If no company linked, return empty or error? Empty seems safer.
return []AgendaResponse{}, nil
}
listRows, err := s.queries.ListAgendasByCompany(ctx, generated.ListAgendasByCompanyParams{
EmpresaID: user.EmpresaID,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
return nil, err
}
// Convert ListAgendasByCompanyRow to ListAgendasRow manually
for _, r := range listRows {
rows = append(rows, generated.ListAgendasRow{
ID: r.ID,
UserID: r.UserID,
FotID: r.FotID,
DataEvento: r.DataEvento,
TipoEventoID: r.TipoEventoID,
ObservacoesEvento: r.ObservacoesEvento,
LocalEvento: r.LocalEvento,
Endereco: r.Endereco,
Horario: r.Horario,
QtdFormandos: r.QtdFormandos,
QtdFotografos: r.QtdFotografos,
QtdRecepcionistas: r.QtdRecepcionistas,
QtdCinegrafistas: r.QtdCinegrafistas,
QtdEstudios: r.QtdEstudios,
QtdPontoFoto: r.QtdPontoFoto,
QtdPontoID: r.QtdPontoID,
QtdPontoDecorado: r.QtdPontoDecorado,
QtdPontosLed: r.QtdPontosLed,
QtdPlataforma360: r.QtdPlataforma360,
StatusProfissionais: r.StatusProfissionais,
FotoFaltante: r.FotoFaltante,
RecepFaltante: r.RecepFaltante,
CineFaltante: r.CineFaltante,
LogisticaObservacoes: r.LogisticaObservacoes,
PreVenda: r.PreVenda,
CriadoEm: r.CriadoEm,
AtualizadoEm: r.AtualizadoEm,
Status: r.Status,
FotNumero: r.FotNumero,
Instituicao: r.Instituicao,
CursoNome: r.CursoNome,
EmpresaNome: r.EmpresaNome,
EmpresaID: r.EmpresaID,
AnoSemestre: r.AnoSemestre,
ObservacoesFot: r.ObservacoesFot,
TipoEventoNome: r.TipoEventoNome,
AssignedProfessionals: r.AssignedProfessionals,
})
}
} else {
rows, err = s.queries.ListAgendas(ctx, pgtype.Text{String: regiao, Valid: true})
if err != nil {
return nil, err
}
}
var response []AgendaResponse
for _, row := range rows {
var assignments []Assignment
if row.AssignedProfessionals != nil {
bytes, ok := row.AssignedProfessionals.([]byte)
if !ok {
str, ok := row.AssignedProfessionals.(string)
if ok {
bytes = []byte(str)
}
}
if bytes != nil {
json.Unmarshal(bytes, &assignments)
}
}
response = append(response, AgendaResponse{
ListAgendasRow: row,
ParsedAssignments: assignments,
})
}
return response, nil
}
func (s *Service) Get(ctx context.Context, id uuid.UUID, regiao string) (generated.Agenda, error) {
return s.queries.GetAgenda(ctx, generated.GetAgendaParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
}
func (s *Service) Update(ctx context.Context, id uuid.UUID, req CreateAgendaRequest, regiao string) (generated.Agenda, error) {
if req.FotID == uuid.Nil {
return generated.Agenda{}, fmt.Errorf("FOT ID inválido ou não informado")
}
if req.TipoEventoID == uuid.Nil {
return generated.Agenda{}, fmt.Errorf("Tipo de Evento ID inválido ou não informado")
}
status := s.CalculateStatus(req.FotoFaltante, req.RecepFaltante, req.CineFaltante)
params := generated.UpdateAgendaParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
FotID: pgtype.UUID{Bytes: req.FotID, Valid: true},
DataEvento: pgtype.Date{Time: req.DataEvento, Valid: true},
TipoEventoID: pgtype.UUID{Bytes: req.TipoEventoID, Valid: true},
ObservacoesEvento: pgtype.Text{String: req.ObservacoesEvento, Valid: req.ObservacoesEvento != ""},
LocalEvento: pgtype.Text{String: req.LocalEvento, Valid: req.LocalEvento != ""},
Endereco: pgtype.Text{String: req.Endereco, Valid: req.Endereco != ""},
Horario: pgtype.Text{String: req.Horario, Valid: req.Horario != ""},
QtdFormandos: pgtype.Int4{Int32: req.QtdFormandos, Valid: true},
QtdFotografos: pgtype.Int4{Int32: req.QtdFotografos, Valid: true},
QtdRecepcionistas: pgtype.Int4{Int32: req.QtdRecepcionistas, Valid: true},
QtdCinegrafistas: pgtype.Int4{Int32: req.QtdCinegrafistas, Valid: true},
QtdEstudios: pgtype.Int4{Int32: req.QtdEstudios, Valid: true},
QtdPontoFoto: pgtype.Int4{Int32: req.QtdPontoFoto, Valid: true},
QtdPontoID: pgtype.Int4{Int32: req.QtdPontoID, Valid: true},
QtdPontoDecorado: pgtype.Int4{Int32: req.QtdPontoDecorado, Valid: true},
QtdPontosLed: pgtype.Int4{Int32: req.QtdPontosLed, Valid: true},
QtdPlataforma360: pgtype.Int4{Int32: req.QtdPlataforma360, Valid: true},
StatusProfissionais: pgtype.Text{String: status, Valid: true},
FotoFaltante: pgtype.Int4{Int32: req.FotoFaltante, Valid: true},
RecepFaltante: pgtype.Int4{Int32: req.RecepFaltante, Valid: true},
CineFaltante: pgtype.Int4{Int32: req.CineFaltante, Valid: true},
LogisticaObservacoes: pgtype.Text{String: req.LogisticaObservacoes, Valid: req.LogisticaObservacoes != ""},
PreVenda: pgtype.Bool{Bool: req.PreVenda, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
}
return s.queries.UpdateAgenda(ctx, params)
}
func (s *Service) Delete(ctx context.Context, id uuid.UUID, regiao string) error {
return s.queries.DeleteAgenda(ctx, generated.DeleteAgendaParams{
ID: pgtype.UUID{Bytes: id, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
}
func (s *Service) AssignProfessional(ctx context.Context, agendaID uuid.UUID, profID uuid.UUID, funcaoID *uuid.UUID, regiao string) error {
params := generated.AssignProfessionalParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true},
}
if funcaoID != nil {
params.FuncaoID = pgtype.UUID{Bytes: *funcaoID, Valid: true}
} else {
params.FuncaoID = pgtype.UUID{Valid: false}
}
if err := s.queries.AssignProfessional(ctx, params); err != nil {
return err
}
// Notification Logic (Async or Sync - staying Sync but safe for now)
go func() {
// Create a detached context for the notification to avoid cancellation if the HTTP request ends
// Note: Ideally use a proper background context with timeout
bgCtx := context.Background()
// 1. Get Agenda Details
// 1. Get Agenda Details. To notify successfully we need region?
// Since this is async/background, we might not have region context easily if we don't pass it.
// GetAgenda now requires regiao.
// We passed agendaID.
// We should probably rely on a GetAgendaByIDOnly query for system tasks?
// Or pass region to AssignProfessional and then to NotifyLogistics.
// For now, let's assume 'SP' default if background, OR we need access to the region.
// Wait, we don't have region here.
// Notification logic is tricky with strict isolation.
// But Wait, I can't call GetAgenda without Regiao now.
// I must fix Notify to accept Regiao, or use a system query.
// I'll skip fixing Notify call sites for a moment and focus on signatures.
// Actually, I should probably pass Regiao to AssignProfessional too.
// But AssignProfessional only modifies junction table.
// The Notification part reads Agenda.
// I'll add Regiao to AssignProfessional signature.
agenda, err := s.queries.GetAgenda(bgCtx, generated.GetAgendaParams{
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
log.Printf("[Notification] Erro ao buscar dados da agenda para notificação: %v", err)
return
}
// 2. Get Professional Details
// We use GetProfissionalByID which returns the struct we need
prof, err := s.queries.GetProfissionalByID(bgCtx, generated.GetProfissionalByIDParams{
ID: pgtype.UUID{Bytes: profID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
log.Printf("[Notification] Erro ao buscar dados do profissional para notificação: %v", err)
return
}
if prof.Whatsapp.String == "" {
log.Printf("[Notification] Profissional %s sem WhatsApp cadastrado. Ignorando.", prof.Nome)
return
}
// 3. Get Event Type Details to show the name
// We need to fetch the TipoEvento name
var tipoEventoNome string = "Evento"
if agenda.TipoEventoID.Valid {
tipoEvento, err := s.queries.GetTipoEventoByID(bgCtx, generated.GetTipoEventoByIDParams{
ID: pgtype.UUID{Bytes: agenda.TipoEventoID.Bytes, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err == nil {
tipoEventoNome = tipoEvento.Nome
} else {
log.Printf("[Notification] Erro ao buscar tipo de evento: %v", err)
}
}
// 4. Format Message
dataEvento := "Data a definir"
if agenda.DataEvento.Valid {
dataEvento = agenda.DataEvento.Time.Format("02/01/2006")
}
horario := "Horário a definir"
if agenda.Horario.Valid && agenda.Horario.String != "" {
horario = agenda.Horario.String
}
local := "Local a definir"
if agenda.LocalEvento.Valid && agenda.LocalEvento.String != "" {
local = agenda.LocalEvento.String
}
if agenda.Endereco.Valid && agenda.Endereco.String != "" {
local += fmt.Sprintf(" (%s)", agenda.Endereco.String)
}
// Use configured FRONTEND_URL or default to localhost
baseUrl := "http://localhost:3000"
if s.cfg != nil && s.cfg.FrontendURL != "" {
baseUrl = s.cfg.FrontendURL
}
// Refined Message:
msg := fmt.Sprintf("Olá *%s*! 📸\n\nVocê foi escalado(a) para um evento no dia *%s*.\n\n*Tipo:* %s\n*Horário:* %s\n*Local:* %s\n\nAcesse seu painel na Photum para conferir os detalhes e confirmar sua presença.\n%s/entrar",
prof.Nome,
dataEvento,
tipoEventoNome,
horario,
local,
baseUrl,
)
if err := s.notification.SendWhatsApp(prof.Whatsapp.String, msg); err != nil {
log.Printf("[Notification] Falha ao enviar WhatsApp para %s: %v", prof.Nome, err)
}
}()
return nil
}
func (s *Service) RemoveProfessional(ctx context.Context, agendaID uuid.UUID, profID uuid.UUID) error {
params := generated.RemoveProfessionalParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true},
}
return s.queries.RemoveProfessional(ctx, params)
}
func (s *Service) GetAgendaProfessionals(ctx context.Context, agendaID uuid.UUID) ([]generated.GetAgendaProfessionalsRow, error) {
return s.queries.GetAgendaProfessionals(ctx, pgtype.UUID{Bytes: agendaID, Valid: true})
}
func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status string, regiao string) (generated.Agenda, error) {
params := generated.UpdateAgendaStatusParams{
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
Status: pgtype.Text{String: status, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true}, // Added via SQL modification
}
agenda, err := s.queries.UpdateAgendaStatus(ctx, params)
if err != nil {
return generated.Agenda{}, err
}
// Se o evento for confirmado, enviar notificações com logística
if status == "Confirmado" {
go s.NotifyLogistics(context.Background(), agendaID, nil, regiao)
}
return agenda, nil
}
func (s *Service) NotifyLogistics(ctx context.Context, agendaID uuid.UUID, passengerOrders map[string]map[string]int, regiao string) error {
// 1. Buscar Detalhes do Evento
bgCtx := context.Background() // Isolate context for background execution if needed, but if valid ctx passed use it?
// If caller passed context.Background(), fine.
agenda, err := s.queries.GetAgenda(ctx, generated.GetAgendaParams{
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
log.Printf("[Notification] Erro ao buscar agenda: %v", err)
return err
}
tipoEventoNome := "Evento"
if agenda.TipoEventoID.Valid {
te, err := s.queries.GetTipoEventoByID(bgCtx, generated.GetTipoEventoByIDParams{
ID: pgtype.UUID{Bytes: agenda.TipoEventoID.Bytes, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err == nil {
tipoEventoNome = te.Nome
}
}
dataFmt := "Data a definir"
if agenda.DataEvento.Valid {
dataFmt = agenda.DataEvento.Time.Format("02/01/2006")
}
horaFmt := "Horário a definir"
if agenda.Horario.Valid {
horaFmt = agenda.Horario.String
}
localFmt := ""
if agenda.LocalEvento.Valid && agenda.LocalEvento.String != "" {
localFmt = agenda.LocalEvento.String
}
if agenda.Endereco.Valid && agenda.Endereco.String != "" {
if localFmt != "" {
localFmt += " - " + agenda.Endereco.String
} else {
localFmt = agenda.Endereco.String
}
}
if localFmt == "" {
localFmt = "Local a definir"
}
// 2. Buscar Profissionais Escalados
profs, err := s.queries.GetAgendaProfessionals(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true})
if err != nil {
log.Printf("[Notification] Erro ao buscar profissionais: %v", err)
return err
}
// 3. Buscar Logística (Carros)
carros, err := s.queries.ListCarrosByAgendaID(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true})
if err != nil {
log.Printf("[Notification] Erro ao buscar carros: %v", err)
// Segue sem logística detalhada
}
// Struct para passageiro + ID para ordenação
type PassengerInfo struct {
ID string
Nome string
}
// Mapear Passageiros por Carro e Carro por Profissional
passengersByCar := make(map[uuid.UUID][]PassengerInfo)
carByProfessional := make(map[uuid.UUID]generated.ListCarrosByAgendaIDRow) // ProfID -> Carro
for _, carro := range carros {
// Converter pgtype.UUID para uuid.UUID para usar como chave de mapa
carUuid := uuid.UUID(carro.ID.Bytes)
carIDStr := carUuid.String()
passengers, err := s.queries.ListPassageirosByCarroID(bgCtx, carro.ID)
if err == nil {
var pList []PassengerInfo
for _, p := range passengers {
pUUID := uuid.UUID(p.ProfissionalID.Bytes)
pList = append(pList, PassengerInfo{ID: pUUID.String(), Nome: p.Nome})
if p.ProfissionalID.Valid {
carByProfessional[pUUID] = carro
}
}
// Sort passengers if order provided
if passengerOrders != nil {
if orders, ok := passengerOrders[carIDStr]; ok {
sort.Slice(pList, func(i, j int) bool {
ordA := orders[pList[i].ID]
ordB := orders[pList[j].ID]
// If order missing (0), push to end? Or treat as 999 equivalent?
if ordA == 0 {
ordA = 999
}
if ordB == 0 {
ordB = 999
}
if ordA == ordB {
return pList[i].Nome < pList[j].Nome
}
return ordA < ordB
})
}
}
passengersByCar[carUuid] = pList
}
// O motorista também está no carro
if carro.MotoristaID.Valid {
driverUuid := uuid.UUID(carro.MotoristaID.Bytes)
carByProfessional[driverUuid] = carro
}
}
// 4. Enviar Mensagens
for _, p := range profs {
// O retorno de GetAgendaProfessionals traz p.*, então o ID é p.ID (cadastro_profissionais.id)
targetID := uuid.UUID(p.ID.Bytes)
// Buscar dados completos para ter o whatsapp atualizado (se necessario, mas p.* ja tem whatsapp)
// Vamos usar p.Whatsapp direto se tiver.
phone := p.Whatsapp.String
if phone == "" {
// Tenta buscar novamente caso GetAgendaProfessionals não trouxer (mas traz p.*)
continue
}
// Montar mensagem de logística
logisticaMsg := ""
if carro, ok := carByProfessional[targetID]; ok {
motorista := carro.NomeMotorista.String
if carro.MotoristaNomeSistema.Valid {
motorista = carro.MotoristaNomeSistema.String
}
chegada := carro.HorarioChegada.String
carroUuid := uuid.UUID(carro.ID.Bytes)
passageirosInfos := passengersByCar[carroUuid]
// Filtrar o próprio nome para "Outros", mas manter a lista completa para "Rota" se ordenada
// Se o usuário é passageiro, mostramos a rota completa ou "Você é o Xº"?
// Melhor mostrar a lista ordenada de todos os passageiros.
// Se passengerOrders != nil, mostramos Rota Ordenada.
// Se nil, mostramos "Com: A, B" (Style antigo).
hasOrder := passengerOrders != nil && len(passengerOrders[carroUuid.String()]) > 0
listaPassageiros := ""
if hasOrder {
listaPassageiros = "\n\n📋 *Ordem de Busca:*"
foundSelf := false
for i, passInfo := range passageirosInfos {
marker := ""
if passInfo.ID == targetID.String() {
marker = " (Você)"
foundSelf = true
}
listaPassageiros += fmt.Sprintf("\n%d. %s%s", i+1, passInfo.Nome, marker)
}
if !foundSelf {
// Caso seja o motorista, não aparece na lista de passageiros? ou aparece?
// ListaPassageiros só tem passageiros. Motorista é separado.
// Se o alvo é motorista, ele vê a ordem de busca.
if targetID == uuid.UUID(carro.MotoristaID.Bytes) {
// Motorista vê a lista
} else {
// Passageiro não está na lista? Erro de lógica?
// pList vem de ListPassageirosByCarroID.
}
}
} else {
// Legacy list style (excludes self if passenger)
var outros []string
for _, passInfo := range passageirosInfos {
if passInfo.ID != targetID.String() {
outros = append(outros, passInfo.Nome)
}
}
if len(outros) > 0 {
listaPassageiros = "\nCom: " + strings.Join(outros, ", ")
}
}
logisticaMsg = fmt.Sprintf("\n\n🚗 *Transporte Definido*\nCarro de: *%s*\nChegada: *%s*%s", motorista, chegada, listaPassageiros)
} else {
logisticaMsg = "\n\n🚗 *Transporte:* Verifique no painel ou entre em contato."
}
msg := fmt.Sprintf("✅ *Evento Confirmado!* 🚀\n\nOlá *%s*! O evento a seguir foi confirmado e sua escala está valendo.\n\n📅 *%s*\n⏰ *%s*\n📍 *%s*\n📌 *%s*%s\n\nBom trabalho!",
p.Nome,
dataFmt,
horaFmt,
localFmt,
tipoEventoNome,
logisticaMsg,
)
if err := s.notification.SendWhatsApp(phone, msg); err != nil {
// Não logar erro para todos se for falha de validação de numero, mas logar warning
log.Printf("[Notification] Erro ao enviar para %s: %v", p.Nome, err)
}
}
// Atualizar timestamp da notificação
if err := s.queries.UpdateLogisticsNotificationTimestamp(bgCtx, pgtype.UUID{Bytes: agendaID, Valid: true}); err != nil {
log.Printf("[Notification] Erro ao atualizar timestamp: %v", err)
}
return nil
}
func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professionalID uuid.UUID, status string, reason string, regiao string) error {
// Conflict Validation on Accept
if status == "ACEITO" {
// 1. Get Current Agenda to know Date/Time
agenda, err := s.queries.GetAgenda(ctx, generated.GetAgendaParams{
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
return fmt.Errorf("erro ao buscar agenda para validação: %v", err)
}
if agenda.DataEvento.Valid {
// 2. Check for other confirmed events on the same date
// Exclude current agenda ID from check
conflicts, err := s.queries.CheckProfessionalBusyDate(ctx, generated.CheckProfessionalBusyDateParams{
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
DataEvento: agenda.DataEvento,
ID: pgtype.UUID{Bytes: agendaID, Valid: true},
})
if err != nil {
return fmt.Errorf("erro ao verificar disponibilidade: %v", err)
}
if len(conflicts) > 0 {
// 3. Check time overlap
currentStart := parseTimeMinutes(agenda.Horario.String)
currentEnd := currentStart + 240 // Assume 4 hours duration
for _, c := range conflicts {
conflictStart := parseTimeMinutes(c.Horario.String)
conflictEnd := conflictStart + 240
// Check overlap: StartA < EndB && StartB < EndA
if currentStart < conflictEnd && conflictStart < currentEnd {
return fmt.Errorf("conflito de horário: profissional já confirmou presença em outro evento às %s", c.Horario.String)
}
}
}
}
}
params := generated.UpdateAssignmentStatusParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
Status: pgtype.Text{String: status, Valid: true},
MotivoRejeicao: pgtype.Text{String: reason, Valid: reason != ""},
}
_, err := s.queries.UpdateAssignmentStatus(ctx, params)
return err
}
// Helper for time parsing (HH:MM)
func parseTimeMinutes(t string) int {
if len(t) < 5 {
return 0 // Default or Error
}
h, _ := strconv.Atoi(t[0:2])
m, _ := strconv.Atoi(t[3:5])
return h*60 + m
}
func (s *Service) UpdateAssignmentPosition(ctx context.Context, agendaID, professionalID uuid.UUID, posicao string) error {
params := generated.UpdateAssignmentPositionParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
Posicao: pgtype.Text{String: posicao, Valid: true},
}
_, err := s.queries.UpdateAssignmentPosition(ctx, params)
return err
}
func (s *Service) ListAvailableProfessionals(ctx context.Context, date time.Time, regiao string) ([]generated.ListAvailableProfessionalsForDateRow, error) {
return s.queries.ListAvailableProfessionalsForDate(ctx, generated.ListAvailableProfessionalsForDateParams{
Data: pgtype.Date{Time: date, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
}
type FinancialStatementResponse struct {
TotalRecebido float64 `json:"total_recebido"`
PagamentosConfirmados float64 `json:"pagamentos_confirmados"`
PagamentosPendentes float64 `json:"pagamentos_pendentes"`
Transactions []FinancialTransactionDTO `json:"transactions"`
}
type FinancialTransactionDTO struct {
ID uuid.UUID `json:"id"`
DataEvento string `json:"data_evento"`
NomeEvento string `json:"nome_evento"` // Fot + Tipo
TipoEvento string `json:"tipo_evento"`
Empresa string `json:"empresa"`
ValorRecebido float64 `json:"valor_recebido"`
ValorFree float64 `json:"valor_free"`
ValorExtra float64 `json:"valor_extra"`
DescricaoExtra string `json:"descricao_extra"`
DataPagamento string `json:"data_pagamento"`
Status string `json:"status"`
}
func (s *Service) GetProfessionalFinancialStatement(ctx context.Context, userID uuid.UUID) (*FinancialStatementResponse, error) {
// 1. Identificar o profissional logado
prof, err := s.queries.GetProfissionalByUsuarioID(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return &FinancialStatementResponse{}, fmt.Errorf("profissional não encontrado para este usuário")
}
profID := pgtype.UUID{Bytes: prof.ID.Bytes, Valid: true}
cpf := prof.CpfCnpjTitular.String // Fallback para registros antigos
// 2. Buscar Transações
rawTransactions, err := s.queries.ListTransactionsByProfessional(ctx, generated.ListTransactionsByProfessionalParams{
ProfissionalID: profID,
Column2: cpf,
})
if err != nil {
// Se não houver transações, retorna vazio sem erro
return &FinancialStatementResponse{}, nil
}
// 3. Processar e Somar
var response FinancialStatementResponse
var dtoList []FinancialTransactionDTO
for _, t := range rawTransactions {
// Validar valores
valor := 0.0
valTotal, _ := t.TotalPagar.Float64Value() // pgtype.Numeric
if valTotal.Valid {
valor = valTotal.Float64
}
fmt.Printf("DEBUG Transaction %v: Total=%.2f Free=%.2f Extra=%.2f Desc=%s\n",
t.ID, valor, getFloat64(t.ValorFree), getFloat64(t.ValorExtra), t.DescricaoExtra.String)
// Status e Somatórios
status := "Pendente"
if t.PgtoOk.Valid && t.PgtoOk.Bool {
status = "Pago"
response.TotalRecebido += valor
response.PagamentosConfirmados += valor // Assumindo Recebido = Confirmado neste contexto
} else {
response.PagamentosPendentes += valor
}
// Formatar Dados
dataEvento := ""
if t.DataCobranca.Valid {
dataEvento = t.DataCobranca.Time.Format("02/01/2006")
}
dtPagamento := "-"
if t.DataPagamento.Valid {
dtPagamento = t.DataPagamento.Time.Format("02/01/2006")
}
empresa := "-"
if t.EmpresaNome.Valid {
empresa = t.EmpresaNome.String
}
nomeEvento := ""
if t.FotNumero.Valid {
if t.CursoNome.Valid {
nomeEvento = fmt.Sprintf("Formatura %s (FOT %s)", t.CursoNome.String, t.FotNumero.String)
} else {
nomeEvento = fmt.Sprintf("Formatura FOT %s", t.FotNumero.String)
}
} else {
nomeEvento = t.TipoEvento.String
}
dto := FinancialTransactionDTO{
ID: uuid.UUID(t.ID.Bytes),
DataEvento: dataEvento,
NomeEvento: nomeEvento,
TipoEvento: t.TipoEvento.String,
Empresa: empresa,
ValorRecebido: valor,
ValorFree: getFloat64(t.ValorFree),
ValorExtra: getFloat64(t.ValorExtra),
DescricaoExtra: t.DescricaoExtra.String,
DataPagamento: dtPagamento,
Status: status,
}
dtoList = append(dtoList, dto)
}
response.Transactions = dtoList
return &response, nil
}
func getFloat64(n pgtype.Numeric) float64 {
v, _ := n.Float64Value()
if v.Valid {
return v.Float64
}
return 0
}
type ImportAgendaRequest struct {
Fot string `json:"fot"`
Data string `json:"data"` // DD/MM/YYYY
TipoEvento string `json:"tipo_evento"`
Observacoes string `json:"observacoes"` // Obs Evento (Column I)
Local string `json:"local"`
Endereco string `json:"endereco"`
Horario string `json:"horario"`
QtdFormandos int32 `json:"qtd_formandos"` // M
QtdFotografos int32 `json:"qtd_fotografos"` // N
QtdCinegrafistas int32 `json:"qtd_cinegrafistas"` // O - Cinegrafista (Screenshot Col O header "cinegrafista"?)
QtdRecepcionistas int32 `json:"qtd_recepcionistas"` // ? Need mapping from header. Screenshot Col P "Estúdio"?
// Mapping from Screenshot:
// M: Formandos -> qtd_formandos
// N: fotografo -> qtd_fotografos
// O: cinegrafista -> qtd_cinegrafistas
// P: Estudio -> qtd_estudios
// Q: Ponto de Foto -> qtd_ponto_foto
// R: Ponto de ID -> qtd_ponto_id
// S: Ponto -> qtd_ponto_decorado (Assumption)
// T: Pontos Led -> qtd_pontos_led
// U: Plataforma -> qtd_plataforma_360
QtdEstudios int32 `json:"qtd_estudios"`
QtdPontoFoto int32 `json:"qtd_ponto_foto"`
QtdPontoID int32 `json:"qtd_ponto_id"`
QtdPontoDecorado int32 `json:"qtd_ponto_decorado"`
QtdPontosLed int32 `json:"qtd_pontos_led"`
QtdPlataforma360 int32 `json:"qtd_plataforma_360"`
FotoFaltante int32 `json:"foto_faltante"`
RecepFaltante int32 `json:"recep_faltante"`
CineFaltante int32 `json:"cine_faltante"`
LogisticaObservacoes string `json:"logistica_observacoes"`
PreVenda bool `json:"pre_venda"`
}
func (s *Service) ImportAgenda(ctx context.Context, userID uuid.UUID, items []ImportAgendaRequest, regiao string) error {
// 1. Pre-load cache if needed or just query. Query is safer (less race conditions).
for _, item := range items {
// Parse Date
// Helper to parse DD/MM/YYYY
parsedDate, err := time.Parse("02/01/2006", item.Data)
if err != nil {
// Try standard format or log error?
// Fallback
parsedDate = time.Now() // Dangerous default, better skip.
log.Printf("Erro ao parsear data para FOT %s: %v", item.Fot, err)
continue
}
// 1. Find FOT
// Assume GetCadastroFotByFOT exists in generated.
fot, err := s.queries.GetCadastroFotByFOT(ctx, generated.GetCadastroFotByFOTParams{
Fot: item.Fot,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
log.Printf("FOT %s não encontrado. Pulando evento.", item.Fot)
continue
}
// 2. Find/Create Tipo Evento
tipoEventoID, err := s.findOrCreateTipoEvento(ctx, item.TipoEvento, regiao)
if err != nil {
log.Printf("Erro ao processar Tipo Evento %s: %v", item.TipoEvento, err)
continue
}
// 3. Upsert Agenda
// We will assume that same FOT + Date + Tipo = Same Event
// We check if exists
params := generated.CreateAgendaParams{
FotID: pgtype.UUID{Bytes: fot.ID.Bytes, Valid: true},
DataEvento: pgtype.Date{Time: parsedDate, Valid: true},
TipoEventoID: pgtype.UUID{Bytes: tipoEventoID, Valid: true},
ObservacoesEvento: pgtype.Text{String: item.Observacoes, Valid: item.Observacoes != ""},
LocalEvento: pgtype.Text{String: item.Local, Valid: item.Local != ""},
Endereco: pgtype.Text{String: item.Endereco, Valid: item.Endereco != ""},
Horario: pgtype.Text{String: item.Horario, Valid: item.Horario != ""},
QtdFormandos: pgtype.Int4{Int32: item.QtdFormandos, Valid: true},
QtdFotografos: pgtype.Int4{Int32: item.QtdFotografos, Valid: true},
QtdCinegrafistas: pgtype.Int4{Int32: item.QtdCinegrafistas, Valid: true},
QtdRecepcionistas: pgtype.Int4{Int32: item.QtdRecepcionistas, Valid: true},
QtdEstudios: pgtype.Int4{Int32: item.QtdEstudios, Valid: true},
QtdPontoFoto: pgtype.Int4{Int32: item.QtdPontoFoto, Valid: true},
QtdPontoID: pgtype.Int4{Int32: item.QtdPontoID, Valid: true},
QtdPontoDecorado: pgtype.Int4{Int32: item.QtdPontoDecorado, Valid: true},
QtdPontosLed: pgtype.Int4{Int32: item.QtdPontosLed, Valid: true},
QtdPlataforma360: pgtype.Int4{Int32: item.QtdPlataforma360, Valid: true},
StatusProfissionais: pgtype.Text{String: "OK", Valid: true}, // Recalculated below
FotoFaltante: pgtype.Int4{Int32: item.FotoFaltante, Valid: true},
RecepFaltante: pgtype.Int4{Int32: item.RecepFaltante, Valid: true},
CineFaltante: pgtype.Int4{Int32: item.CineFaltante, Valid: true},
LogisticaObservacoes: pgtype.Text{String: item.LogisticaObservacoes, Valid: item.LogisticaObservacoes != ""},
PreVenda: pgtype.Bool{Bool: item.PreVenda, Valid: true},
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
}
// Recalculate status
status := s.CalculateStatus(item.FotoFaltante, item.RecepFaltante, item.CineFaltante)
params.StatusProfissionais = pgtype.Text{String: status, Valid: true}
// Attempt Upsert (Check existence)
// Ideally we need UpsertAgenda query in sqlc. Since we don't know if it exists, use Check Logic.
existing, err := s.queries.GetAgendaByFotDataTipo(ctx, generated.GetAgendaByFotDataTipoParams{
FotID: fot.ID,
DataEvento: pgtype.Date{Time: parsedDate, Valid: true},
TipoEventoID: pgtype.UUID{Bytes: tipoEventoID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err == nil {
// Update
updateParams := generated.UpdateAgendaParams{
ID: existing.ID,
Regiao: pgtype.Text{String: regiao, Valid: true}, // Added to Update query
FotID: params.FotID,
DataEvento: params.DataEvento,
TipoEventoID: params.TipoEventoID,
ObservacoesEvento: params.ObservacoesEvento,
LocalEvento: params.LocalEvento,
Endereco: params.Endereco,
Horario: params.Horario,
QtdFormandos: params.QtdFormandos,
QtdFotografos: params.QtdFotografos,
QtdCinegrafistas: params.QtdCinegrafistas,
QtdRecepcionistas: params.QtdRecepcionistas,
QtdEstudios: params.QtdEstudios,
QtdPontoFoto: params.QtdPontoFoto,
QtdPontoID: params.QtdPontoID,
QtdPontoDecorado: params.QtdPontoDecorado,
QtdPontosLed: params.QtdPontosLed,
QtdPlataforma360: params.QtdPlataforma360,
StatusProfissionais: params.StatusProfissionais,
FotoFaltante: params.FotoFaltante,
RecepFaltante: params.RecepFaltante,
CineFaltante: params.CineFaltante,
LogisticaObservacoes: params.LogisticaObservacoes,
PreVenda: params.PreVenda,
}
s.queries.UpdateAgenda(ctx, updateParams)
} else {
// Insert
s.queries.CreateAgenda(ctx, params)
}
}
return nil
}
func (s *Service) findOrCreateTipoEvento(ctx context.Context, nome string, regiao string) (uuid.UUID, error) {
if nome == "" {
nome = "Evento"
}
// Check if exists
te, err := s.queries.GetTipoEventoByNome(ctx, generated.GetTipoEventoByNomeParams{
Nome: nome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err == nil {
return uuid.UUID(te.ID.Bytes), nil
}
// Create
newTe, err := s.queries.CreateTipoEvento(ctx, generated.CreateTipoEventoParams{
Nome: nome,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if err != nil {
return uuid.Nil, err
}
return uuid.UUID(newTe.ID.Bytes), nil
}