diff --git a/database-novo/manual/f6_2g_sql_to_plpgsql.supabase_admin.sql b/database-novo/manual/f6_2g_sql_to_plpgsql.supabase_admin.sql new file mode 100644 index 0000000..e537240 --- /dev/null +++ b/database-novo/manual/f6_2g_sql_to_plpgsql.supabase_admin.sql @@ -0,0 +1,126 @@ +-- ============================================================================= +-- F6.2 Lote G — funções SQL puras → plpgsql + roteamento por tenant +-- +-- ⚠️ APLICAR COMO supabase_admin. +-- +-- SQL puro não permite set_config dinâmico do search_path (limitação 3 do +-- blueprint) → converter pra plpgsql. Adicionam p_tenant_id + _tenant_route. +-- RETURNS SETOF → jsonb. get_entity_primary_phone (interno, +-- 0 callers) herda search_path do chamador (sem SET, unqualified). +-- ============================================================================= + +BEGIN; + +DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, integer, integer); +DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, uuid, integer, integer); +CREATE FUNCTION public.get_financial_summary(p_tenant_id uuid, p_owner_id uuid, p_year integer, p_month integer) +RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); + RETURN QUERY + SELECT + COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0), + COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0), + COALESCE(SUM(amount) FILTER (WHERE status IN ('pending','overdue')), 0), + COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0) - COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0), + COALESCE(SUM(clinic_fee_amount) FILTER (WHERE type='receita' AND status='paid'), 0), + COUNT(*) FILTER (WHERE type='receita' AND deleted_at IS NULL), + COUNT(*) FILTER (WHERE type='despesa' AND deleted_at IS NULL) + FROM financial_records + WHERE owner_id = p_owner_id AND deleted_at IS NULL + AND EXTRACT(YEAR FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_year + AND EXTRACT(MONTH FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_month; +END $$; + +-- list_financial_records: RETURNS SETOF financial_records → jsonb (array) +DROP FUNCTION IF EXISTS public.list_financial_records(uuid, integer, integer, text, text, uuid, integer, integer); +DROP FUNCTION IF EXISTS public.list_financial_records(uuid, uuid, integer, integer, text, text, uuid, integer, integer); +CREATE FUNCTION public.list_financial_records(p_tenant_id uuid, p_owner_id uuid, p_year integer DEFAULT NULL, p_month integer DEFAULT NULL, p_type text DEFAULT NULL, p_status text DEFAULT NULL, p_patient_id uuid DEFAULT NULL, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0) +RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +DECLARE v_result jsonb; +BEGIN + PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); + SELECT COALESCE(jsonb_agg(row_json), '[]'::jsonb) INTO v_result FROM ( + SELECT to_jsonb(fr) AS row_json + FROM financial_records fr + WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL + AND (p_type IS NULL OR fr.type::text = p_type) + AND (p_status IS NULL OR fr.status = p_status) + AND (p_patient_id IS NULL OR fr.patient_id = p_patient_id) + AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_year) + AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_month) + ORDER BY COALESCE(fr.paid_at, fr.due_date::timestamptz, fr.created_at) DESC + LIMIT p_limit OFFSET p_offset + ) sub; + RETURN v_result; +END $$; + +DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid[]); +DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid, uuid[]); +CREATE FUNCTION public.get_patient_session_counts(p_tenant_id uuid, p_patient_ids uuid[]) +RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); + RETURN QUERY + SELECT ae.patient_id, COUNT(*)::int, MAX(ae.inicio_em) + FROM agenda_eventos ae + WHERE ae.patient_id = ANY(p_patient_ids) + GROUP BY ae.patient_id; +END $$; + +DROP FUNCTION IF EXISTS public.get_financial_report(uuid, date, date, text); +DROP FUNCTION IF EXISTS public.get_financial_report(uuid, uuid, date, date, text); +CREATE FUNCTION public.get_financial_report(p_tenant_id uuid, p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month') +RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp' +AS $$ +BEGIN + PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true); + RETURN QUERY + WITH base AS ( + SELECT fr.type, fr.amount, fr.final_amount, fr.status, fr.deleted_at, + CASE p_group_by + WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM') + WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW') + WHEN 'category' THEN COALESCE(fr.category_id::text, fr.category, 'sem_categoria') + WHEN 'patient' THEN COALESCE(fr.patient_id::text, 'sem_paciente') + ELSE NULL END AS gkey, + CASE p_group_by + WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM') + WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW') + WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria') + WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::text, 'Sem paciente') + ELSE NULL END AS glabel + FROM financial_records fr + LEFT JOIN financial_categories fc ON fc.id = fr.category_id + LEFT JOIN patients p ON p.id = fr.patient_id + WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL + AND COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date) BETWEEN p_start_date AND p_end_date + ) + SELECT gkey, glabel, + COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0), + COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0), + COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0) - COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0), + COALESCE(SUM(final_amount) FILTER (WHERE status='pending'),0), + COALESCE(SUM(final_amount) FILTER (WHERE status='overdue'),0), + COUNT(*) + FROM base WHERE gkey IS NOT NULL GROUP BY gkey, glabel ORDER BY gkey ASC; +END $$; + +-- get_entity_primary_phone: interno (0 callers). Sem SET search_path → herda do +-- chamador (que roteia pro schema). Unqualified. Mantém assinatura. +CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid) +RETURNS text LANGUAGE sql STABLE SECURITY DEFINER +AS $$ + SELECT number FROM contact_phones + WHERE entity_type = p_entity_type AND entity_id = p_entity_id + ORDER BY is_primary DESC, position ASC, created_at ASC + LIMIT 1; +$$; + +COMMIT; diff --git a/src/features/financeiro/pages/FinanceiroDashboardPage.vue b/src/features/financeiro/pages/FinanceiroDashboardPage.vue index 7a1a9c3..bf3fb73 100644 --- a/src/features/financeiro/pages/FinanceiroDashboardPage.vue +++ b/src/features/financeiro/pages/FinanceiroDashboardPage.vue @@ -21,8 +21,10 @@ import { useRouter } from 'vue-router'; import { supabase } from '@/lib/supabase/client'; import { tenantDb } from '@/lib/supabase/tenantClient'; +import { useTenantStore } from '@/stores/tenantStore'; // ─── helpers ───────────────────────────────────────────────────────────────── const router = useRouter(); +const tenantStore = useTenantStore(); const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }); function fmtBRL(v) { @@ -67,6 +69,7 @@ async function loadSummary(uid) { try { // Receitas e despesas pagas no mês via RPC const { data: rpc } = await supabase.rpc('get_financial_summary', { + p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_year: year, p_month: month @@ -117,7 +120,7 @@ async function loadChart(uid) { chartLoading.value = true; const months = getLast6Months(); try { - const results = await Promise.all(months.map((m) => supabase.rpc('get_financial_summary', { p_owner_id: uid, p_year: m.year, p_month: m.month }))); + const results = await Promise.all(months.map((m) => supabase.rpc('get_financial_summary', { p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_year: m.year, p_month: m.month }))); const receitas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0)); const despesas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_despesas ?? 0)); @@ -169,6 +172,7 @@ async function loadRecent(uid) { recentLoading.value = true; try { const { data } = await supabase.rpc('list_financial_records', { + p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_limit: 5, p_offset: 0 diff --git a/src/features/patients/services/patientsRepository.js b/src/features/patients/services/patientsRepository.js index 594f339..9ba4753 100644 --- a/src/features/patients/services/patientsRepository.js +++ b/src/features/patients/services/patientsRepository.js @@ -510,7 +510,8 @@ export async function markIntakeConverted(intakeId, patientId, { tenantId } = {} */ export async function getSessionCounts(patientIds) { if (!patientIds?.length) return []; - const { data, error } = await supabase.rpc('get_patient_session_counts', { p_patient_ids: patientIds }); + const tid = resolveTenantId(); + const { data, error } = await supabase.rpc('get_patient_session_counts', { p_tenant_id: tid, p_patient_ids: patientIds }); if (error) throw error; return data || []; } diff --git a/src/layout/melissa/MelissaFinanceiro.vue b/src/layout/melissa/MelissaFinanceiro.vue index 84cdaf8..68a0f64 100644 --- a/src/layout/melissa/MelissaFinanceiro.vue +++ b/src/layout/melissa/MelissaFinanceiro.vue @@ -19,10 +19,12 @@ import { ref, computed, onMounted } from 'vue'; import { useRouter } from 'vue-router'; import { supabase } from '@/lib/supabase/client'; import { tenantDb } from '@/lib/supabase/tenantClient'; +import { useTenantStore } from '@/stores/tenantStore'; // Chart/DataTable/Column/Skeleton/Tag: auto via PrimeVueResolver const emit = defineEmits(['close']); const router = useRouter(); +const tenantStore = useTenantStore(); // ── Helpers ──────────────────────────────────────────── const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }); @@ -72,7 +74,7 @@ async function loadSummary(uid) { const month = now.getMonth() + 1; try { const { data: rpc } = await supabase.rpc('get_financial_summary', { - p_owner_id: uid, p_year: year, p_month: month + p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_year: year, p_month: month }); const s = Array.isArray(rpc) ? rpc[0] : rpc; totalRecebido.value = Number(s?.total_receitas ?? 0); @@ -122,7 +124,7 @@ async function loadChart(uid) { try { const results = await Promise.all( months.map((m) => supabase.rpc('get_financial_summary', { - p_owner_id: uid, p_year: m.year, p_month: m.month + p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_year: m.year, p_month: m.month })) ); const receitas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0)); @@ -178,7 +180,7 @@ async function loadRecent(uid) { recentLoading.value = true; try { const { data } = await supabase.rpc('list_financial_records', { - p_owner_id: uid, p_limit: 5, p_offset: 0 + p_tenant_id: tenantStore.activeTenantId, p_owner_id: uid, p_limit: 5, p_offset: 0 }); recentRecords.value = data ?? []; } finally {