feat(backend): implementa criação automática de transação financeira ao aceitar evento

This commit is contained in:
NANDO9322 2026-02-10 20:34:33 -03:00
parent c9e34af619
commit 6382145442
9 changed files with 285 additions and 21 deletions

View file

@ -73,6 +73,7 @@ func main() {
// Initialize services
notificationService := notification.NewService()
profissionaisService := profissionais.NewService(queries)
financeService := finance.NewService(queries, profissionaisService)
authService := auth.NewService(queries, profissionaisService, cfg)
funcoesService := funcoes.NewService(queries)
cursosService := cursos.NewService(queries)
@ -81,7 +82,7 @@ func main() {
tiposServicosService := tipos_servicos.NewService(queries)
tiposEventosService := tipos_eventos.NewService(queries)
cadastroFotService := cadastro_fot.NewService(queries)
agendaService := agenda.NewService(queries, notificationService, cfg)
agendaService := agenda.NewService(queries, notificationService, cfg, financeService)
availabilityService := availability.NewService(queries)
s3Service := storage.NewS3Service(cfg)
@ -105,7 +106,7 @@ func main() {
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
logisticaHandler := logistica.NewHandler(logistica.NewService(queries, notificationService, cfg))
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
financeHandler := finance.NewHandler(finance.NewService(queries, profissionaisService))
financeHandler := finance.NewHandler(financeService)
r := gin.Default()

View file

@ -14,21 +14,25 @@ import (
"photum-backend/internal/db/generated"
"photum-backend/internal/notification"
"photum-backend/internal/finance"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
queries *generated.Queries
notification *notification.Service
cfg *config.Config
queries *generated.Queries
notification *notification.Service
financeService *finance.Service
cfg *config.Config
}
func NewService(db *generated.Queries, notif *notification.Service, cfg *config.Config) *Service {
func NewService(db *generated.Queries, notif *notification.Service, cfg *config.Config, fin *finance.Service) *Service {
return &Service{
queries: db,
notification: notif,
cfg: cfg,
queries: db,
notification: notif,
cfg: cfg,
financeService: fin,
}
}
@ -751,6 +755,87 @@ func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professi
return fmt.Errorf("conflito de horário: profissional já confirmou presença em outro evento às %s", c.Horario.String)
}
}
}
// --- AUTO-CREATE FINANCIAL TRANSACTION ---
// 1. Fetch Assignment to know the Function/Role
assign, err := s.queries.GetAssignment(ctx, generated.GetAssignmentParams{
AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
})
if err != nil {
log.Printf("[AutoFinance] Error fetching assignment: %v", err)
} else {
// 2. Determine Service Name
var serviceName string
if assign.FuncaoID.Valid {
fn, err := s.queries.GetFuncaoByID(ctx, assign.FuncaoID)
if err == nil {
serviceName = fn.Nome
}
}
// Fallback to professional's default function
prof, errProf := s.queries.GetProfissionalByID(ctx, generated.GetProfissionalByIDParams{
ID: pgtype.UUID{Bytes: professionalID, Valid: true},
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if serviceName == "" && errProf == nil && prof.FuncaoProfissionalID.Valid {
fn, err := s.queries.GetFuncaoByID(ctx, prof.FuncaoProfissionalID)
if err == nil {
serviceName = fn.Nome
}
}
// 3. Fetch Event Type Name
tipoEvento, errType := s.queries.GetTipoEventoByID(ctx, generated.GetTipoEventoByIDParams{
ID: agenda.TipoEventoID,
Regiao: pgtype.Text{String: regiao, Valid: true},
})
if serviceName != "" && errType == nil && errProf == nil {
// 4. Fetch Standard Price
price, errPrice := s.financeService.GetStandardPrice(ctx, tipoEvento.Nome, serviceName, regiao)
baseValue := 0.0
var priceNumeric pgtype.Numeric
if errPrice == nil {
priceNumeric = price
if val, err := price.Float64Value(); err == nil && val.Valid {
baseValue = val.Float64
}
} else {
log.Printf("[AutoFinance] Price not found for %s / %s: %v", tipoEvento.Nome, serviceName, errPrice)
// We continue with 0.0 or handle error?
// User wants it to appear. If 0, it appears as 0.
priceNumeric.Scan("0")
}
// 5. Create Transaction
// Check if already exists? (Optional, but good practice. For now simpler is better)
_, errCreate := s.financeService.Create(ctx, generated.CreateTransactionParams{
FotID: agenda.FotID,
DataCobranca: agenda.DataEvento,
TipoEvento: pgtype.Text{String: tipoEvento.Nome, Valid: true},
TipoServico: pgtype.Text{String: serviceName, Valid: true},
ProfessionalName: pgtype.Text{String: prof.Nome, Valid: true},
Whatsapp: prof.Whatsapp,
Cpf: prof.CpfCnpjTitular,
TotalPagar: priceNumeric, // Base Value
ValorFree: priceNumeric, // Populate Free value for frontend calc
ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
PgtoOk: pgtype.Bool{Bool: false, Valid: true},
// Others default/null
}, regiao)
if errCreate != nil {
log.Printf("[AutoFinance] Failed to create transaction: %v", errCreate)
} else {
log.Printf("[AutoFinance] Transaction created for %s: %.2f", prof.Nome, baseValue)
}
}
}
}
}

View file

@ -427,6 +427,33 @@ func (q *Queries) GetAgendaProfessionals(ctx context.Context, agendaID pgtype.UU
return items, nil
}
const getAssignment = `-- name: GetAssignment :one
SELECT id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, posicao, criado_em, is_coordinator FROM agenda_profissionais
WHERE agenda_id = $1 AND profissional_id = $2
`
type GetAssignmentParams struct {
AgendaID pgtype.UUID `json:"agenda_id"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
}
func (q *Queries) GetAssignment(ctx context.Context, arg GetAssignmentParams) (AgendaProfissionai, error) {
row := q.db.QueryRow(ctx, getAssignment, arg.AgendaID, arg.ProfissionalID)
var i AgendaProfissionai
err := row.Scan(
&i.ID,
&i.AgendaID,
&i.ProfissionalID,
&i.Status,
&i.MotivoRejeicao,
&i.FuncaoID,
&i.Posicao,
&i.CriadoEm,
&i.IsCoordinator,
)
return i, err
}
const listAgendas = `-- name: ListAgendas :many
SELECT
a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status, a.logistica_notificacao_enviada_em, a.regiao, a.contatos,

View file

@ -118,7 +118,7 @@ SELECT p.valor
FROM precos_tipos_eventos p
JOIN tipos_eventos te ON p.tipo_evento_id = te.id
JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
WHERE te.nome = $1 AND f.nome = $2 AND te.regiao = $3
WHERE te.nome ILIKE $1 AND f.nome ILIKE $2 AND te.regiao = $3
LIMIT 1
`

View file

@ -255,4 +255,8 @@ ORDER BY a.data_evento;
-- name: SetCoordinator :exec
UPDATE agenda_profissionais
SET is_coordinator = $3
WHERE agenda_id = $1 AND profissional_id = $2;
WHERE agenda_id = $1 AND profissional_id = $2;
-- name: GetAssignment :one
SELECT * FROM agenda_profissionais
WHERE agenda_id = $1 AND profissional_id = $2;

View file

@ -37,7 +37,7 @@ SELECT p.valor
FROM precos_tipos_eventos p
JOIN tipos_eventos te ON p.tipo_evento_id = te.id
JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id
WHERE te.nome = $1 AND f.nome = $2 AND te.regiao = @regiao
WHERE te.nome ILIKE $1 AND f.nome ILIKE $2 AND te.regiao = @regiao
LIMIT 1;
-- name: GetTipoEventoByNome :one

View file

@ -22,7 +22,7 @@ import {
AlertCircle,
Star,
} from "lucide-react";
import { setCoordinator, finalizeFOT } from "../services/apiService";
import { setCoordinator, finalizeFOT, getPrice } from "../services/apiService";
import { useAuth } from "../contexts/AuthContext";
import { useData } from "../contexts/DataContext";
import { STATUS_COLORS } from "../constants";
@ -151,6 +151,97 @@ export const Dashboard: React.FC<DashboardProps> = ({
const [teamStatusFilter, setTeamStatusFilter] = useState("all");
const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all");
const [roleSelectionProf, setRoleSelectionProf] = useState<Professional | null>(null);
const [basePrice, setBasePrice] = useState<number | null>(null);
useEffect(() => {
const fetchBasePrice = async () => {
if (!selectedEvent || !user || !token) {
setBasePrice(null);
return;
}
// Only for professionals (skip owners/admins unless they want to see it? User asked for professional view)
// If user is admin but viewing as professional context? No, user req specifically: "ao convidar um profissional... no painel dele"
if (user.role === UserRole.EVENT_OWNER || user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) {
setBasePrice(null);
return;
}
const currentProf = professionals.find(p => p.usuarioId === user.id);
if (!currentProf) return;
// Determine Service Name
let serviceName = "";
// 1. Check assignment
const assignment = selectedEvent.assignments?.find(a => a.professionalId === currentProf.id);
if (assignment && assignment.funcaoId && functions) {
const fn = functions.find(f => f.id === assignment.funcaoId);
if (fn) serviceName = fn.nome;
}
// 2. Fallback to professional functions/role
if (!serviceName) {
if (currentProf.functions && currentProf.functions.length > 0) {
// Use first function as default base? Or try to match?
// Simple approach: use first.
serviceName = currentProf.functions[0].nome;
} else {
serviceName = currentProf.role;
}
}
// Map legacy roles if needed (Backend expects names matching tipos_servicos)
// e.g. "fotografo" -> "Fotografia"? Check backend migration/seeds.
// Assuming names match or backend handles fuzzy match.
// Backend GetStandardPrice uses ILIKE on tipos_servicos.nome.
// "Fotógrafo" might need to map to "Fotografia".
// "Cinegrafista" -> "Cinegrafia".
// Let's try raw first, but maybe normalize if needed.
// Actually, let's map common ones just in case.
// Map legacy roles and English enums
const serviceLower = serviceName.toLowerCase();
if (serviceLower.includes("fot") || serviceLower === "photographer") serviceName = "Fotógrafo";
else if (serviceLower.includes("cine") || serviceLower === "videographer") serviceName = "Cinegrafista";
else if (serviceLower.includes("recep")) serviceName = "Recepcionista";
else if (serviceLower === "drone") serviceName = "Drone"; // Example
console.log("Fetch Base Price Debug:", {
currentProfId: currentProf.id,
assignmentFound: !!assignment,
rawService: serviceLower,
finalService: serviceName,
eventType: selectedEvent.type
});
if (serviceName && selectedEvent.type) {
try {
const res = await getPrice(token, selectedEvent.type, serviceName);
console.log("Fetch Base Price Result:", res);
if (res.data) {
setBasePrice(res.data.valor);
} else {
console.warn("Base Price returned no data");
setBasePrice(0);
}
} catch (err) {
console.error("Error fetching base price:", err);
setBasePrice(0);
}
} else {
console.warn("Skipping price fetch: Missing serviceName or eventType");
}
};
fetchBasePrice();
}, [selectedEvent, user, token, professionals, functions]);
// ... (existing code) ...
// Inside render (around line 1150 in original file, need to find the "Time" card)
// Look for: <div className="bg-gray-50 p-4 rounded-sm border border-gray-100"> ... Horário ... </div>
useEffect(() => {
if (initialView) {
@ -1023,6 +1114,17 @@ export const Dashboard: React.FC<DashboardProps> = ({
{selectedEvent.time}
</p>
</div>
{basePrice !== null && (
<div className={`p-4 rounded border ${basePrice > 0 ? "bg-green-50 border-green-200" : "bg-gray-50 border-gray-200"}`}>
<p className={`text-xs uppercase tracking-wide mb-1 font-bold ${basePrice > 0 ? "text-green-700" : "text-gray-500"}`}>
Valor Base
</p>
<p className={`font-bold ${basePrice > 0 ? "text-green-800" : "text-gray-700"}`}>
{basePrice > 0 ? basePrice.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }) : "R$ 0,00"}
</p>
</div>
)}
</div>
{/* FOT Information Table */}
@ -1309,6 +1411,18 @@ export const Dashboard: React.FC<DashboardProps> = ({
</>
)}
{/* Base Value Card for Professionals */}
{basePrice !== null && basePrice > 0 && (
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Valor Base
</td>
<td className="px-4 py-3 text-sm text-gray-900 font-bold text-green-700">
{basePrice.toLocaleString("pt-BR", { style: "currency", currency: "BRL" })}
</td>
</tr>
)}
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Horário

View file

@ -544,7 +544,7 @@ const Finance: React.FC = () => {
whatsapp: t.whatsapp,
cpf: t.cpf,
tabelaFree: t.tabelaFree,
valorFree: t.valorFree,
valorFree: t.valorFree ?? t.totalPagar, // Use Total as fallback if Free is null (legacy/auto records)
valorExtra: t.valorExtra,
descricaoExtra: t.descricaoExtra,
dataPgto: t.dataPgto,
@ -1074,8 +1074,8 @@ const Finance: React.FC = () => {
<td className="px-3 py-2">{t.whatsapp}</td>
<td className="px-3 py-2">{t.cpf}</td>
<td className="px-3 py-2">{t.tabelaFree}</td>
<td className="px-3 py-2 text-right">{t.valorFree?.toFixed(2)}</td>
<td className="px-3 py-2 text-right">{t.valorExtra?.toFixed(2)}</td>
<td className="px-3 py-2 text-right">{t.valorFree != null ? t.valorFree.toFixed(2) : "-"}</td>
<td className="px-3 py-2 text-right">{t.valorExtra != null ? t.valorExtra.toFixed(2) : "-"}</td>
<td className="px-3 py-2 max-w-[150px] truncate" title={t.descricaoExtra}>{t.descricaoExtra}</td>
<td className="px-3 py-2 text-right font-bold text-green-700">{t.totalPagar?.toFixed(2)}</td>
<td className="px-3 py-2">
@ -1292,13 +1292,13 @@ const Finance: React.FC = () => {
/>
</div>
{/* Values */}
{/* Values */}
<div>
<label className="block text-xs font-medium text-gray-700">Tabela Free</label>
{proFunctions.length > 0 ? (
<select
className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.tabelaFree}
value={formData.tabelaFree || ""}
onChange={e => {
setFormData({...formData, tabelaFree: e.target.value});
// Also set Tipo Servico to match? Or keep them separate?
@ -1311,20 +1311,28 @@ const Finance: React.FC = () => {
</select>
) : (
<input type="text" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.tabelaFree} onChange={e => setFormData({...formData, tabelaFree: e.target.value})}
value={formData.tabelaFree || ""} onChange={e => setFormData({...formData, tabelaFree: e.target.value})}
/>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Valor Free (R$)</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.valorFree} onChange={e => setFormData({...formData, valorFree: parseFloat(e.target.value)})}
value={formData.valorFree ?? ""}
onChange={e => {
const val = e.target.value;
setFormData({...formData, valorFree: val === "" ? null : parseFloat(val)});
}}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700">Valor Extra (R$)</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 mt-1"
value={formData.valorExtra} onChange={e => setFormData({...formData, valorExtra: parseFloat(e.target.value)})}
value={formData.valorExtra ?? ""}
onChange={e => {
const val = e.target.value;
setFormData({...formData, valorExtra: val === "" ? null : parseFloat(val)});
}}
/>
</div>

View file

@ -1391,6 +1391,31 @@ export async function getProfessionalFinancialStatement(token: string): Promise<
}
}
/**
* Busca o preço base para um dado evento e serviço
*/
export async function getPrice(token: string, eventName: string, serviceName: string): Promise<ApiResponse<{ valor: number }>> {
try {
const response = await fetch(`${API_BASE_URL}/api/finance/price?event=${encodeURIComponent(eventName)}&service=${encodeURIComponent(serviceName)}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
"x-regiao": localStorage.getItem("photum_selected_region") || "SP"
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return { data, error: null, isBackendDown: false };
} catch (error) {
console.error("Error fetching price:", error);
return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
}
}
export const setCoordinator = async (token: string, eventId: string, professionalId: string, isCoordinator: boolean) => {
try {
const region = localStorage.getItem("photum_selected_region") || "SP";