agenda: C7 OK + Fase 6 lock-edit ativada em Melissa + cross-week payment propagation

Cenário 7 (Pacote UPFRONT — Ana Souza Ferreira 4×R$ 200 = R$ 800)
- Testado e passou. User criou Ana, pagou os R$ 800 em dinheiro pelo
  Financeiro. Borda verde + popover "Pago R$ 800" funcionando.

Fase 6 (lock-edit cobrada) ativada em Melissa
- Removido guard `if (!props.occurrenceMode) return;` em
  loadOccFinancialRecord (useAgendaEventLifecycle.js:217+). Agora ele
  carrega em ambos modos (Rail/Clínica E Melissa)
- loadOccFinancialRecord SINTETIZA record paid/pending pra siblings de
  contrato upfront ativo — assim TODAS as ocorrências da série mostram
  "Cobrança paga R$ 800 do pacote" no AgendaEventDialog
- AgendaEventDialog card Sessão/Honorários (flow Melissa) ganhou lock
  template: Tag em vez de Select billingType quando occFinancialRecord
  existe; Message com cadeado "Cobrança de R$ X já emitida"
- AgendaEventoFinanceiroPanel só renderiza dentro do lock quando record
  é REAL (não sintetizado) — evita "Gerar cobrança" indevido em sibling
- paymentSummary do Resumo lateral unificado pra usar occFinancialRecord
  (em vez do sessionPaymentRecord paralelo de antes)

Cross-week propagation de pacote upfront
- BUG: ao navegar pra semana só com virtuais (sem reais), bulk-load
  caía no else `_rulePaymentMap.value = {}` — virtuais perdiam estado
  paid herdado
- FIX em useMelissaAgenda._reloadRange:
  * Maps (payment/amount/rule) inicializados SEMPRE no início
  * Propagação roda independente de realIds.length (depende só de
    ruleIdsInView.size>0, considera reais E virtuais com recurrence_id)
  * Query cross-week: pra cada rule em view, busca QUALQUER evento
    sibling em qualquer semana + seus records pra determinar estado do
    contrato. Encontra o record do pacote mesmo em outra semana
- Saldo NÃO propaga (filter: charging_style='upfront' || NULL); cada
  sessão de saldo gera cobrança individual ao realizar
- Memória durável: memory/project_cross_week_propagation.md

Visualização de virtuais cobertas
- MelissaEventoPanel.showPaymentRow: virtuais só escondem quando state
  ='none'. Com paid/pending herdado, exibem linha colorida
- MelissaAgenda fcEvents: isPaidSession e badge $ pendente removeram
  exigência de !is_occurrence. Virtuais herdadas via propagação mostram
  borda verde / badge amber

Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" quando paymentVariant
  ='none' && !is_occurrence. Click → gerarCobrancaManual direto, fecha
  popover pra impedir double-click. Tooltip: "Gerar fatura agora"
- Wire em MelissaLayout via novo emit gerar-cobranca + handler
  onGerarCobrancaQuick

Info de pacote no popover
- Header agora mostra "Sessão · Pacote · N sessões" (computed
  seriesLabel lê de _raw do rule)

Botão "Excluir série inteira"
- Novo emit delete-series em MelissaEventoPanel + botão ao lado de
  "Excluir sessão" quando evento tem recurrence_id
- Handler onDeleteSeries em MelissaLayout: hard delete em 3 etapas
  (financial_records pendentes → agenda_eventos materializados →
  recurrence_rules CASCADE leva exceptions). Bloqueia se algum record
  paid (estorno via Financeiro primeiro)

cancel_session some da agenda
- useRecurrence.expandRules agora pula occurrence com exception.type=
  'cancel_session' (era visível com status cancelado; doc dizia que
  some). patient_missed/therapist_canceled/holiday_block permanecem
  como histórico

recurrence_exceptions cancel idempotente
- MelissaLayout onDeleteEvento usa upsert com onConflict pra exception
  cancel — não quebra mais com unique violation em re-cancel

billing_contract_id na 1ª materializada
- _createPackageContract agora .select() o contrato após insert e seta
  billing_contract_id no insert da 1ª agenda_eventos materializada

onVerLancamentos cobre virtual de upfront
- Antes virtual sempre toast "Sem lançamentos". Agora busca records via
  siblings da série pra encontrar o do pacote. Saldo/sem pacote continua
  com toast

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 20:54:23 -03:00
parent c23d0a574f
commit 1feb7112ff
8 changed files with 530 additions and 95 deletions
@@ -117,7 +117,7 @@ function isoToDecimalHour(iso) {
return d.getHours() + d.getMinutes() / 60;
}
function normalizeForMelissa(r, paymentStateMap = null) {
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = 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").
const isOccurrence = !!r.is_occurrence;
@@ -128,8 +128,17 @@ function normalizeForMelissa(r, paymentStateMap = null) {
// _reloadRange via bulk-query em financial_records). Default 'none'
// pra ocorrências virtuais (sem id real ainda) e eventos sem record.
// 'paid' | 'pending' | 'none'.
const paymentState =
(!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || 'none';
// Fallback pra virtuais ou reais sem record: herda do contrato upfront
// pago da série via _rulePaymentMap (chave = recurrence_id).
let paymentState =
(!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || null;
let paymentAmount =
!isOccurrence && r.id && paymentAmountMap ? (paymentAmountMap[r.id] ?? null) : null;
if ((!paymentState || paymentState === 'none') && r.recurrence_id && rulePaymentMap && rulePaymentMap[r.recurrence_id]) {
paymentState = rulePaymentMap[r.recurrence_id].state;
if (paymentAmount == null) paymentAmount = rulePaymentMap[r.recurrence_id].amount;
}
if (!paymentState) paymentState = 'none';
return {
id: r.id,
@@ -151,6 +160,7 @@ function normalizeForMelissa(r, paymentStateMap = null) {
recurrence_id: r.recurrence_id ?? null,
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
paymentState,
paymentAmount,
price: r.price != null ? Number(r.price) : null,
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
// price). Popover e Resumo usam fallback `price ?? insurance_value`
@@ -333,9 +343,17 @@ export function useMelissaAgenda() {
// badge "$ pendente" no FC e linha "A receber" no popover sem ter que
// queryar por evento. 'paid' | 'pending'. Eventos não listados = 'none'.
const _paymentStateMap = ref({});
// Map evento_id → valor pago/cobrado (final_amount do record). Usado pelo
// popover e Resumo do dialog quando paymentState='paid' pra mostrar o
// valor REAL pago (vs evento.price que pode ter sido editado depois).
const _paymentAmountMap = ref({});
// Map recurrence_id → {state, amount}. Pra ocorrências virtuais (que não
// têm id real e portanto não entram em _paymentStateMap), normalize lê
// daqui o estado herdado do contrato upfront pago da série.
const _rulePaymentMap = ref({});
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value)));
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value)));
// ── Eventos do FC original (se precisar — AgendaEventDialog quer
// `allEvents` no shape FC pra checar conflitos) ─────────
@@ -462,16 +480,21 @@ export function useMelissaAgenda() {
// Não bloqueia render — eventos aparecem sem badge até este await
// resolver (default 'none').
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
// Inicializa maps SEMPRE (não condicional a realIds.length>0) — em
// semanas onde só há virtuais (sem eventos reais), ainda precisamos
// rodar a propagação cross-week pra ruleMap.
const map = {};
const amountMap = {};
const ruleMap = {};
// Filtra cancelados: cobrança cancelada não deve manter
// paymentState='pending' (badge $ residual). Tratamos cancelled
// como "sem cobrança ativa" → cai pro default 'none'.
if (realIds.length) {
// Filtra cancelados: cobrança cancelada não deve manter
// paymentState='pending' (badge $ residual). Tratamos cancelled
// como "sem cobrança ativa" → cai pro default 'none'.
const { data: recs } = await supabase
.from('financial_records')
.select('agenda_evento_id, paid_at, status')
.select('agenda_evento_id, paid_at, status, amount, final_amount')
.in('agenda_evento_id', realIds)
.neq('status', 'cancelled');
const map = {};
for (const id of realIds) map[id] = 'none';
for (const rec of recs || []) {
const eid = rec.agenda_evento_id;
@@ -481,14 +504,147 @@ export function useMelissaAgenda() {
// consideramos cobrança honrada. Filhos (multas/taxas) que
// venham pendentes não revertem esse estado pro badge.
map[eid] = 'paid';
amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
} else if (map[eid] !== 'paid') {
map[eid] = 'pending';
if (amountMap[eid] == null) amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
}
}
_paymentStateMap.value = map;
} else {
_paymentStateMap.value = {};
}
// Propagação de pacote UPFRONT — SEMPRE roda (mesmo sem eventos reais
// na view), pra que virtuais isoladas em semanas futuras herdem o
// estado do contrato cross-week. Antes estava dentro de
// `if (realIds.length)` e falhava em semanas só com virtuais.
//
// Quando a 1ª materializada
// de uma série tem record (paid OU pending), TODAS as ocorrências
// do mesmo recurrence_id devem herdar o mesmo estado (cobertas
// pelo contrato — cobrança única do pacote inteiro).
// Funciona pra real rows; virtuais ficam por loadOccFinancialRecord
// no occurrenceMode (não passam por este bulk).
// Estratégia: pega rules dos eventos com record OU dos eventos
// visíveis que têm recurrence_id (cross-week — record do pacote
// pode estar em outra semana), busca contratos upfront ativos
// pros pacientes, propaga estado pra siblings no view atual.
try {
// 1) Coleta rule_ids de TODOS os eventos visíveis que pertencem
// a uma série. Antes só usávamos eventos COM record paid/pending,
// o que falhava cross-week (record do pacote pode estar em
// outra semana). Agora propagação cobre Ana 2/3/4 mesmo
// quando o record está só no Ana 1.
const ruleIdsInView = new Set();
for (const r of rows.value || []) {
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
}
// Virtuais expandidas (que vêm depois) também trazem rules:
for (const r of _occurrenceRows.value || []) {
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
}
if (ruleIdsInView.size) {
// 2) Acha QUALQUER evento (em qualquer semana) das rules
// em view + seus records paid/pending pra detectar o
// estado do contrato.
const { data: allRuleEvents } = await supabase
.from('agenda_eventos')
.select('id, recurrence_id, patient_id')
.in('recurrence_id', [...ruleIdsInView]);
const ruleEventIds = (allRuleEvents || []).map((e) => e.id);
let ruleRecords = [];
if (ruleEventIds.length) {
const { data: rr } = await supabase
.from('financial_records')
.select('agenda_evento_id, paid_at, status')
.in('agenda_evento_id', ruleEventIds)
.neq('status', 'cancelled');
ruleRecords = rr || [];
}
// Mapeia: rule_id → state (paid > pending > none) + patient
const evIdToRule = new Map();
const evIdToPatient = new Map();
for (const e of allRuleEvents || []) {
evIdToRule.set(e.id, e.recurrence_id);
evIdToPatient.set(e.id, e.patient_id);
}
const ruleToPatient = new Map();
const ruleToState = new Map();
for (const rec of ruleRecords) {
const rid = evIdToRule.get(rec.agenda_evento_id);
const pid = evIdToPatient.get(rec.agenda_evento_id);
if (!rid || !pid) continue;
ruleToPatient.set(rid, pid);
const newState = rec.paid_at ? 'paid' : 'pending';
const existing = ruleToState.get(rid);
if (existing !== 'paid') ruleToState.set(rid, newState);
}
if (ruleToPatient.size) {
// Confirma contratos upfront pros pacientes envolvidos
const patientIds = [...new Set(ruleToPatient.values())];
const { data: contracts } = await supabase
.from('billing_contracts')
.select('patient_id, charging_style, status, type')
.in('patient_id', patientIds);
const activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
// NULL charging_style → assume upfront (default histórico
// antes da migration 20260514000003). Pra dados antigos
// sem a coluna preenchida, evita virtuais ficarem sem
// propagação. 'saldo' explícito mantém siblings 'none'.
const upfrontPatients = new Set(
activePackages
.filter((c) => c.charging_style === 'upfront' || c.charging_style == null)
.map((c) => c.patient_id)
);
const ruleIdsToPropagate = [...ruleToPatient.entries()]
.filter(([, pid]) => upfrontPatients.has(pid))
.map(([rid]) => rid);
if (ruleIdsToPropagate.length) {
const { data: siblings } = await supabase
.from('agenda_eventos')
.select('id, recurrence_id')
.in('recurrence_id', ruleIdsToPropagate);
// Captura o package_price do contrato pra propagar
// valor real pra siblings (não o per-session que
// pode ter sido editado depois)
const contractPriceByPatient = new Map();
const { data: contractsDetail } = await supabase
.from('billing_contracts')
.select('patient_id, package_price')
.in('patient_id', [...upfrontPatients])
.eq('charging_style', 'upfront')
.eq('status', 'active');
for (const c of contractsDetail || []) {
contractPriceByPatient.set(c.patient_id, c.package_price);
}
for (const s of siblings || []) {
if (map[s.id] !== undefined) {
const stateToPropagate = ruleToState.get(s.recurrence_id) || 'pending';
// Não rebaixa: se sibling já está paid
// (record próprio paid), mantém.
if (map[s.id] !== 'paid') map[s.id] = stateToPropagate;
const pid = ruleToPatient.get(s.recurrence_id);
const price = contractPriceByPatient.get(pid);
if (price != null) amountMap[s.id] = price;
}
}
// Populate _rulePaymentMap pra que VIRTUAIS (ainda
// não materializadas) também herdem o estado ao
// passar pelo normalize.
for (const rid of ruleIdsToPropagate) {
const pid = ruleToPatient.get(rid);
const price = contractPriceByPatient.get(pid);
const state = ruleToState.get(rid) || 'pending';
ruleMap[rid] = { state, amount: price ?? null };
}
}
}
}
} catch (e) {
console.warn('[useMelissaAgenda] propagação upfront falhou:', e?.message);
}
_paymentStateMap.value = map;
_paymentAmountMap.value = amountMap;
_rulePaymentMap.value = ruleMap;
}
async function refetch() {
@@ -2130,18 +2286,23 @@ async function _createPackageContract({ rule, normalized, recorrencia, tenantId,
// charging_style: identifica como o pacote foi cobrado na criação;
// handler de status change usa pra decidir entre "só status" (upfront)
// ou "criar cobrança + consumir saldo" (saldo).
const { error: contractErr } = await supabase.from('billing_contracts').insert({
owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id,
type: 'package',
total_sessions: n,
sessions_used: 0,
package_price: packagePrice,
status: 'active',
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
});
const { data: createdContract, error: contractErr } = await supabase
.from('billing_contracts')
.insert({
owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id,
type: 'package',
total_sessions: n,
sessions_used: 0,
package_price: packagePrice,
status: 'active',
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
})
.select('id')
.single();
if (contractErr) throw contractErr;
const contractId = createdContract?.id ?? null;
// Estilo 'saldo': para aqui — sem cobrança imediata.
if (packageStyle === 'saldo') {
@@ -2180,6 +2341,7 @@ async function _createPackageContract({ rule, normalized, recorrencia, tenantId,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
price: packagePrice,
billing_contract_id: contractId,
visibility_scope: normalized.visibility_scope || 'public'
})
.select('id')