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 ContactInfo struct { Name string `json:"name"` Role string `json:"role"` Phone string `json:"phone"` } 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"` Contatos []ContactInfo `json:"contatos"` } type Assignment struct { ProfessionalID string `json:"professional_id"` Status string `json:"status"` MotivoRejeicao *string `json:"motivo_rejeicao"` FuncaoID *string `json:"funcao_id"` IsCoordinator bool `json:"is_coordinator"` } type AgendaResponse struct { generated.ListAgendasRow ParsedAssignments []Assignment `json:"assignments"` ParsedContacts []ContactInfo `json:"contacts"` } 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) contatosBytes, _ := json.Marshal(req.Contatos) 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}, Contatos: contatosBytes, } 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, Contatos: r.Contatos, }) } } 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) } } var contacts []ContactInfo if len(row.Contatos) > 0 { json.Unmarshal(row.Contatos, &contacts) } response = append(response, AgendaResponse{ ListAgendasRow: row, ParsedAssignments: assignments, ParsedContacts: contacts, }) } 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) contatosBytes, _ := json.Marshal(req.Contatos) 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}, Contatos: contatosBytes, } 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) SetCoordinator(ctx context.Context, agendaID, profID uuid.UUID, isCoordinator bool) error { return s.queries.SetCoordinator(ctx, generated.SetCoordinatorParams{ AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true}, ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true}, IsCoordinator: pgtype.Bool{Bool: isCoordinator, Valid: true}, }) } 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 // [MODIFIED] User requested to NOT send notification on approval. Only manually via logistics panel. // 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 }