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
+92 -21
View File
@@ -1,39 +1,112 @@
# HANDOFF — 2026-05-19 (C1-C6 ✅, próximo C7 — pacote upfront)
# HANDOFF — 2026-05-19 noite (C1-C7 ✅, próximo C8 — pacote saldo)
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
> testes manuais dos 13 cenários do doc viva
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C6 ✅**.
> Próximo passo: **Cenário 7** (Donald Winnicott · pacote UPFRONT · 4 ×
> R$ 200 = cobrança única de R$ 800).
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C7 ✅**.
> Próximo: **Cenário 8** (Carl Jung · pacote SALDO · 4 × R$ 40 — modelo
> Cliniko: contrato sem cobrança imediata, cada sessão gera record ao
> ser realizada).
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 da manhã. Migration
> nova (20260519000001) já rodada no DB local. Pronto pra próximo bloco.
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 noite. Fase 6
> (lock-edit cobrada) ativada em Melissa também. Lock + popover atalho
> "Gerar fatura" + propagação cross-week de pacote upfront tudo
> funcionando.
---
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 7 (Pacote UPFRONT)
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 8 (Pacote SALDO)
| Campo | Valor |
|---|---|
| Paciente | **Donald Winnicott** |
| Paciente | **Carl Jung** |
| Frequência | **Semanal · 4 ocorrências** |
| Serviço | qualquer (ex: Sessão particular R$ 200) |
| Cobrança ao salvar | **Gerar cobrança** + estilo **"Pacote único (upfront)"** |
| Total esperado | **R$ 800** (4 × 200) |
| Serviço | Sessão (R$ 40 cada) |
| Cobrança ao salvar | **Pacote único** + estilo **"Saldo (Cliniko)"** |
| Total contrato | **R$ 160** (4 × 40) |
**Esperado:**
- 1 row em `recurrence_rules` + **1 row em `agenda_eventos`** (1ª materializada)
- 1 row em `billing_contracts` (type=package, charging_style=upfront, total_sessions=4, package_price=800)
- **1 row em `financial_records`** com amount=800, status=pending (cobrança ÚNICA do pacote, vencimento na 1ª sessão)
- Agenda: 1ª com badge $ (R$ 800 a cobrar) + 3 virtuais limpas (cobertas pelo pacote)
- 1 row em `recurrence_rules`
- **0 rows em `agenda_eventos`** materializadas inicialmente (saldo NÃO materializa 1ª)
- 1 row em `billing_contracts` (type=package, charging_style=**saldo**, total_sessions=4, package_price=160)
- **0 rows em `financial_records`** (sem cobrança imediata — modelo Cliniko)
- Agenda: 4 ocorrências virtuais **TODAS LIMPAS** (sem badge $, sem barra verde) — saldo intencionalmente não propaga estado, cada sessão gera cobrança individual quando vira realidade
Após C7: C8 (saldo) → C9 (per_session) → C10-C13 (status change + edit cobrada).
Diferença chave vs upfront:
- Upfront: 1 cobrança única (paga ou pendente) cobre todas as 4 sessões
- Saldo: contrato sem cobrança; cada sessão materializada GERA sua própria cobrança ao virar realidade (status realizado/faltou via flow C10-C12)
Após C8: C9 (per_session) → C10-C13 (status change + edit cobrada).
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
---
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira)
Testado e validado. Usuária criou Ana, R$ 200/sessão × 4 = R$ 800, marcou como pago em dinheiro pelo Financeiro. Visualização correta em mês AND em semana navegando pelas 4 semanas.
### Fase 6 (lock-edit cobrada) ativada em Melissa
Antes: `loadOccFinancialRecord` tinha guard `if (!props.occurrenceMode) return;` — só carregava em Rail/Clínica (edição de ocorrência). Em Melissa, `sessionPaymentRecord` paralelo alimentava só o Resumo lateral, sem trigger de lock.
Agora unificado: `occFinancialRecord` carrega em ambos modos:
- Card Sessão / Honorários ganha **Tag** (em vez de Select billingType) quando há cobrança
- Body do card mostra **Message "Cobrança de R$ X já emitida"** + cadeado
- Tipo de cobrança (Particular/Convênio/Gratuito) bloqueado
- Edição de serviços/preço bloqueada
### Propagação cross-week de pacote upfront pago/pendente
**Bug descoberto durante C7:** ao navegar pra semanas futuras (onde só virtual da Ana 2/3/4 aparecia, sem real event paid na view), o `_rulePaymentMap` era zerado pelo else branch do bulk-load → virtuais perdiam estado paid.
Fix em `useMelissaAgenda.js _reloadRange`:
- Maps (paymentStateMap, amountMap, rulePaymentMap) inicializados SEMPRE no início
- Propagação agora roda **independente de realIds.length** (ie, mesmo em semanas só-com-virtuais)
- Coleta `ruleIdsInView` de TODOS eventos da view (reais + virtuais com recurrence_id)
- Cross-week query: pra cada rule em view, busca QUALQUER evento sibling (inclusive em outras semanas) + seus records paid/pending → determina estado do contrato
- Propaga estado pra eventos reais (via map) + virtuais (via rulePaymentMap acessado pelo normalize)
### Atalho "Gerar fatura" no popover
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (`paymentVariant === 'none' && !is_occurrence`)
- Click → `gerarCobrancaManual` direto, fecha popover pra impedir double-click
- Tooltip: "Gerar fatura agora"
### 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 faz hard delete: `financial_records` pendentes → `agenda_eventos` materializados → `recurrence_rules` (CASCADE leva exceptions + rule_services)
- Bloqueia se algum record tem `status='paid'` (estornar primeiro)
### RPC `create_financial_record_for_session` ignora cancelled
**Migration 20260519000001:** idempotência da RPC passou a filtrar `AND status != 'cancelled'` além de `deleted_at IS NULL`. Antes: cancelar cobrança sem querer → todo "Gerar fatura" subsequente retornava o cancelado em vez de criar nova. Toast verde mentindo.
Memória durável em `memory/project_rpc_idempotency_cancelled.md`.
### `cancel_session` exception some da agenda
- `useRecurrence.expandRules` agora pula ocorrência com `exception.type === 'cancel_session'` (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha)
- `patient_missed` / `therapist_canceled` / `holiday_block` permanecem visíveis como histórico
### `recurrence_exceptions` cancel idempotente
- Cancel de ocorrência (virtual e materializada) usa `upsert` com `onConflict: 'recurrence_id,original_date'` — não quebra mais com unique violation quando há exception zumbi de tentativa anterior.
### Visualização paid/pending de upfront em virtuais
- `MelissaEventoPanel.showPaymentRow` antes excluía virtuais incondicionalmente. Agora só esconde quando `paymentState === 'none'` (saldo/sem pacote continua limpo; upfront propagado mostra).
- `MelissaAgenda.fcEvents`: removida exigência de `!is_occurrence` no `isPaidSession` e no badge $ pendente. Virtuais herdadas via propagação mostram borda verde/badge amber.
### `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.
### Confirmação 3 decisões UX (não codar)
Antes de C7, user perguntou e concordou:
1. Editar serviço já lançado e pago → **NÃO** (cobrança fiscal imutável)
2. Alternar Particular/Convênio/Gratuito em série com cobrança ativa → **NÃO** (mesma razão)
3. "Gerar fatura" extra em sessão coberta por contrato upfront → **NÃO** (duplicaria cobrança)
Tudo isso o lock-edit (Fase 6 ativada acima) cobre.
---
## 📦 O que foi feito em 18/05
### Cenário 4 (Joyce · "Já recebi") ✅
@@ -150,15 +223,13 @@ User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
| 4 | Avulsa "já recebi" no salvar | ✅ |
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
| **7** | **Pacote UPFRONT (Donald Winnicott 4 × R$ 200)** | 🔴 **PRÓXIMO** |
| 6 | Recorrente sem pacote (Anna Freud 4 sem) | ⏳ |
| 7 | Pacote upfront (Donald Winnicott 4× R$ 200) | ⏳ |
| 8 | Pacote saldo (Carl Jung 4× R$ 40) | ⏳ |
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
| **8** | **Pacote saldo (Carl Jung 4 × R$ 40)** | 🔴 **PRÓXIMO** |
| 9 | 1 por sessão (Michael Balint 12 sem) | ⏳ |
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
| 11 | Status change pacote saldo | ⏳ |
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
| 13 | Edit cobrada | ⏳ |
| 13 | Edit cobrada | ⏳ (parcialmente — lock ativo em Melissa pós-19/05 noite) |
---
+59
View File
@@ -14,6 +14,65 @@ Chronological, append-only record of everything that's happened in this wiki.
---
## [2026-05-19 23:00] session | C7 OK + Fase 6 lock em Melissa + cross-week propagation
Touched: project_cross_week_propagation (nova)
Detalhes: rodada longa cobrindo C7 (pacote upfront, Ana Souza Ferreira 4xR$200=R$800),
ativacao do lock-edit (Fase 6) em Melissa e correcao de bug cross-week
na propagacao de estado de pagamento.
CENARIO 7 (Pacote UPFRONT):
- User criou Ana, pagou os R$800 em dinheiro pelo Financeiro
- Borda verde + popover "Pago R$800" no card materializado funcionou
- 4 pills no dialog da serie, todas com "Cobranca paga R$800 do pacote"
ate as virtuais (via synthesized record em loadOccFinancialRecord)
LOCK-EDIT (Fase 6) ATIVADO EM MELISSA:
- Antes: loadOccFinancialRecord tinha guard occurrenceMode, so ativo
em Rail/Clinica. Em Melissa havia sessionPaymentRecord paralelo so
pro Resumo lateral (sem trigger de lock).
- Agora unificado: removido guard, occFinancialRecord carrega em ambos
modos. Card Sessao/Honorarios ganha Tag em vez de Select billingType
quando ha cobranca. Body mostra Message lock + cadeado.
AgendaEventoFinanceiroPanel so renderiza se record real (nao
sintetizado).
- Antes do codar, alinhei 3 perguntas de UX com user:
1) Editar servico pago? NAO (cobranca fiscal imutavel)
2) Alternar Particular/Convenio/Gratuito em serie cobrada? NAO
3) Gerar fatura individual em upfront? NAO (duplicaria cobranca)
CROSS-WEEK PROPAGATION (descoberto durante C7):
- Bug: virtuais isoladas em semanas futuras nao mostravam paid.
- Root cause: bulk-load tinha "if (realIds.length) { ... propagacao ... }
else { _rulePaymentMap.value = {} }". Quando user navegava pra semana
so com virtuais (sem reais), else zerava o ruleMap.
- Fix: maps inicializados SEMPRE, propagacao roda sempre, atribuicao
final tambem fora do if. Propagacao tambem ficou cross-week — pega
ruleIdsInView de TODOS eventos da view (real+virtual com recurrence_id),
busca records de QUALQUER evento da rule (em qualquer semana). Memoria
durador em project_cross_week_propagation.md.
OUTROS FIXES NA RODADA:
- Atalho "Gerar fatura" no popover (pill amber ao lado de "A cobrar R$ X").
- Info de pacote no header popover ("Sessao · Pacote · N sessoes").
- Botao "Excluir serie inteira" no popover (hard delete, bloqueia se
algum record paid).
- Migration 20260519000001: RPC create_financial_record_for_session
ignora cancelled na idempotencia. Memoria em
project_rpc_idempotency_cancelled.md.
- cancel_session exception some da agenda (era visivel com status
cancelado, doc dizia que sumia).
- recurrence_exceptions cancel agora upsert idempotente (nao quebra
com unique violation em re-cancel).
- onVerLancamentos busca records via siblings da serie pra virtuais de
upfront.
- Visualizacao: virtuais herdadas via propagacao agora mostram borda
verde + linha verde no popover (showPaymentRow e isPaidSession
relaxados pra paymentState !== 'none').
PROXIMO: Cenario 8 (Carl Jung pacote SALDO 4x R$40 = R$160).
Esperado: 0 records iniciais, 4 virtuais limpas (saldo NAO propaga —
cada sessao gera cobranca quando vira realidade).
## [2026-05-19 14:00] session | C5+C6 OK + atalho gerar fatura + RPC idempotencia fix
Touched: project_rpc_idempotency_cancelled (nova)
Detalhes: sessao longa cobrindo C5 e C6 ate green checkmark, com varios
@@ -965,12 +965,15 @@ const occBillingStatusSeverity = computed(() => {
// da cobrança (Pago / Pendente / Atrasada / Sem cobrança). Espelha
// os 3 canais visuais da agenda (verde pago / amber pendente /
// neutro). Sources:
// - sessionPaymentRecord (1 query em useAgendaEventLifecycle)
// - occFinancialRecord (lifecycle, agora carrega em ambos modos —
// guard de occurrenceMode foi removido em 2026-05-19 pra ativar
// lock-edit em Melissa tambem). Cobre record direto OU sintetizado
// a partir de contrato upfront ativo.
// - eventRow.price (fallback pra "Sem cobrança · R$ X" quando
// ainda nao ha record)
const paymentSummary = computed(() => {
if (!isSessionEvent.value) return null;
const rec = sessionPaymentRecord.value;
const rec = occFinancialRecord.value;
const fmtDate = (d) => {
if (!d) return '';
try {
@@ -1770,16 +1773,22 @@ onBeforeUnmount(() => {
/>
</div>
<div class="field-card__body">
<!-- LOCKED: cobranca ja emitida -->
<!-- LOCKED: cobranca ja emitida.
AgendaEventoFinanceiroPanel renderiza quando
o record é REAL (não sintetizado de contrato
upfront em sibling). Sintetizado: a sessão não
tem record próprio, então o panel exibiria
"Gerar cobrança" indevidamente. O lock Message
comunica o estado. -->
<template v-if="occFinancialRecord">
<Message severity="info" :closable="false" class="m-2">
<div class="text-sm">
<i class="pi pi-lock mr-1" />
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> emitida.
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> {{ occFinancialRecord._synthesized ? 'do pacote' : 'já emitida' }}.
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
</div>
</Message>
<AgendaEventoFinanceiroPanel :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
</template>
<!-- LOADING -->
@@ -2204,7 +2213,13 @@ onBeforeUnmount(() => {
<!-- Mobile: label visível; Desktop: label some e Select
ocupa o espaço. Mesmo padrão do Paciente e Data/Horário. -->
<span class="md:hidden">Sessão / Honorários</span>
<!-- Lock-edit (Fase 6, ativada em Melissa 2026-05-19):
quando cobrança paga/pendente OU contrato upfront
ativo, mostra Tag em vez do Select pra impedir
troca de billingType. -->
<Tag v-if="occFinancialRecord" :value="occBillingStatusLabel" :severity="occBillingStatusSeverity" class="ml-auto" />
<Select
v-else-if="!occFinancialLoading"
v-model="billingType"
:options="billingTypeOptions"
optionLabel="label"
@@ -2213,7 +2228,20 @@ onBeforeUnmount(() => {
class="aed-pay-mod-select ml-auto md:ml-0"
/>
</div>
<Transition name="aed-pay-expand">
<!-- LOCKED: cobrança emitida (real OU sintetizada via
contrato upfront pago/pendente). Trava edição de
tipo/serviços/preço. Ajustes via Financeiro. -->
<template v-if="occFinancialRecord">
<Message severity="info" :closable="false" class="m-2">
<div class="text-sm">
<i class="pi pi-lock mr-1" />
Cobrança de <b>{{ fmtBRL(occFinancialRecord.final_amount ?? occFinancialRecord.amount) }}</b> {{ occFinancialRecord._synthesized ? 'do pacote' : 'já emitida' }}.
Para alterar tipo ou serviços, ajuste a cobrança no Financeiro abaixo.
</div>
</Message>
<AgendaEventoFinanceiroPanel v-if="!occFinancialRecord._synthesized && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadOccFinancialRecord" />
</template>
<Transition v-else-if="!occFinancialLoading" name="aed-pay-expand">
<div class="field-card__body aed-pay-body">
<!-- Gratuito: aviso informativo, sem ações -->
<div v-if="billingType === 'gratuito'" class="aed-pay-gratuito">
@@ -2273,10 +2301,15 @@ onBeforeUnmount(() => {
</div>
</Transition>
<!-- Cobrança da sessão (financial_records ja criado)
em edição. Movido pra dentro do Sessao/Honorarios
em 2026-05-11 (antes ficava num side-card separado). -->
<AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="m-3" @cobranca-atualizada="loadSessionPaymentRecord" />
<!-- UNLOCKED: panel pra gerar cobrança inline (atalho do
popover é o caminho principal, mas mantido aqui pra
paridade com flow anterior). aparece sem record. -->
<AgendaEventoFinanceiroPanel
v-if="!occFinancialRecord && !occFinancialLoading && isEdit && eventRow?.id"
:evento="eventRow"
class="m-3"
@cobranca-atualizada="loadOccFinancialRecord"
/>
<!-- Botão "Ver lançamentos" (2026-05-14): abre dialog
listando todos os financial_records vinculados à
@@ -208,13 +208,26 @@ export function useAgendaEventLifecycle({
}
// ── occurrence financial record loader ────────────────────
// Tenta 3 caminhos pra encontrar um record relevante:
// 1) Record direto por agenda_evento_id (materializada com cobrança própria)
// 2) Se a sessão pertence a contrato upfront PAGO, sintetiza um record
// 'paid' com o package_price → assim TODAS as ocorrências da série
// mostram "Cobrança paga" no dialog (cobertas pelo pacote pago).
// 3) Senão, null → card unlocked.
async function loadOccFinancialRecord() {
occFinancialRecord.value = null;
if (!props.occurrenceMode) return;
// Guard de occurrenceMode REMOVIDO em 2026-05-19 — necessario pra
// que o lock de "edit cobrada" (Fase 6) tambem ative em Melissa,
// nao so em Rail/Clinica. Padrao SimplePractice: cobranca emitida
// eh imutavel pelo dialog; ajustes via estorno no Financeiro.
const evId = props.eventRow?.id;
if (!evId) return;
const ruleId = props.eventRow?.recurrence_id;
const patientId = props.eventRow?.paciente_id || props.eventRow?.patient_id;
occFinancialLoading.value = true;
try {
// 1) Record direto (materializada que tem agenda_evento_id real)
const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::');
if (evId && !isVirtualId) {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
@@ -224,7 +237,60 @@ export function useAgendaEventLifecycle({
.limit(1)
.maybeSingle();
if (error) throw error;
occFinancialRecord.value = data ?? null;
if (data) {
occFinancialRecord.value = data;
return;
}
}
// 2) Sintetiza record 'paid' quando há contrato upfront pago.
// A 1ª materializada tem o record real; siblings (virtuais ou
// materializadas sem cobrança individual) herdam status do
// contrato pra UI mostrar "Cobrança paga" coerentemente.
if (ruleId && patientId) {
const { data: contracts } = await supabase
.from('billing_contracts')
.select('id, package_price, charging_style, status')
.eq('patient_id', patientId)
.eq('type', 'package')
.eq('status', 'active')
.order('created_at', { ascending: false });
const upfront = (contracts || []).find((c) => c.charging_style === 'upfront');
if (upfront) {
// Confere se há record PAGO ligado a qualquer evento do
// mesmo recurrence_id (ou seja, contrato foi quitado).
const { data: siblingEvents } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId);
const ids = (siblingEvents || []).map((e) => e.id);
if (ids.length) {
// Pega o record mais recente NAO cancelado (paid OU
// pending OU overdue). Pacote upfront tem 1 record
// unico cobrindo toda a serie — qualquer status dele
// trava as siblings (cobranca ja emitida, imutavel).
const { data: anyRec } = await supabase
.from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.in('agenda_evento_id', ids)
.in('status', ['paid', 'pending', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (anyRec) {
// Sintetiza usando o package_price (não o per-session
// que pode ter mudado depois de editar).
occFinancialRecord.value = {
...anyRec,
amount: upfront.package_price,
final_amount: upfront.package_price,
_synthesized: true
};
return;
}
}
}
}
occFinancialRecord.value = null;
} catch (e) {
console.warn('[occurrence] erro ao carregar financial_record:', e?.message);
occFinancialRecord.value = null;
+14 -5
View File
@@ -395,13 +395,13 @@ const fcEvents = computed(() => {
const pStatus = ev.paciente_status;
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
// Sessão paga → barra esquerda verde (override do border-left que o
// FC pinta com a cor do commitment). Espelha as mesmas condições do
// badge $ amber: sessão + paciente + não-virtual; aqui inverte pra
// paymentState === 'paid'.
// FC pinta com a cor do commitment). Aplica a virtuais TAMBÉM quando
// herdam 'paid' de pacote upfront pago via propagação no bulk-load
// (sem essa propagação, virtuais ficam paymentState='none' e o
// check abaixo já as exclui).
const isPaidSession =
String(ev.tipo || '').toLowerCase() === 'sessao' &&
(ev.patient_id || ev.paciente_id) &&
!ev.is_occurrence &&
ev.paymentState === 'paid';
const cls = [];
if (isInactivePatient) cls.push('ma-evt--inactive-patient');
@@ -650,7 +650,16 @@ const fcOptions = computed(() => ({
// não poluir séries recorrentes com pacote upfront/saldo (cobertas
// pelo contrato, não por record-por-sessão).
let payBadgeHtml = '';
if (isSessao && ext.patient_id && !ext.is_occurrence && ext.paymentState !== 'paid') {
// Badge $ amber pendente: aparece pra sessão com paciente quando há
// cobrança pendente (paymentState='pending'/'overdue') OU quando é
// REAL sem cobrança ainda ('none'). Virtuais com 'none' (saldo,
// sem pacote, ou virtuais limpas) ficam SEM badge — só virtuais
// herdando 'pending' de pacote upfront mostram o badge.
const wantBadge = isSessao && ext.patient_id && ext.paymentState !== 'paid' && (
ext.paymentState === 'pending' || ext.paymentState === 'overdue' ||
(!ext.is_occurrence && (ext.paymentState === 'none' || !ext.paymentState))
);
if (wantBadge) {
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
}
+12 -4
View File
@@ -114,7 +114,11 @@ const isSessaoComPaciente = computed(
// — não polui séries com pacote upfront.
const showPaymentRow = computed(() => {
if (!isSessaoComPaciente.value) return false;
if (ev.value.is_occurrence) return false;
// Virtuais sem estado herdado de contrato ficam limpas (paymentState='none').
// Quando herdam 'paid' ou 'pending' de pacote upfront via propagação no
// bulk-load, exibem normalmente. Pacote saldo continua limpo (siblings
// ficam 'none' propositadamente — modelo Cliniko).
if (ev.value.is_occurrence && (!ev.value.paymentState || ev.value.paymentState === 'none')) return false;
return !!ev.value.paymentState;
});
const paymentVariant = computed(() => {
@@ -128,9 +132,13 @@ const paymentIcon = computed(() => {
});
const paymentLabel = computed(() => {
const state = ev.value.paymentState;
// Em sessão particular, valor mora em price. Em convênio, vai pra
// insurance_value (price = null). Fallback cobre os dois casos.
const valor = ev.value.price ?? ev.value.insurance_value;
// Pra estado 'paid', usar o VALOR REAL pago (paymentAmount, vem do
// financial_record). Em pacote upfront, é o package_price total —
// o evento.price pode ter sido editado depois e divergir. Em outros
// estados, fallback pro price/insurance_value do evento.
const valor = state === 'paid' && ev.value.paymentAmount != null
? ev.value.paymentAmount
: (ev.value.price ?? ev.value.insurance_value);
const valorFmt = (valor != null && !Number.isNaN(Number(valor)))
? Number(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
: null;
+40 -13
View File
@@ -881,23 +881,37 @@ async function confirmAnteciparPagamento() {
async function onVerLancamentos() {
const ev = eventoSelecionado.value;
if (!ev?.id) return;
// Ocorrência virtual ainda não foi materializada — id é sintético
// `rec::<rule>::<date>`, não bate com agenda_evento_id (uuid).
// Aborta sem query e avisa o user. 2026-05-14.
// Ocorrência virtual: id é sintético `rec::<rule>::<date>`, não bate com
// agenda_evento_id (uuid). Mas se a virtual pertence a uma série com
// contrato upfront, os records do contrato (linkados a sibling
// materializada) cobrem ela — busca via recurrence_id.
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
if (ev.is_occurrence || isVirtualId) {
toast.add({
severity: 'info',
summary: 'Sem lançamentos ainda',
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
life: 5000
});
return;
}
const isVirtual = ev.is_occurrence || isVirtualId;
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
lancamentosDialogOpen.value = true;
lancamentosLoading.value = true;
try {
let records = [];
if (isVirtual && ev.recurrence_id) {
// Pega records de QUALQUER sibling materializada (cobre o caso
// pacote upfront onde só a 1ª tem record do pacote inteiro).
const { data: siblings } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', ev.recurrence_id);
const ids = (siblings || []).map((s) => s.id);
if (ids.length) {
const { data, error } = await supabase
.from('financial_records')
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
.in('agenda_evento_id', ids)
.is('deleted_at', null)
.order('created_at', { ascending: true });
if (error) throw error;
records = data || [];
}
} else if (!isVirtual) {
const { data, error } = await supabase
.from('financial_records')
.select('id, description, amount, final_amount, status, due_date, paid_at, payment_method, created_at')
@@ -905,7 +919,20 @@ async function onVerLancamentos() {
.is('deleted_at', null)
.order('created_at', { ascending: true });
if (error) throw error;
lancamentosList.value = data || [];
records = data || [];
}
lancamentosList.value = records;
if (!records.length && isVirtual) {
// Caso virtual sem records de contrato (saldo, sem pacote): fecha
// o dialog e avisa que ainda não materializou (comportamento antigo).
lancamentosDialogOpen.value = false;
toast.add({
severity: 'info',
summary: 'Sem lançamentos ainda',
detail: 'Esta ocorrência ainda não foi materializada. Lançamentos aparecem após a primeira ação na sessão (status, edição etc).',
life: 5000
});
}
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao carregar lançamentos.', life: 4000 });
lancamentosList.value = [];
@@ -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);
if (realIds.length) {
// 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) {
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;
}
}
}
// 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;
} else {
_paymentStateMap.value = {};
}
_paymentAmountMap.value = amountMap;
_rulePaymentMap.value = ruleMap;
}
async function refetch() {
@@ -2130,7 +2286,9 @@ 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({
const { data: createdContract, error: contractErr } = await supabase
.from('billing_contracts')
.insert({
owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id,
@@ -2140,8 +2298,11 @@ async function _createPackageContract({ rule, normalized, recorrencia, tenantId,
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')