From e7e3d1beb1b49f34e9847690273d87f84e23b9fd Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 21 May 2026 09:30:06 -0300 Subject: [PATCH] agenda Fase B1: agendaBilling.service (read-only + helpers puros) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continua decomposicao da agenda (apos Fase A utils). Extrai pro service os componentes read-only / pure: - computeSeriePrice (puro) - generateOccurrenceDates (puro) - loadStatusChangeContext (read-only DB — assina supabase, ownerId, tenantId, row, eventoId, status) - needsStatusConfirmDialog (puro — depende so do ctx) useMelissaAgenda.js: 2792L -> 2593L (-199L). _loadStatusChangeContext agora e wrapper fino que injeta supabase/ownerId/tenantId do composable scope. _needsConfirmDialog vira alias direto. _computeSeriePrice/_generateOccurrenceDates importados direto. Fase B1 deixa Rail/Clínica capazes de reusar TODA a logica read-only de status change. Mutations (applyStatusDecisions, createPackageContract, materializeAndChargePerSession) ficam pra Fase B2. Risco: zero comportamental — toda chamada produz o mesmo ctx de antes. Codigo movido sem mudancas de logica. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agenda/services/agendaBilling.service.js | 295 ++++++++++++++++++ .../melissa/composables/useMelissaAgenda.js | 245 ++------------- 2 files changed, 318 insertions(+), 222 deletions(-) create mode 100644 src/features/agenda/services/agendaBilling.service.js diff --git a/src/features/agenda/services/agendaBilling.service.js b/src/features/agenda/services/agendaBilling.service.js new file mode 100644 index 0000000..ab6091a --- /dev/null +++ b/src/features/agenda/services/agendaBilling.service.js @@ -0,0 +1,295 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI — agendaBilling service (Fase B1) +|-------------------------------------------------------------------------- +| Helpers e loaders relacionados a billing da agenda, extraídos de +| useMelissaAgenda.js pra serem reusados em Rail/Clínica. +| +| Esta sessão (Fase B1) cobre só read-only + helpers puros: +| - computeSeriePrice (puro) +| - generateOccurrenceDates (puro) +| - loadStatusChangeContext (read-only DB) +| - needsStatusConfirmDialog (puro) +| +| Fase B2 (mutations) extrairá: applyStatusDecisions, createPackageContract, +| materializeAndChargePerSession. +| +| Convenção: funções recebem `supabase` explícito (não usa import direto) +| pra facilitar teste + reuso fora do contexto Vue. Nenhuma função aqui +| dispara toast — caller decide. +|-------------------------------------------------------------------------- +*/ + +import { dateToISO } from '@/features/agenda/utils/timeHelpers'; + +// ── Helpers puros ───────────────────────────────────────────────────────── + +/** + * Calcula o valor total da série a partir dos commitmentItems. + * + * @param {object} recorrencia { qtdSessoes, commitmentItems, serieValorMode } + * @returns {{ n, perSessao, packagePrice }} + */ +export function computeSeriePrice(recorrencia) { + const items = recorrencia?.commitmentItems || []; + const n = recorrencia?.qtdSessoes || 1; + const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0); + const pacoteFechado = recorrencia?.serieValorMode === 'dividir'; + return { + n, + perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao, + packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n + }; +} + +/** + * Gera lista de datas ISO ('YYYY-MM-DD') a partir de uma rule de recorrência. + * Pula datas em exceptionDates (Set). Para até `max` datas. Suporta weekly + * (interval=1 ou 2 pra quinzenal) e custom_weekdays. + * + * @param {object} rule { start_date, interval, weekdays, type } + * @param {number} max + * @param {Set} exceptionDates + * @returns {string[]} + */ +export function generateOccurrenceDates(rule, max, exceptionDates = new Set()) { + const dates = []; + const start = new Date(`${rule.start_date}T00:00:00`); + const interval = Math.max(1, rule.interval || 1); + const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length + ? rule.weekdays.map(Number) + : [start.getDay()]; + const isCustom = rule.type === 'custom_weekdays'; + + const cursor = new Date(start); + let safety = 0; + while (dates.length < max && safety < 365 * 3) { + const iso = dateToISO(cursor); + const dow = cursor.getDay(); + const inWeekdays = weekdays.includes(dow); + if (inWeekdays && !exceptionDates.has(iso)) { + dates.push(iso); + } + if (isCustom) { + cursor.setDate(cursor.getDate() + 1); + } else if (inWeekdays) { + cursor.setDate(cursor.getDate() + 7 * interval); + } else { + cursor.setDate(cursor.getDate() + 1); + } + safety++; + } + return dates; +} + +/** + * Decide se o dialog de confirmação de status change deve ser exibido. + * + * Pure: depende só do ctx montado por loadStatusChangeContext. + * + * Regras: + * - faltou/cancelado: mostra se há regra de exceção com charge_mode != 'none' + * OU pacote saldo/upfront + * - realizado: mostra se há pending record OU pacote saldo + * - agendado: (reverse) mostra se há artefatos a desfazer + */ +export function needsStatusConfirmDialog(status, ctx) { + const isFaltouOrCancel = status === 'faltou' || status === 'cancelado'; + const isRealizado = status === 'realizado'; + const isAgendado = status === 'agendado'; + const hasRegraComCobranca = ctx?.regraExcecao && ctx.regraExcecao.charge_mode !== 'none'; + const isPacoteSaldo = ctx?.billingContract?.charging_style === 'saldo'; + const isPacoteUpfront = ctx?.billingContract?.charging_style === 'upfront'; + const hasPending = !!ctx?.pendingRecord; + + if (isFaltouOrCancel) { + return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront; + } + if (isRealizado) { + return hasPending || isPacoteSaldo; + } + if (isAgendado) { + const r = ctx?.reverseArtifacts; + if (!r) return false; + const hasActiveRecords = (r.activeRecords?.length || 0) > 0; + return hasActiveRecords || r.saldoConsumed; + } + return false; +} + +// ── Loaders (read-only DB) ──────────────────────────────────────────────── + +/** + * Carrega contexto pra decisão de status change. + * + * Read-only. Não dispara toast (caller decide). Tolerante a erros parciais + * (loga warn e segue com null). + * + * @param {object} opts + * @param {object} opts.supabase instância do client + * @param {object} opts.row row do agenda_eventos (pode ser parcial — usa fallbacks) + * @param {string} opts.eventoId uuid (null pra ocorrências virtuais não materializadas) + * @param {string} opts.status 'realizado' | 'faltou' | 'cancelado' | 'agendado' + * @param {string} opts.ownerId auth.uid() (resolvido pelo caller) + * @param {string} opts.tenantId activeTenantId + * + * @returns {Promise<{ + * regraExcecao, + * billingContract, + * pendingRecord, + * existingPaidRecord, + * reverseArtifacts: { previousStatus, activeRecords, saldoConsumed } | null + * }>} + */ +export async function loadStatusChangeContext({ supabase, row, eventoId, status, ownerId, tenantId }) { + const ctx = { + regraExcecao: null, + billingContract: null, + pendingRecord: null, + existingPaidRecord: null, + reverseArtifacts: null + }; + + // 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation) + const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' }; + const excType = exceptionTypeMap[status]; + if (excType && tenantId) { + try { + const { data } = await supabase + .from('financial_exceptions') + .select('*') + .eq('tenant_id', tenantId) + .eq('exception_type', excType) + .or(`owner_id.eq.${ownerId},owner_id.is.null`) + .order('owner_id', { ascending: false, nullsLast: true }) + .limit(1) + .maybeSingle(); + ctx.regraExcecao = data ?? null; + } catch (e) { + console.warn('[agendaBilling] regra de exceção:', e?.message); + } + } + + // 2) Billing contract — 3 caminhos: row.billing_contract_id direto → query + // agenda_eventos.billing_contract_id (recém-materializada) → contrato + // ativo do paciente (virtuais). + const patientId = row?.patient_id ?? row?.paciente_id ?? null; + const contractId = row?.billing_contract_id ?? null; + if (contractId) { + try { + const { data } = await supabase + .from('billing_contracts') + .select('*') + .eq('id', contractId) + .maybeSingle(); + ctx.billingContract = data ?? null; + } catch (e) { + console.warn('[agendaBilling] contract via id direto:', e?.message); + } + } + if (!ctx.billingContract && eventoId) { + try { + const { data: ev } = await supabase + .from('agenda_eventos') + .select('billing_contract_id') + .eq('id', eventoId) + .maybeSingle(); + if (ev?.billing_contract_id) { + const { data: c } = await supabase + .from('billing_contracts') + .select('*') + .eq('id', ev.billing_contract_id) + .maybeSingle(); + ctx.billingContract = c ?? null; + } + } catch (e) { + console.warn('[agendaBilling] contract via agenda_evento:', e?.message); + } + } + if (!ctx.billingContract && patientId && tenantId) { + try { + const { data: c } = await supabase + .from('billing_contracts') + .select('*') + .eq('tenant_id', tenantId) + .eq('patient_id', patientId) + .eq('status', 'active') + .eq('type', 'package') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + ctx.billingContract = c ?? null; + } catch (e) { + console.warn('[agendaBilling] contract via patient_id:', e?.message); + } + } + + // 3) Pending record + if (eventoId) { + try { + const { data } = await supabase + .from('financial_records') + .select('*') + .eq('agenda_evento_id', eventoId) + .in('status', ['pending', 'overdue']) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + ctx.pendingRecord = data ?? null; + } catch (e) { + console.warn('[agendaBilling] pending record:', e?.message); + } + } + + // 3b) Paid record pré-existente (caso C12: antecipar pagamento). + if (eventoId) { + try { + const { data } = await supabase + .from('financial_records') + .select('id, status, amount, final_amount, paid_at, payment_method') + .eq('agenda_evento_id', eventoId) + .eq('status', 'paid') + .order('paid_at', { ascending: false }) + .limit(1) + .maybeSingle(); + ctx.existingPaidRecord = data ?? null; + } catch (e) { + console.warn('[agendaBilling] existing paid record:', e?.message); + } + } + + // 4) Reverse transition (status novo='agendado'): artefatos a desfazer. + if (status === 'agendado' && eventoId) { + ctx.reverseArtifacts = { + previousStatus: row?.status || null, + activeRecords: [], + saldoConsumed: false + }; + try { + const { data: evRow } = await supabase + .from('agenda_eventos') + .select('status, billing_contract_id') + .eq('id', eventoId) + .maybeSingle(); + if (evRow) { + ctx.reverseArtifacts.previousStatus = evRow.status; + } + const { data: recs } = await supabase + .from('financial_records') + .select('id, status, amount, final_amount, description, paid_at, payment_method') + .eq('agenda_evento_id', eventoId) + .neq('status', 'cancelled') + .order('created_at', { ascending: false }); + ctx.reverseArtifacts.activeRecords = recs || []; + // Heurística saldo consumido: billing_contract_id + previousStatus + // ≠ 'agendado' + style=saldo. Falso positivo é mitigado pela escolha + // do user no dialog de "devolver saldo". + const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado'; + ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo'; + } catch (e) { + console.warn('[agendaBilling] reverse artifacts:', e?.message); + } + } + + return ctx; +} diff --git a/src/layout/melissa/composables/useMelissaAgenda.js b/src/layout/melissa/composables/useMelissaAgenda.js index 39bc372..b579346 100644 --- a/src/layout/melissa/composables/useMelissaAgenda.js +++ b/src/layout/melissa/composables/useMelissaAgenda.js @@ -52,6 +52,14 @@ import { pickDbFields } from '@/features/agenda/utils/dbFields'; import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers'; import { pickColor } from '@/features/agenda/utils/colors'; +// ─── Service de billing (Fase B1: read-only + helpers puros) ──────────────── +import { + computeSeriePrice as _computeSeriePrice, + generateOccurrenceDates as _generateOccurrenceDates, + loadStatusChangeContext, + needsStatusConfirmDialog +} from '@/features/agenda/services/agendaBilling.service'; + function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) { // r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand // (ocorrência virtual com is_occurrence=true e id "rec::uuid::date"). @@ -1267,186 +1275,22 @@ function _buildHandlers(deps) { } // Carrega contexto pra decidir se mostra dialog e quais blocos renderizar. + // Wrapper fino sobre o service (Fase B1) — injeta supabase, ownerId, tenantId + // do escopo do composable. Lógica pura mora em agendaBilling.service. async function _loadStatusChangeContext({ row, eventoId, status }) { - const ctx = { regraExcecao: null, billingContract: null, pendingRecord: null }; - - // 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation) - const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' }; - const excType = exceptionTypeMap[status]; - if (excType && clinicTenantId.value) { - try { - const { data } = await supabase - .from('financial_exceptions') - .select('*') - .eq('tenant_id', clinicTenantId.value) - .eq('exception_type', excType) - .or(`owner_id.eq.${ownerId.value},owner_id.is.null`) - .order('owner_id', { ascending: false, nullsLast: true }) - .limit(1) - .maybeSingle(); - ctx.regraExcecao = data ?? null; - } catch (e) { - console.warn('[Fase5] erro carregando regra de exceção:', e?.message); - } - } - - // 2) Billing contract — tenta 3 caminhos: - // (a) row.billing_contract_id direto (sessão real materializada) - // (b) eventoId real → query agenda_eventos.billing_contract_id - // (c) ocorrência virtual (sem id real) → busca contrato ativo do paciente - const patientId = row.patient_id ?? row.paciente_id ?? null; - const contractId = row.billing_contract_id ?? null; - if (contractId) { - try { - const { data } = await supabase.from('billing_contracts').select('*').eq('id', contractId).maybeSingle(); - ctx.billingContract = data ?? null; - } catch (e) { - console.warn('[Fase5] erro contract via id direto:', e?.message); - } - } - if (!ctx.billingContract && eventoId) { - // Sessão real materializada — pode ter billing_contract_id no DB mesmo - // que a row passada não tenha (caso de virtual recém-materializada). - try { - const { data: ev } = await supabase.from('agenda_eventos').select('billing_contract_id').eq('id', eventoId).maybeSingle(); - if (ev?.billing_contract_id) { - const { data: c } = await supabase.from('billing_contracts').select('*').eq('id', ev.billing_contract_id).maybeSingle(); - ctx.billingContract = c ?? null; - } - } catch (e) { - console.warn('[Fase5] erro contract via agenda_evento:', e?.message); - } - } - if (!ctx.billingContract && patientId && clinicTenantId.value) { - // Ocorrência virtual da Anna Freud cai aqui: busca contrato ativo - // do paciente. MVP assume 1 contrato active por paciente; pega o - // mais recente caso haja mais de um. - try { - const { data: c } = await supabase - .from('billing_contracts') - .select('*') - .eq('tenant_id', clinicTenantId.value) - .eq('patient_id', patientId) - .eq('status', 'active') - .eq('type', 'package') - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); - ctx.billingContract = c ?? null; - } catch (e) { - console.warn('[Fase5] erro contract via patient_id:', e?.message); - } - } - - // 3) Pending record (se evento já existe e tem cobrança pendente) - if (eventoId) { - try { - const { data } = await supabase - .from('financial_records') - .select('*') - .eq('agenda_evento_id', eventoId) - .in('status', ['pending', 'overdue']) - .order('created_at', { ascending: false }) - .limit(1) - .maybeSingle(); - ctx.pendingRecord = data ?? null; - } catch (e) { - console.warn('[Fase5] erro pending record:', e?.message); - } - } - - // 3b) Paid record pré-existente (caso C12: antecipar pagamento). - // Quando user antecipou paga ANTES de marcar Realizada, o record paid - // já existe ao tempo do status change. Dialog precisa saber pra: - // - Não oferecer "Gerar cobrança nova" (geraria duplicidade) - // - Ainda incrementar sessions_used (a sessão consome saldo do pacote) - if (eventoId) { - try { - const { data } = await supabase - .from('financial_records') - .select('id, status, amount, final_amount, paid_at, payment_method') - .eq('agenda_evento_id', eventoId) - .eq('status', 'paid') - .order('paid_at', { ascending: false }) - .limit(1) - .maybeSingle(); - ctx.existingPaidRecord = data ?? null; - } catch (e) { - console.warn('[Fase5] erro existing paid record:', e?.message); - } - } - - // 4) Reverse transition (status novo='agendado'): carrega artefatos - // a desfazer — current status + ALL records ativos + saldo consumido. - // Sem isso, voltar pra agendado deixa multa/record/saldo órfão. - if (status === 'agendado' && eventoId) { - ctx.reverseArtifacts = { - previousStatus: row?.status || null, - activeRecords: [], - saldoConsumed: false - }; - try { - // Status atual do DB (fonte autoritativa, row pode estar stale) - const { data: evRow } = await supabase - .from('agenda_eventos') - .select('status, billing_contract_id') - .eq('id', eventoId) - .maybeSingle(); - if (evRow) { - ctx.reverseArtifacts.previousStatus = evRow.status; - } - // Todos records NÃO cancelled vinculados (pending + overdue + paid) - const { data: recs } = await supabase - .from('financial_records') - .select('id, status, amount, final_amount, description, paid_at, payment_method') - .eq('agenda_evento_id', eventoId) - .neq('status', 'cancelled') - .order('created_at', { ascending: false }); - ctx.reverseArtifacts.activeRecords = recs || []; - // Detecta saldo consumido: evento pertence a pacote saldo e - // está em status que tipicamente consome (realizado, ou faltou/ - // cancelado se default_consume_on_miss=true e foi aplicado). - // Heurística simples: se billing_contract_id está set + style=saldo - // + status anterior ≠ 'agendado', assume consumido. Se for falso - // positivo, user pode escolher "não devolver" no dialog. - const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado'; - ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo'; - } catch (e) { - console.warn('[Fase5] erro reverse artifacts:', e?.message); - } - } - - return ctx; + return loadStatusChangeContext({ + supabase, + row, + eventoId, + status, + ownerId: ownerId.value, + tenantId: clinicTenantId.value + }); } - // Precisa dialog? Sim se há regra de exceção com charge_mode != 'none' - // OU pacote saldo OU pacote upfront OU pending record (realizado). - function _needsConfirmDialog(status, ctx) { - const isFaltouOrCancel = status === 'faltou' || status === 'cancelado'; - const isRealizado = status === 'realizado'; - const isAgendado = status === 'agendado'; - const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none'; - const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo'; - const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront'; - const hasPending = !!ctx.pendingRecord; - - if (isFaltouOrCancel) { - // Mostra se há regra ou se é pacote saldo (pra perguntar consume) - return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront; - } - if (isRealizado) { - // Mostra se há pending (avulsa) ou pacote saldo (cobrança nova) - return hasPending || isPacoteSaldo; - } - if (isAgendado) { - // Reverse transition: mostra se há artefatos a desfazer - const r = ctx.reverseArtifacts; - if (!r) return false; - const hasActiveRecords = (r.activeRecords?.length || 0) > 0; - return hasActiveRecords || r.saldoConsumed; - } - return false; - } + // _needsConfirmDialog (Fase B1): alias local pra needsStatusConfirmDialog + // do agendaBilling.service. Pure — sem deps de composable state. + const _needsConfirmDialog = needsStatusConfirmDialog; // Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse). async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) { @@ -2511,18 +2355,7 @@ function _buildOnDialogDelete(deps) { const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); -// Calcula valor total da série a partir dos commitmentItems. -function _computeSeriePrice(recorrencia) { - const items = recorrencia.commitmentItems || []; - const n = recorrencia.qtdSessoes; - const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0); - const pacoteFechado = recorrencia.serieValorMode === 'dividir'; - return { - n, - perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao, - packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n - }; -} +// _computeSeriePrice extraído pra agendaBilling.service (Fase B1) — import no topo. // chargeMode='package' — 2 estilos (2026-05-14): // - 'upfront' (default): cria billing_contract + materializa 1ª ocorrência @@ -2756,37 +2589,5 @@ async function _materializeAndChargePerSession({ rule, normalized, recorrencia, } } -// Gera lista de datas ISO ('YYYY-MM-DD') a partir da regra. Pula datas -// em exceptionDates (Set). Para até `max` datas. Suporta weekly (interval=1 -// ou 2 pra quinzenal) e custom_weekdays. -function _generateOccurrenceDates(rule, max, exceptionDates) { - const dates = []; - const start = new Date(`${rule.start_date}T00:00:00`); - const interval = Math.max(1, rule.interval || 1); - const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length ? rule.weekdays.map(Number) : [start.getDay()]; - const isCustom = rule.type === 'custom_weekdays'; - - const cursor = new Date(start); - let safety = 0; - // Para weekly (interval=1), avança 7 dias por iteração. Quinzenal: 14. - // Para custom_weekdays, avança 1 dia e filtra weekdays.includes. - while (dates.length < max && safety < 365 * 3) { - const iso = _dateToISO(cursor); - const dow = cursor.getDay(); - const inWeekdays = weekdays.includes(dow); - if (inWeekdays && !exceptionDates.has(iso)) { - dates.push(iso); - } - if (isCustom) { - cursor.setDate(cursor.getDate() + 1); - } else if (inWeekdays) { - // weekly/quinzenal: ao bater o dow, pula interval semanas - cursor.setDate(cursor.getDate() + 7 * interval); - } else { - cursor.setDate(cursor.getDate() + 1); - } - safety++; - } - return dates; -} +// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo. // _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.