feat(backend): implementa criação automática de transação financeira ao aceitar evento
This commit is contained in:
parent
c9e34af619
commit
6382145442
9 changed files with 285 additions and 21 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue