agenda Fase B1: agendaBilling.service (read-only + helpers puros)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>} 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;
|
||||
}
|
||||
Reference in New Issue
Block a user